Skip to content

Commit b352336

Browse files
committed
Add chapter 4
1 parent 348741f commit b352336

File tree

3 files changed

+680
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)