forked from mozilla/agithub
-
Notifications
You must be signed in to change notification settings - Fork 0
/
agithub.py
374 lines (302 loc) · 11.9 KB
/
agithub.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# Copyright 2012-2014 Jonathan Paugh
# See COPYING for license details
import json
import base64
import re
from functools import partial, update_wrapper
import sys
if sys.version_info[0:2] > (3,0):
import http.client
import urllib.parse
else:
import httplib as http
http.client = http
import urllib as urllib
urllib.parse = urllib
VERSION = [1,2]
STR_VERSION = 'v' + '.'.join(str(v) for v in VERSION)
# These headers are implicitly included in each request; however, each
# can be explicitly overridden by the client code. (Used in Client
# objects.)
_default_headers = {
#XXX: Header field names MUST be lowercase; this is not checked
'user-agent': 'agithub/' + STR_VERSION
}
class API(object):
'''
The toplevel object, and the "entry-point" into the client API.
Subclass this to develop an application for a particular REST API.
Model your __init__ after the Github example.
'''
def __init__(self, *args, **kwargs):
raise Exception (
'Please subclass API and override __init__() to'
'provide a ConnectionProperties object. See the Github'
' class for an example'
)
def setClient(self, client):
self.client = client
def setConnectionProperties(self, props):
self.client.setConnectionProperties(props)
def __getattr__(self, key):
return RequestBuilder(self.client).__getattr__(key)
__getitem__ = __getattr__
def __repr__(self):
return RequestBuilder(self.client).__repr__()
def getheaders(self):
return self.client.headers
class Github(API):
'''The agnostic Github API. It doesn't know, and you don't care.
>>> from agithub import Github
>>> g = Github('user', 'pass')
>>> status, data = g.issues.get(filter='subscribed')
>>> data
... [ list_, of, stuff ]
>>> status, data = g.repos.jpaugh.repla.issues[1].get()
>>> data
... { 'dict': 'my issue data', }
>>> name, repo = 'jpaugh', 'repla'
>>> status, data = g.repos[name][repo].issues[1].get()
... same thing
>>> status, data = g.funny.I.donna.remember.that.one.get()
>>> status
... 404
That's all there is to it. (blah.post() should work, too.)
NOTE: It is up to you to spell things correctly. A Github object
doesn't even try to validate the url you feed it. On the other hand,
it automatically supports the full API--so why should you care?
'''
def __init__(self, *args, **kwargs):
props = ConnectionProperties(
api_url = 'api.github.com',
secure_http = True,
extra_headers = {
'accept' : 'application/vnd.github.v3+json'
}
)
self.setClient(Client(*args, **kwargs))
self.setConnectionProperties(props)
class RequestBuilder(object):
'''RequestBuilders build HTTP requests via an HTTP-idiomatic notation,
or via "normal" method calls.
Specifically,
>>> RequestBuilder(client).path.to.resource.METHOD(...)
is equivalent to
>>> RequestBuilder(client).client.METHOD('path/to/resource, ...)
where METHOD is replaced by get, post, head, etc.
Also, if you use an invalid path, too bad. Just be ready to catch a
bad status from github.com. (Or maybe an httplib.error...)
You can use item access instead of attribute access. This is
convenient for using variables\' values and required for numbers.
>>> Github('user','pass').whatever[1][x][y].post()
To understand the method(...) calls, check out github.client.Client.
'''
def __init__(self, client):
self.client = client
self.url = ''
def __getattr__(self, key):
if key in self.client.http_methods:
mfun = getattr(self.client, key)
fun = partial(mfun, url=self.url)
return update_wrapper(fun, mfun)
else:
self.url += '/' + str(key)
return self
__getitem__ = __getattr__
def __str__(self):
'''If you ever stringify this, you've (probably) messed up
somewhere. So let's give a semi-helpful message.
'''
return "I don't know about " + self.url
def __repr__(self):
return '%s: %s' % (self.__class__, self.url)
class Client(object):
http_methods = (
'head',
'get',
'post',
'put',
'delete',
'patch',
)
default_headers = {}
headers = None
def __init__(self, username=None,
password=None, token=None,
connection_properties=None
):
# Set up connection properties
if connection_properties is not None:
self.setConnectionProperties(connection_properties)
# Set up authentication
self.auth_header = None
if token is not None:
if password is not None:
raise TypeError("You cannot use both password and oauth token authenication")
self.auth_header = 'Token %s' % token
elif username is not None:
if password is None:
raise TypeError("You need a password to authenticate as " + username)
self.username = username
self.auth_header = self.hash_pass(password)
def setConnectionProperties(self, props):
'''
Initialize the connection properties. This must be called
(either by passing connection_properties=... to __init__ or
directly) before any request can be sent.
'''
if type(props) is not ConnectionProperties:
raise TypeError("Client.setConnectionProperties: Expected ConnectionProperties object")
self.prop = props
if self.prop.extra_headers is not None:
self.default_headers = _default_headers.copy()
self.default_headers.update(self.prop.extra_headers)
# Enforce case restrictions on self.default_headers
tmp_dict = {}
for k,v in self.default_headers.items():
tmp_dict[k.lower()] = v
self.default_headers = tmp_dict
def head(self, url, headers={}, **params):
url += self.urlencode(params)
return self.request('HEAD', url, None, headers)
def get(self, url, headers={}, **params):
url += self.urlencode(params)
return self.request('GET', url, None, headers)
def post(self, url, body=None, headers={}, **params):
url += self.urlencode(params)
if not 'content-type' in headers:
# We're doing a json.dumps of body, so let's set the content-type to json
headers['content-type'] = 'application/json'
return self.request('POST', url, json.dumps(body), headers)
def put(self, url, body=None, headers={}, **params):
url += self.urlencode(params)
if not 'content-type' in headers:
# We're doing a json.dumps of body, so let's set the content-type to json
headers['content-type'] = 'application/json'
return self.request('PUT', url, json.dumps(body), headers)
def delete(self, url, headers={}, **params):
url += self.urlencode(params)
return self.request('DELETE', url, None, headers)
def patch(self, url, body=None, headers={}, **params):
"""
Do a http patch request on the given url with given body, headers and parameters
Parameters is a dictionary that will will be urlencoded
"""
url += self.urlencode(params)
if not 'content-type' in headers:
# We're doing a json.dumps of body, so let's set the content-type to json
headers['content-type'] = 'application/json'
return self.request('PATCH', url, json.dumps(body), headers)
def request(self, method, url, body, headers):
'''Low-level networking. All HTTP-method methods call this'''
headers = self._fix_headers(headers)
if self.auth_header:
headers['authorization'] = self.auth_header
#TODO: Context manager
conn = self.get_connection()
conn.request(method, url, body, headers)
response = conn.getresponse()
status = response.status
content = Content(response)
self.headers = response.getheaders()
conn.close()
return status, content.processBody()
def _fix_headers(self, headers):
# Convert header names to a uniform case
tmp_dict = {}
for k,v in headers.items():
tmp_dict[k.lower()] = v
headers = tmp_dict
# Add default headers (if unspecified)
for k,v in self.default_headers.items():
if k not in headers:
headers[k] = v
return headers
def urlencode(self, params):
if not params:
return ''
return '?' + urllib.parse.urlencode(params)
def hash_pass(self, password):
auth_str = ('%s:%s' % (self.username, password)).encode('utf-8')
return 'Basic '.encode('utf-8') + base64.b64encode(auth_str).strip()
def get_connection(self):
if self.prop.secure_http:
conn = http.client.HTTPSConnection(self.prop.api_url)
elif self.auth_header is None:
conn = http.client.HTTPConnection(self.prop.api_url)
else:
raise ConnectionError(
'Refusing to authenticate over non-secure (HTTP) connection.')
return conn
class Content(object):
'''
Decode a response from the server, respecting the Content-Type field
'''
def __init__(self, response):
self.response = response
self.body = response.read()
(self.mediatype, self.encoding) = self.get_ctype()
def get_ctype(self):
'''Split the content-type field into mediatype and charset'''
ctype = self.response.getheader('Content-Type')
start = 0
end = 0
try:
end = ctype.index(';')
mediatype = ctype[:end]
except:
mediatype = 'x-application/unknown'
try:
start = 8 + ctype.index('charset=', end)
end = ctype.index(';', start)
charset = ctype[start:end].rstrip()
except:
charset = 'ISO-8859-1' #TODO
return (mediatype, charset)
def decode_body(self):
'''
Decode (and replace) self.body via the charset encoding
specified in the content-type header
'''
self.body = self.body.decode(self.encoding)
def processBody(self):
'''
Retrieve the body of the response, encoding it into a usuable
form based on the media-type (mime-type)
'''
handlerName = self.mangled_mtype()
handler = getattr(self, handlerName, self.x_application_unknown)
return handler()
def mangled_mtype(self):
'''
Mangle the media type into a suitable function name
'''
return self.mediatype.replace('-','_').replace('/','_')
## media-type handlers
def x_application_unknown(self):
'''Handler for unknown media-types'''
return self.body
def application_json(self):
'''Handler for application/json media-type'''
self.decode_body()
try:
pybody = json.loads(self.body)
except ValueError:
pybody = self.body
return pybody
text_javascript = application_json
# XXX: This isn't technically correct, but we'll hope for the best.
# Patches welcome!
# Insert new media-type handlers here
class ConnectionProperties(object):
__slots__ = ['api_url', 'secure_http', 'extra_headers']
def __init__(self, **props):
# Initialize attribute slots
for key in self.__slots__:
setattr(self, key, None)
# Fill attribute slots with custom values
for key, val in props.items():
if key not in ConnectionProperties.__slots__:
raise TypeError("Invalid connection property: " + str(key))
else:
setattr(self, key, val)