|
| 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