├── .gitattributes ├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── COPYING ├── LICENSE ├── README.md ├── SECURITY.md ├── agithub ├── AppVeyor.py ├── DigitalOcean.py ├── Facebook.py ├── GitHub.py ├── Maven.py ├── OpenWeatherMap.py ├── SalesForce.py ├── __init__.py ├── agithub_test.py ├── base.py └── test.py ├── setup.cfg ├── setup.py └── tox.ini /.gitattributes: -------------------------------------------------------------------------------- 1 | # http://krlmlr.github.io/using-gitattributes-to-avoid-merge-conflicts/ 2 | /CHANGELOG.md merge=union 3 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | name: Run Tox tests 10 | 11 | jobs: 12 | tox_test: 13 | name: Tox test 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12' ] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install install tox tox-gh-actions --upgrade pip 27 | - name: Run tox tests 28 | run: tox -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | *.py[co] 3 | MANIFEST 4 | dist/* 5 | agithub.egg-info/ 6 | build/ 7 | dist/ 8 | .tox 9 | .eggs 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [2.2.2] - 2019-10-07 10 | ### Fixed 11 | * Reverted the move to using setuptools-scm as it's [not actually meant to be 12 | used in the package code, only in setup.py](https://github.com/pypa/setuptools_scm/issues/354#issuecomment-519407730). 13 | setuptools-scm was causing an `LookupError: setuptools-scm was unable to detect version` error 14 | 15 | ## [2.2.1] - 2019-10-07 16 | ### Added 17 | * Mozilla code of conduct 18 | * Long description to setup.py containing README 19 | * End to end GitHub unit test and tox testing with pytest 20 | * Integration with Travis CI 21 | 22 | ### Changed 23 | * Moved to SCM (git) driven version instead of a hard coded one 24 | * VERSION constant from semver list (e.g. [2, 2, 1]) to string version (e.g. 2.2.1) 25 | 26 | ### Removed 27 | * mock module to avoid collision with builtin mock module 28 | * STR_VERSION constant 29 | 30 | ### Fixed 31 | * TypeError when paginate is `True` and `sleep_on_ratelimit` is the default (#66 by [@huma23](https://github.com/huma23)) 32 | 33 | ## [2.2.0] - 2019-01-16 34 | ### Added 35 | * GitHub pagination support, which can be enabled 36 | * GitHub rate limiting support, enabled by default 37 | 38 | ### Changed 39 | * Changelog format changed to keepachangelog 40 | 41 | ## [2.1] - 2018-04-13 42 | 43 | * Support XML de-serialization. (pick from [next-xml]) 44 | * Request body content-type serialization & charset encoding 45 | 46 | [next-xml]: 3d373435c8110612cad061e9a9b31a7a1abd752c 47 | 48 | ## [2.0] - 2016-01-16 49 | 50 | * Features: 51 | - Setup.py, for easy installation (Marcos Hernández) 52 | - Legit Python package 53 | - `url_prefix`: Ability to add an always-on prefix to the url for an API 54 | * Bugfixes: 55 | - Use `application/octet-stream` for unknown media type 56 | - Spell 'GitHub' correctly 57 | 58 | ## [1.3] - 2015-08-31 59 | 60 | A stable branch, with a lot of bug fixes! (Thanks to all who 61 | contributed!) 62 | 63 | * Feature: Unit tests (Uriel Corfa, Joachim Durchholz) 64 | * Grown-up Incomplete-request error message (Joachim Durchholz) 65 | * bug: PATCH method (ala) 66 | * bug: Allow using auth tokens without a username (Uriel Corfa) 67 | * bug: Set content-type to JSON when sending a JSON request 68 | (Jens Timmerman) 69 | 70 | ## [1.2] - 2014-06-14 71 | 72 | * Revamp the internals, adding extensibility and flexibility. Meanwhile, 73 | the external API (i.e. via the GitHub class) is entirely unchanged 74 | 75 | * New test-suite. It is ad-hoc and primitive, but effective 76 | 77 | * Generic support for other REST web services 78 | 79 | - New top-level class (API) 80 | - GitHub is now a subclass of the API class, and is the model for 81 | creating new subclasses 82 | - Facebook and SalesForce subclasses created, allowing (basic) 83 | access to these web services 84 | 85 | ## [1.1.1] - 2014-06-11 86 | * bug: Ensure Client.auth_header is always defined 87 | * bug: Python-3 support for password authentication 88 | 89 | ## [1.1] - 2014-06-06 90 | 91 | * Includes the version in the user-agent string 92 | 93 | ## 1.0 - 2014-06-06 94 | 95 | * Has a version number. (Yippie!) 96 | * First more-or-less stable version 97 | 98 | [Unreleased]: https://github.com/mozilla/agithub/compare/v2.2.2...HEAD 99 | [2.2.2]: https://github.com/mozilla/agithub/compare/v2.2.1...v2.2.2 100 | [2.2.1]: https://github.com/mozilla/agithub/compare/v2.2.0...v2.2.1 101 | [2.2.0]: https://github.com/mozilla/agithub/compare/v2.1...v2.2.0 102 | [2.1]: https://github.com/mozilla/agithub/compare/v2.0...v2.1 103 | [2.0]: https://github.com/mozilla/agithub/compare/v1.3...v2.0 104 | [1.3]: https://github.com/mozilla/agithub/compare/v1.2...v1.3 105 | [1.2]: https://github.com/mozilla/agithub/compare/v1.1.1...v1.2 106 | [1.1.1]: https://github.com/mozilla/agithub/compare/v1.1...v1.1.1 107 | [1.1]: https://github.com/mozilla/agithub/compare/v1.0...v1.1 108 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright 2012-2016 Jonathan Paugh and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Gene Wood 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Agnostic GitHub API 2 | 3 | > It doesn't know, and you don't care! 4 | 5 | `agithub` is a REST API client with transparent syntax which facilitates 6 | rapid prototyping — on *any* REST API! 7 | 8 | Originally tailored to the GitHub REST API, AGitHub has grown up to 9 | support many other REST APIs: 10 | 11 | * DigitalOcean 12 | * Facebook 13 | * GitHub 14 | * OpenWeatherMap 15 | * SalesForce 16 | 17 | Additionally, you can add *full support* for another REST API with very 18 | little new code! To see how, check out the [Facebook client], which has 19 | about 30 lines of code. 20 | 21 | This works because AGithub knows everything it needs to about protocol 22 | (REST, HTTP, TCP), but assumes nothing about your upstream API. 23 | 24 | [Facebook client]: agithub/Facebook.py 25 | 26 | # Use 27 | 28 | The most striking quality of AGitHub is how closely its syntax emulates 29 | HTTP. In fact, you might find it even more convenient than HTTP, and 30 | almost as general (as far as REST APIs go, anyway). The examples below 31 | tend to use the GitHub API as a reference point, but it is no less easy to 32 | use `agithub` with, say, the Facebook Graph. 33 | 34 | ## Create a client 35 | 36 | ```python 37 | from agithub.GitHub import GitHub 38 | client = GitHub() 39 | ``` 40 | 41 | ## GET 42 | 43 | Here's how to do a `GET` request, with properly-encoded url parameters: 44 | 45 | ```python 46 | client.issues.get(filter='subscribed') 47 | ``` 48 | 49 | That is equivalent to the following: 50 | 51 | ```http 52 | GET /issues/?filter=subscribed 53 | ``` 54 | 55 | ## POST 56 | 57 | Here's how to send a request body along with your request: 58 | 59 | ```python 60 | some_object = {'foo': 'bar'} 61 | client.video.upload.post(body=some_object, tags="social devcon") 62 | ``` 63 | 64 | This will send the following request, with `some_object` serialized as 65 | the request body:* 66 | 67 | ```http 68 | POST /video/upload?tags=social+devcon 69 | 70 | {"foo": "bar"} 71 | ``` 72 | 73 | The `body` parameter is reserved and is used to define the request body to be 74 | POSTed. `tags` is an example query parameter, showing that you can pass both 75 | an object to send as the request body as well as query parameters. 76 | 77 | * For now, the request body is limited to JSON data; but 78 | we plan to add support for other types as well 79 | 80 | ## Parameters 81 | 82 | ### `headers` 83 | 84 | Pass custom http headers in your ruquest with the reserved parameter `headers`. 85 | 86 | ```python 87 | from agithub.GitHub import GitHub 88 | g = GitHub() 89 | headers = {'Accept': 'application/vnd.github.symmetra-preview+json'} 90 | status, data = g.search.labels.get(headers=headers, repository_id=401025, q='¯\_(ツ)_/¯') 91 | print(data['items'][0]) 92 | ``` 93 | 94 | ```text 95 | {u'default': False, u'name': u'\xaf\\_(\u30c4)_/\xaf', u'url': u'https://api.github.com/repos/github/hub/labels/%C2%AF%5C_(%E3%83%84)_/%C2%AF', u'color': u'008672', u'node_id': u'MDU6TGFiZWwxMTcwNjYzNTM=', u'score': 43.937515, u'id': 117066353, u'description': u''} 96 | 97 | ``` 98 | 99 | ### `body` 100 | 101 | If you're using `POST`, `PUT`, or `PATCH` (`post()`, `put()`, or `patch()`), 102 | then you should include the body as the `body=` argument. The body is 103 | serialized to JSON before sending it out on the wire. 104 | 105 | ```python 106 | from agithub.GitHub import GitHub 107 | g = GitHub() 108 | # This Content-Type header is only required in this example due to a GitHub 109 | # requirement for this specific markdown.raw API endpoint 110 | headers={'Content-Type': 'text/plain'} 111 | body = '# This should be my header' 112 | status, data = g.markdown.raw.post(body=body, headers=headers) 113 | print(data) 114 | ``` 115 | 116 | ```text 117 |

118 | This should be my header

119 | 120 | ``` 121 | 122 | 123 | 124 | ## Example App 125 | 126 | 1. First, instantiate a `GitHub` object. 127 | 128 | ```python 129 | from agithub.GitHub import GitHub 130 | g = GitHub() 131 | ``` 132 | 133 | 2. When you make a request, the status and response body are passed back 134 | as a tuple. 135 | 136 | ```python 137 | status, data = g.users.octocat.get() 138 | print(data['name']) 139 | print(status) 140 | ``` 141 | 142 | ```text 143 | The Octocat 144 | 200 145 | ``` 146 | 147 | 3. If you forget the request method, `agithub` will complain that you 148 | haven't provided enough information to complete the request. 149 | 150 | ```python 151 | g.users 152 | ``` 153 | 154 | ```text 155 | : /users 156 | ``` 157 | 158 | 4. Sometimes, it is inconvenient (or impossible) to refer to a URL as a 159 | chain of attributes, so indexing syntax is provided as well. It 160 | behaves exactly the same. In these examples we use indexing syntax because 161 | you can't have a python function name 162 | 163 | * starting with a digit : `1` 164 | * containing a dash (`-`) character : `Spoon-Knife` 165 | 166 | ```python 167 | g.repos.github.hub.issues[1].get() 168 | g.repos.octocat['Spoon-Knife'].branches.get() 169 | ``` 170 | 171 | ```text 172 | (200, { 'id': '#blah', ... }) 173 | (200, [ list, of, branches ]) 174 | 175 | ``` 176 | 177 | 5. You can also pass query parameter to the API as function parameters to the 178 | method function (e.g. `get`). 179 | 180 | ```python 181 | status, data = g.repos.octocat['Spoon-Knife'].issues.get( 182 | state='all', creator='octocat') 183 | print(data[0].keys()) 184 | print(status) 185 | ``` 186 | 187 | ```text 188 | [u'labels', u'number', … , u'assignees'] 189 | 200 190 | ``` 191 | 192 | Notice the syntax here: 193 | `..()` 194 | 195 | * `API-object` : `g` 196 | * `URL-path` : `repos.octocat['Spoon-Knife'].issues` 197 | * `request-method` : `get` 198 | * `query-parameters` : `state='all', creator='octocat'` 199 | 200 | 6. As a weird quirk of the implementation, you may build a partial call 201 | to the upstream API, and use it later. 202 | 203 | ```python 204 | def following(self, user): 205 | return self.user.following[user].get 206 | 207 | myCall = following(g, 'octocat') 208 | if 204 == myCall()[0]: 209 | print 'You are following octocat' 210 | ``` 211 | 212 | ```text 213 | You are following octocat 214 | ``` 215 | 216 | You may find this useful — or not. 217 | 218 | 7. Finally, `agithub` knows nothing at all about the GitHub API, and it 219 | won't second-guess you. 220 | 221 | ```python 222 | g.funny.I.donna.remember.that.one.head() 223 | ``` 224 | 225 | ```text 226 | (404, {'message': 'Not Found'}) 227 | ``` 228 | 229 | The error message you get is directly from GitHub's API. This gives 230 | you all of the information you need to survey the situation. 231 | 232 | 7. If you need more information, the response headers of the previous 233 | request are available via the `getheaders()` method. 234 | 235 | ```python 236 | g.getheaders() 237 | ``` 238 | 239 | ```text 240 | [('status', '404 Not Found'), 241 | ('x-ratelimit-remaining', '54'), 242 | … 243 | ('server', 'GitHub.com')] 244 | ``` 245 | 246 | Note that the headers are standardized to all lower case. So though, in this 247 | example, GitHub returns a header of `X-RateLimit-Remaining` the header is 248 | returned from `getheaders` as `x-ratelimit-remaining` 249 | 250 | ## Error handling 251 | Errors are handled in the most transparent way possible: they are passed 252 | on to you for further scrutiny. There are two kinds of errors that can 253 | crop up: 254 | 255 | 1. Networking Exceptions (from the `http` library). Catch these with 256 | `try .. catch` blocks, as you otherwise would. 257 | 258 | 2. GitHub API errors. These mean you're doing something wrong with the 259 | API, and they are always evident in the response's status. The API 260 | considerately returns a helpful error message in the JSON body. 261 | 262 | ## Specific REST APIs 263 | 264 | `agithub` includes a handful of implementations for specific REST APIs. The 265 | example above uses the GitHub API but only for demonstration purposes. It 266 | doesn't include any GitHub specific functionality (for example, authentication). 267 | 268 | Here is a summary of additional functionality available for each distinct REST 269 | API with support included in `agithub`. Keep in mind, `agithub` is designed 270 | to be extended to any REST API and these are just an initial collection of APIs. 271 | 272 | ### GitHub : [`agithub/GitHub.py`](agithub/GitHub.py) 273 | 274 | #### GitHub Authentication 275 | 276 | To initiate an authenticated `GitHub` object, pass it your username and password 277 | or a [token](https://github.com/settings/tokens). 278 | 279 | ```python 280 | from agithub.GitHub import GitHub 281 | g = GitHub('user', 'pass') 282 | ``` 283 | 284 | ```python 285 | from agithub.GitHub import GitHub 286 | g = GitHub(token='token') 287 | ``` 288 | 289 | #### GitHub Pagination 290 | 291 | When calling the GitHub API with a query that returns many results, GitHub will 292 | [paginate](https://developer.github.com/v3/#pagination) the response, requiring 293 | you to request each page of results with separate API calls. If you'd like to 294 | automatically fetch all pages, you can enable pagination in the `GitHub` object 295 | by setting `paginate` to `True`. 296 | 297 | ```python 298 | from agithub.GitHub import GitHub 299 | g = GitHub(paginate=True) 300 | status, data = g.repos.octocat['Spoon-Knife'].issues.get() 301 | 302 | status, data = g.users.octocat.repos.get(per_page=1) 303 | print(len(data)) 304 | ``` 305 | 306 | ```text 307 | 8 308 | ``` 309 | 310 | (added in v2.2.0) 311 | 312 | #### GitHub Rate Limiting 313 | 314 | By default, if GitHub returns a response indicating that a request was refused 315 | due to [rate limiting](https://developer.github.com/v3/#rate-limiting), agithub 316 | will wait until the point in time when the rate limit is lifted and attempt 317 | the call again. 318 | 319 | If you'd like to disable this behavior and instead just return the error 320 | response from GitHub set `sleep_on_ratelimit` to `False`. 321 | 322 | ```python 323 | from agithub.GitHub import GitHub 324 | g = GitHub(sleep_on_ratelimit=False) 325 | status, data = g.repos.octocat['Spoon-Knife'].issues.get() 326 | print(status) 327 | print(data['message']) 328 | ``` 329 | 330 | ```text 331 | 403 332 | API rate limit exceeded for 203.0.113.2. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.) 333 | ``` 334 | 335 | (added in v2.2.0) 336 | 337 | #### GitHub Logging 338 | 339 | To see log messages related to GitHub specific features like pagination and 340 | rate limiting, you can use a root logger from the Python logging module. 341 | 342 | ```python 343 | import logging 344 | logging.basicConfig() 345 | logger = logging.getLogger() # The root logger 346 | logger.setLevel(logging.DEBUG) 347 | from agithub.GitHub import GitHub 348 | g = GitHub(paginate=True) 349 | status, data = g.repos.octocat['Spoon-Knife'].issues.get() 350 | ``` 351 | 352 | ```text 353 | DEBUG:agithub.GitHub:No GitHub ratelimit remaining. Sleeping for 676 seconds until 14:22:43 before trying API call again. 354 | DEBUG:agithub.GitHub:Fetching an additional paginated GitHub response page at https://api.github.com/repositories/1300192/issues?page=2 355 | DEBUG:agithub.GitHub:Fetching an additional paginated GitHub response page at https://api.github.com/repositories/1300192/issues?page=3 356 | … 357 | ``` 358 | 359 | ## Semantics 360 | 361 | Here's how `agithub` works, under the hood: 362 | 363 | 1. It translates a sequence of attribute look-ups into a URL; The 364 | Python method you call at the end of the chain determines the 365 | HTTP method to use for the request. 366 | 2. The Python method also receives `name=value` arguments, which it 367 | interprets as follows: 368 | * `headers=` 369 | * You can include custom headers as a dictionary supplied to the 370 | `headers=` argument. Some headers are provided by default (such as 371 | User-Agent). If these occur in the supplied dictionary, the default 372 | value will be overridden. 373 | 374 | ```python 375 | headers = {'Accept': 'application/vnd.github.loki-preview+json'} 376 | ``` 377 | * `body=` 378 | * If you're using `POST`, `PUT`, or `PATCH` (`post()`, `put()`, and 379 | `patch()`), then you should include the body as the `body=` argument. 380 | The body is serialized to JSON before sending it out on the wire. 381 | * GET Parameters 382 | * Any other arguments to the Python method become GET parameters, and are 383 | tacked onto the end of the URL. They are, of course, url-encoded for 384 | you. 385 | 3. When the response is received, `agithub` looks at its content 386 | type to determine how to handle it, possibly decoding it from the 387 | given char-set to Python's Unicode representation, then converting to 388 | an appropriate form, then passed to you along with the response 389 | status code. (A JSON object is de-serialized into a Python object.) 390 | 391 | ## Extensibility 392 | `agithub` has been written in an extensible way. You can easily: 393 | 394 | * Add new HTTP methods by extending the `Client` class with 395 | new Python methods of the same name (and adding them to the 396 | [`http_methods` list][1]). 397 | 398 | * Add new default headers to the [`_default_headers` dictionary][2]. 399 | Just make sure that the header names are lower case. 400 | 401 | * Add a new media-type (a.k.a. content-type a.k.a mime-type) by 402 | inserting a new method into the [`ResponseBody` class][3], replacing 403 | `'-'` and `'/'` with `'_'` in the method name. That method will then be 404 | responsible for converting the response body to a usable 405 | form — and for calling `decode_body` to do char-set 406 | conversion, if required. For example to create a handler for the content-type 407 | `application/xml` you'd extend `ResponseBody` and create a new method like this 408 | 409 | ```python 410 | import xml.etree.ElementTree as ET 411 | 412 | class CustomResponseBody(ResponseBody): 413 | def __init__(self): 414 | super(ChildB, self).__init__() 415 | 416 | def application_xml(self): 417 | # Handles Content-Type of "application/xml" 418 | return ET.fromstring(self.body) 419 | ``` 420 | 421 | And if all else fails, you can strap in, and take 15 minutes to read and 422 | become an expert on the code. From there, anything's possible. 423 | 424 | [1]: https://github.com/mozilla/agithub/blob/b47661df9e62224a69216a2f11dbe574990349d2/agithub/base.py#L103-L110 425 | [2]: https://github.com/mozilla/agithub/blob/b47661df9e62224a69216a2f11dbe574990349d2/agithub/base.py#L22-L28 426 | [3]: https://github.com/mozilla/agithub/blob/b47661df9e62224a69216a2f11dbe574990349d2/agithub/base.py#L309-L332 427 | 428 | ## License 429 | Copyright 2012–2016 Jonathan Paugh and contributors 430 | See [COPYING](COPYING) for license details 431 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Security Announcements 4 | 5 | Security announcements will be recorded in the [CHANGELOG](CHANGELOG.md). 6 | 7 | ## Reporting a Vulnerability 8 | 9 | To report a security vulnerability use the [GitHub Reporting a Security Vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) process on the [security tab](https://github.com/mozilla/agithub/security) 10 | 11 | ## Supported Versions 12 | 13 | agithub is an open source project and all support is community driven. As such 14 | there are no specific versions which are supported. The newest released 15 | version which should contain all security fixes can be found on the GitHub 16 | [releases page](https://github.com/mozilla/agithub/releases) 17 | -------------------------------------------------------------------------------- /agithub/AppVeyor.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2017 Jonathan Paugh and contributors 2 | # See COPYING for license details 3 | 4 | # AppVeyor REST API: https://www.appveyor.com/docs/api/ 5 | # Version 1.0 (2017-02-13) by topic2k@atlogger.de 6 | 7 | from agithub.base import API, ConnectionProperties, Client 8 | 9 | 10 | class AppVeyor(API): 11 | """ 12 | The agnostic AppVeyor API. It doesn't know, and you don't care. 13 | >>> from agithub.AppVeyor import AppVeyor 14 | >>> ci = AppVeyor('') 15 | >>> status, data = ci.api.projects.get() 16 | >>> data 17 | ... [ list_, of, stuff ] 18 | 19 | >>> status, data = ci.api.projects.topic2k.eventghost.get() 20 | >>> data 21 | ... { 'project': , } 22 | 23 | >>> account_name = 'topic2k' 24 | >>> project_slug = 'eventghost' 25 | >>> status, data = ci.api.projects[account_name][project_slug].get() 26 | ... same as above 27 | 28 | >>> status, data = ci.api.projects.topic2k.eventghost.buildcache.delete() 29 | >>> status 30 | ... 204 31 | 32 | That's all there is to it. (blah.post() should work, too.) 33 | 34 | NOTE: It is up to you to spell things correctly. An AppVeyor object 35 | doesn't even try to validate the url you feed it. On the other hand, 36 | it automatically supports the full API--so why should you care? 37 | """ 38 | def __init__(self, token, accept='application/json', *args, **kwargs): 39 | extra_headers = dict( 40 | Accept=accept, 41 | Authorization='Bearer {0}'.format(token) 42 | ) 43 | props = ConnectionProperties( 44 | api_url='ci.appveyor.com', 45 | secure_http=True, 46 | extra_headers=extra_headers 47 | ) 48 | self.setClient(Client(*args, **kwargs)) 49 | self.setConnectionProperties(props) 50 | -------------------------------------------------------------------------------- /agithub/DigitalOcean.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2016 Jonathan Paugh and contributors 2 | # See COPYING for license details 3 | from agithub.base import API, ConnectionProperties, Client 4 | 5 | 6 | class DigitalOcean(API): 7 | """ 8 | Digital Ocean API 9 | """ 10 | def __init__(self, token=None, *args, **kwargs): 11 | props = ConnectionProperties( 12 | api_url='api.digitalocean.com', 13 | url_prefix='/v2', 14 | secure_http=True, 15 | extra_headers={ 16 | 'authorization': self.generateAuthHeader(token) 17 | }, 18 | ) 19 | self.setClient(Client(*args, **kwargs)) 20 | self.setConnectionProperties(props) 21 | 22 | def generateAuthHeader(self, token): 23 | if token is not None: 24 | return "Bearer " + token 25 | return None 26 | -------------------------------------------------------------------------------- /agithub/Facebook.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2016 Jonathan Paugh and contributors 2 | # See COPYING for license details 3 | from agithub.base import API, ConnectionProperties, Client 4 | 5 | 6 | class Facebook(API): 7 | """ 8 | Facebook Graph API 9 | 10 | The following example taken from 11 | https://developers.facebook.com/docs/graph-api/quickstart/v2.0 12 | 13 | >>> fb = Facebook() 14 | >>> fb.facebook.picture.get(redirect='false') 15 | {u'data': {u'is_silhouette': False, 16 | u'url': 17 | u'https://fbcdn-profile-a.akamaihd.net/hprofile-ak-frc3/t1.0-1/p50x50/1377580_10152203108461729_809245696_n.png'}}) 18 | """ 19 | def __init__(self, *args, **kwargs): 20 | props = ConnectionProperties( 21 | api_url='graph.facebook.com', 22 | secure_http=True, 23 | ) 24 | self.setClient(Client(*args, **kwargs)) 25 | self.setConnectionProperties(props) 26 | -------------------------------------------------------------------------------- /agithub/GitHub.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2016 Jonathan Paugh and contributors 2 | # See COPYING for license details 3 | import base64 4 | import time 5 | import re 6 | import logging 7 | 8 | from agithub.base import ( 9 | API, ConnectionProperties, Client, RequestBody, ResponseBody) 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class GitHub(API): 15 | """ 16 | The agnostic GitHub API. It doesn't know, and you don't care. 17 | >>> from agithub.GitHub import GitHub 18 | >>> g = GitHub('user', 'pass') 19 | >>> status, data = g.issues.get(filter='subscribed') 20 | >>> data 21 | [ list_, of, stuff ] 22 | 23 | >>> status, data = g.repos.jpaugh.repla.issues[1].get() 24 | >>> data 25 | { 'dict': 'my issue data', } 26 | 27 | >>> name, repo = 'jpaugh', 'repla' 28 | >>> status, data = g.repos[name][repo].issues[1].get() 29 | same thing 30 | 31 | >>> status, data = g.funny.I.donna.remember.that.one.get() 32 | >>> status 33 | 404 34 | 35 | That's all there is to it. (blah.post() should work, too.) 36 | 37 | NOTE: It is up to you to spell things correctly. A GitHub object 38 | doesn't even try to validate the url you feed it. On the other hand, 39 | it automatically supports the full API--so why should you care? 40 | """ 41 | def __init__(self, username=None, password=None, token=None, 42 | *args, **kwargs): 43 | extraHeaders = {'accept': 'application/vnd.github.v3+json'} 44 | auth = self.generateAuthHeader(username, password, token) 45 | if auth is not None: 46 | extraHeaders['authorization'] = auth 47 | props = ConnectionProperties( 48 | api_url=kwargs.pop('api_url', 'api.github.com'), 49 | secure_http=True, 50 | extra_headers=extraHeaders 51 | ) 52 | 53 | self.setClient(GitHubClient(*args, **kwargs)) 54 | self.setConnectionProperties(props) 55 | 56 | def generateAuthHeader(self, username=None, password=None, token=None): 57 | if token is not None: 58 | if password is not None: 59 | raise TypeError( 60 | "You cannot use both password and oauth token " 61 | "authenication" 62 | ) 63 | return 'Token %s' % token 64 | elif username is not None: 65 | if password is None: 66 | raise TypeError( 67 | "You need a password to authenticate as " + username 68 | ) 69 | self.username = username 70 | return self.hash_pass(password) 71 | 72 | def hash_pass(self, password): 73 | auth_str = ('%s:%s' % (self.username, password)).encode('utf-8') 74 | return 'Basic '.encode('utf-8') + base64.b64encode(auth_str).strip() 75 | 76 | 77 | class GitHubClient(Client): 78 | def __init__(self, username=None, password=None, token=None, 79 | connection_properties=None, paginate=False, 80 | sleep_on_ratelimit=True): 81 | super(GitHubClient, self).__init__() 82 | self.paginate = paginate 83 | self.sleep_on_ratelimit = sleep_on_ratelimit 84 | 85 | def request(self, method, url, bodyData, headers): 86 | """Low-level networking. All HTTP-method methods call this""" 87 | 88 | headers = self._fix_headers(headers) 89 | url = self.prop.constructUrl(url) 90 | 91 | if bodyData is None: 92 | # Sending a content-type w/o the body might break some 93 | # servers. Maybe? 94 | if 'content-type' in headers: 95 | del headers['content-type'] 96 | 97 | # TODO: Context manager 98 | requestBody = RequestBody(bodyData, headers) 99 | 100 | if self.sleep_on_ratelimit and self.no_ratelimit_remaining(): 101 | self.sleep_until_more_ratelimit() 102 | 103 | while True: 104 | conn = self.get_connection() 105 | conn.request(method, url, requestBody.process(), headers) 106 | response = conn.getresponse() 107 | status = response.status 108 | content = ResponseBody(response) 109 | self.headers = response.getheaders() 110 | 111 | conn.close() 112 | if (status == 403 and self.sleep_on_ratelimit and 113 | self.no_ratelimit_remaining()): 114 | self.sleep_until_more_ratelimit() 115 | else: 116 | data = content.processBody() 117 | if self.paginate and type(data) is list: 118 | data.extend( 119 | self.get_additional_pages(method, bodyData, headers)) 120 | return status, data 121 | 122 | def get_additional_pages(self, method, bodyData, headers): 123 | data = [] 124 | url = self.get_next_link_url() 125 | if not url: 126 | return data 127 | logger.debug( 128 | 'Fetching an additional paginated GitHub response page at ' 129 | '{}'.format(url)) 130 | 131 | status, data = self.request(method, url, bodyData, headers) 132 | if type(data) is list: 133 | data.extend(self.get_additional_pages(method, bodyData, headers)) 134 | return data 135 | elif (status == 403 and self.no_ratelimit_remaining() 136 | and not self.sleep_on_ratelimit): 137 | raise TypeError( 138 | 'While fetching paginated GitHub response pages, the GitHub ' 139 | 'ratelimit was reached but sleep_on_ratelimit is disabled. ' 140 | 'Either enable sleep_on_ratelimit or disable paginate.') 141 | else: 142 | raise TypeError( 143 | 'While fetching a paginated GitHub response page, a non-list ' 144 | 'was returned with status {}: {}'.format(status, data)) 145 | 146 | def no_ratelimit_remaining(self): 147 | headers = dict(self.headers if self.headers is not None else []) 148 | ratelimit_remaining = int( 149 | headers.get('X-RateLimit-Remaining', 1)) 150 | return ratelimit_remaining == 0 151 | 152 | def ratelimit_seconds_remaining(self): 153 | ratelimit_reset = int(dict(self.headers).get( 154 | 'X-RateLimit-Reset', 0)) 155 | return max(0, int(ratelimit_reset - time.time()) + 1) 156 | 157 | def sleep_until_more_ratelimit(self): 158 | logger.debug( 159 | 'No GitHub ratelimit remaining. Sleeping for {} seconds until {} ' 160 | 'before trying API call again.'.format( 161 | self.ratelimit_seconds_remaining(), 162 | time.strftime( 163 | "%H:%M:%S", time.localtime( 164 | time.time() + self.ratelimit_seconds_remaining())) 165 | )) 166 | time.sleep(self.ratelimit_seconds_remaining()) 167 | 168 | def get_next_link_url(self): 169 | """Given a set of HTTP headers find the RFC 5988 Link header field, 170 | determine if it contains a relation type indicating a next resource and 171 | if so return the URL of the next resource, otherwise return an empty 172 | string. 173 | 174 | From https://github.com/requests/requests/blob/master/requests/utils.py 175 | """ 176 | for value in [x[1] for x in self.headers if x[0].lower() == 'link']: 177 | replace_chars = ' \'"' 178 | value = value.strip(replace_chars) 179 | if not value: 180 | return '' 181 | for val in re.split(', *<', value): 182 | try: 183 | url, params = val.split(';', 1) 184 | except ValueError: 185 | url, params = val, '' 186 | link = {'url': url.strip('<> \'"')} 187 | for param in params.split(';'): 188 | try: 189 | key, value = param.split('=') 190 | except ValueError: 191 | break 192 | link[key.strip(replace_chars)] = value.strip(replace_chars) 193 | if link.get('rel') == 'next': 194 | return link['url'] 195 | return '' 196 | -------------------------------------------------------------------------------- /agithub/Maven.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2016 Jonathan Paugh and contributors 2 | # See COPYING for license details 3 | from agithub.base import API, Client, ConnectionProperties 4 | 5 | 6 | class Maven(API): 7 | """ 8 | Maven Search API 9 | """ 10 | def __init__(self, *args, **kwargs): 11 | props = ConnectionProperties( 12 | api_url='search.maven.org', 13 | url_prefix='/solrsearch', 14 | secure_http=True, 15 | ) 16 | self.setClient(Client(*args, **kwargs)) 17 | self.setConnectionProperties(props) 18 | -------------------------------------------------------------------------------- /agithub/OpenWeatherMap.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2016 Jonathan Paugh and contributors 2 | # See COPYING for license details 3 | from agithub.base import API, ConnectionProperties, Client 4 | 5 | 6 | class OpenWeatherMap(API): 7 | """ 8 | Open Weather Map API 9 | """ 10 | def __init__(self, *args, **kwargs): 11 | props = ConnectionProperties( 12 | api_url='api.openweathermap.org', 13 | secure_http=False, 14 | ) 15 | self.setClient(Client(*args, **kwargs)) 16 | self.setConnectionProperties(props) 17 | -------------------------------------------------------------------------------- /agithub/SalesForce.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2016 Jonathan Paugh and contributors 2 | # See COPYING for license details 3 | from agithub.base import API, ConnectionProperties, Client 4 | 5 | 6 | class SalesForce(API): 7 | """ 8 | SalesForce.com REST API 9 | 10 | Example taken from 11 | http://www.salesforce.com/us/developer/docs/api_rest/index_Left.htm#CSHID=quickstart_code.htm|StartTopic=Content%2Fquickstart_code.htm|SkinName=webhelp 12 | 13 | >>> from SalesForce import SalesForce 14 | >>> sf = SalesForce() 15 | >>> sf.services.data.get() 16 | (200, 17 | [{u'label': u"Winter '11", 18 | u'url': u'/services/data/v20.0', 19 | u'version': u'20.0'}, 20 | ... 21 | {u'label': u"Spring '14", 22 | u'url': u'/services/data/v30.0', 23 | u'version': u'30.0'}]) 24 | 25 | SalseForce allows you to request either XML or JSON based on the 26 | URL "file extension," like so 27 | 28 | >>> sf.services["data.xml"].get() 29 | (200, ' ....') 30 | 31 | NB: XML is not automically decoded or de-serialized. Patch the 32 | ResponseBody class to fix this. 33 | """ 34 | def __init__(self, *args, **kwargs): 35 | props = ConnectionProperties( 36 | api_url='na1.salesforce.com', 37 | secure_http=True, 38 | ) 39 | self.setClient(Client(*args, **kwargs)) 40 | self.setConnectionProperties(props) 41 | -------------------------------------------------------------------------------- /agithub/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2016 Jonathan Paugh and contributors 2 | # See COPYING for license details 3 | from agithub.base import VERSION, STR_VERSION 4 | 5 | __all__ = ["VERSION", "STR_VERSION"] 6 | -------------------------------------------------------------------------------- /agithub/agithub_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2012-2016 Jonathan Paugh and contributors 3 | # See COPYING for license details 4 | from agithub.GitHub import GitHub 5 | from agithub.base import IncompleteRequest 6 | import unittest 7 | 8 | 9 | class Client(object): 10 | http_methods = ('demo', 'test') 11 | 12 | def __init__(self, username=None, password=None, token=None, 13 | connection_properties=None): 14 | pass 15 | 16 | def setConnectionProperties(self, props): 17 | pass 18 | 19 | def demo(self, *args, **params): 20 | return self.methodCalled('demo', *args, **params) 21 | 22 | def test(self, *args, **params): 23 | return self.methodCalled('test', *args, **params) 24 | 25 | def methodCalled(self, methodName, *args, **params): 26 | return { 27 | 'methodName': methodName, 28 | 'args': args, 29 | 'params': params 30 | } 31 | 32 | 33 | class TestGitHubObjectCreation(unittest.TestCase): 34 | def test_user_pw(self): 35 | gh = GitHub('korfuri', '1234') 36 | self.assertTrue(gh is not None) 37 | 38 | gh = GitHub(username='korfuri', password='1234') 39 | self.assertTrue(gh is not None) 40 | 41 | def test_token(self): 42 | gh = GitHub(username='korfuri', token='deadbeef') 43 | self.assertTrue(gh is not None) 44 | 45 | gh = GitHub(token='deadbeef') 46 | self.assertTrue(gh is not None) 47 | 48 | def test_token_password(self): 49 | with self.assertRaises(TypeError): 50 | GitHub(username='korfuri', password='1234', token='deadbeef') 51 | 52 | 53 | class TestIncompleteRequest(unittest.TestCase): 54 | 55 | def newIncompleteRequest(self): 56 | return IncompleteRequest(Client()) 57 | 58 | def test_pathByGetAttr(self): 59 | rb = self.newIncompleteRequest() 60 | rb.hug.an.octocat 61 | self.assertEqual(rb.url, "/hug/an/octocat") 62 | 63 | def test_callMethodDemo(self): 64 | rb = self.newIncompleteRequest() 65 | self.assertEqual( 66 | rb.path.demo(), 67 | { 68 | "methodName": "demo", 69 | "args": (), 70 | "params": {"url": "/path"} 71 | } 72 | ) 73 | 74 | def test_pathByGetItem(self): 75 | rb = self.newIncompleteRequest() 76 | rb["hug"][1]["octocat"] 77 | self.assertEqual(rb.url, "/hug/1/octocat") 78 | 79 | def test_callMethodTest(self): 80 | rb = self.newIncompleteRequest() 81 | self.assertEqual( 82 | rb.path.demo(), 83 | { 84 | "methodName": "demo", 85 | "args": (), 86 | "params": {"url": "/path"} 87 | } 88 | ) 89 | 90 | 91 | def test_github(): 92 | g = GitHub() 93 | status, data = g.users.octocat.get() 94 | assert data.get('name') == 'The Octocat' 95 | assert status == 200 96 | # Workaround to https://github.com/mozilla/agithub/issues/67 97 | response_headers = dict([(x.lower(), y) for x, y in g.getheaders()]) 98 | assert ( 99 | response_headers.get('Content-Type'.lower()) == 100 | 'application/json; charset=utf-8') 101 | 102 | 103 | if __name__ == '__main__': 104 | unittest.main() 105 | -------------------------------------------------------------------------------- /agithub/base.py: -------------------------------------------------------------------------------- 1 | # Copyright 2012-2016 Jonathan Paugh and contributors 2 | # See COPYING for license details 3 | import json 4 | from functools import partial, update_wrapper 5 | 6 | import sys 7 | if sys.version_info[0:2] > (3, 0): 8 | from http.client import HTTPConnection, HTTPSConnection 9 | from urllib.parse import urlencode 10 | else: 11 | from httplib import HTTPConnection, HTTPSConnection 12 | from urllib import urlencode 13 | 14 | class ConnectionError(OSError): 15 | pass 16 | 17 | VERSION = [2, 2, 2] 18 | STR_VERSION = 'v' + '.'.join(str(v) for v in VERSION) 19 | 20 | # These headers are implicitly included in each request; however, each 21 | # can be explicitly overridden by the client code. (Used in Client 22 | # objects.) 23 | _default_headers = { 24 | 'user-agent': 'agithub/' + STR_VERSION, 25 | 'content-type': 'application/json' 26 | } 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 IncompleteRequest(self.client).__getattr__(key) 51 | __getitem__ = __getattr__ 52 | 53 | def __repr__(self): 54 | return IncompleteRequest(self.client).__repr__() 55 | 56 | def getheaders(self): 57 | return self.client.headers 58 | 59 | 60 | class IncompleteRequest(object): 61 | """ 62 | IncompleteRequests are partially-built HTTP requests. 63 | They can be built via an HTTP-idiomatic notation, 64 | or via "normal" method calls. 65 | 66 | Specifically, 67 | >>> IncompleteRequest(client).path.to.resource.METHOD(...) 68 | is equivalent to 69 | >>> IncompleteRequest(client).client.METHOD('path/to/resource', ...) 70 | where METHOD is replaced by get, post, head, etc. 71 | 72 | Also, if you use an invalid path, too bad. Just be ready to handle a 73 | bad status code from the upstream API. (Or maybe an 74 | httplib.error...) 75 | 76 | You can use item access instead of attribute access. This is 77 | convenient for using variables\' values and is required for numbers. 78 | >>> GitHub('user','pass').whatever[1][x][y].post() 79 | 80 | To understand the method(...) calls, check out github.client.Client. 81 | """ 82 | def __init__(self, client): 83 | self.client = client 84 | self.url = '' 85 | 86 | def __getattr__(self, key): 87 | if key in self.client.http_methods: 88 | htmlMethod = getattr(self.client, key) 89 | wrapper = partial(htmlMethod, url=self.url) 90 | return update_wrapper(wrapper, htmlMethod) 91 | else: 92 | self.url += '/' + str(key) 93 | return self 94 | 95 | __getitem__ = __getattr__ 96 | 97 | def __str__(self): 98 | return self.url 99 | 100 | def __repr__(self): 101 | return '%s: %s' % (self.__class__, self.url) 102 | 103 | 104 | class Client(object): 105 | http_methods = ( 106 | 'head', 107 | 'get', 108 | 'post', 109 | 'put', 110 | 'delete', 111 | 'patch', 112 | ) 113 | 114 | default_headers = {} 115 | headers = None 116 | 117 | def __init__(self, username=None, password=None, token=None, 118 | connection_properties=None): 119 | self.prop = None 120 | 121 | # Set up connection properties 122 | if connection_properties is not None: 123 | self.setConnectionProperties(connection_properties) 124 | 125 | def setConnectionProperties(self, prop): 126 | """ 127 | Initialize the connection properties. This must be called 128 | (either by passing connection_properties=... to __init__ or 129 | directly) before any request can be sent. 130 | """ 131 | if type(prop) is not ConnectionProperties: 132 | raise TypeError( 133 | "Client.setConnectionProperties: " 134 | "Expected ConnectionProperties object" 135 | ) 136 | 137 | if prop.extra_headers is not None: 138 | prop.filterEmptyHeaders() 139 | self.default_headers = _default_headers.copy() 140 | self.default_headers.update(prop.extra_headers) 141 | self.prop = prop 142 | 143 | # Enforce case restrictions on self.default_headers 144 | tmp_dict = {} 145 | for k, v in self.default_headers.items(): 146 | tmp_dict[k.lower()] = v 147 | self.default_headers = tmp_dict 148 | 149 | def head(self, url, headers=None, **params): 150 | headers = headers or {} 151 | url += self.urlencode(params) 152 | return self.request('HEAD', url, None, headers) 153 | 154 | def get(self, url, headers=None, **params): 155 | headers = headers or {} 156 | url += self.urlencode(params) 157 | return self.request('GET', url, None, headers) 158 | 159 | def post(self, url, body=None, headers=None, **params): 160 | headers = headers or {} 161 | url += self.urlencode(params) 162 | if 'content-type' not in headers: 163 | headers['content-type'] = 'application/json' 164 | return self.request('POST', url, body, headers) 165 | 166 | def put(self, url, body=None, headers=None, **params): 167 | headers = headers or {} 168 | url += self.urlencode(params) 169 | if 'content-type' not in headers: 170 | headers['content-type'] = 'application/json' 171 | return self.request('PUT', url, body, headers) 172 | 173 | def delete(self, url, headers=None, **params): 174 | headers = headers or {} 175 | url += self.urlencode(params) 176 | return self.request('DELETE', url, None, headers) 177 | 178 | def patch(self, url, body=None, headers=None, **params): 179 | """ 180 | Do a http patch request on the given url with given body, 181 | headers and parameters. 182 | Parameters is a dictionary that will will be urlencoded 183 | """ 184 | headers = headers or {} 185 | url += self.urlencode(params) 186 | if 'content-type' not in headers: 187 | headers['content-type'] = 'application/json' 188 | return self.request('PATCH', url, body, headers) 189 | 190 | def request(self, method, url, bodyData, headers): 191 | """ 192 | Low-level networking. All HTTP-method methods call this 193 | """ 194 | 195 | headers = self._fix_headers(headers) 196 | url = self.prop.constructUrl(url) 197 | 198 | if bodyData is None: 199 | # Sending a content-type w/o the body might break some 200 | # servers. Maybe? 201 | if 'content-type' in headers: 202 | del headers['content-type'] 203 | 204 | # TODO: Context manager 205 | requestBody = RequestBody(bodyData, headers) 206 | conn = self.get_connection() 207 | conn.request(method, url, requestBody.process(), headers) 208 | response = conn.getresponse() 209 | status = response.status 210 | content = ResponseBody(response) 211 | self.headers = response.getheaders() 212 | 213 | conn.close() 214 | return status, content.processBody() 215 | 216 | def _fix_headers(self, headers): 217 | # Convert header names to a uniform case 218 | tmp_dict = {} 219 | for k, v in headers.items(): 220 | tmp_dict[k.lower()] = v 221 | headers = tmp_dict 222 | 223 | # Add default headers (if unspecified) 224 | for k, v in self.default_headers.items(): 225 | if k not in headers: 226 | headers[k] = v 227 | return headers 228 | 229 | def urlencode(self, params): 230 | if not params: 231 | return '' 232 | return '?%s' % urlencode(params) 233 | 234 | def get_connection(self): 235 | if self.prop.secure_http: 236 | conn = HTTPSConnection(self.prop.api_url) 237 | elif self.prop.extra_headers is None \ 238 | or 'authorization' not in self.prop.extra_headers: 239 | conn = HTTPConnection(self.prop.api_url) 240 | else: 241 | raise ConnectionError( 242 | 'Refusing to send the authorization header over an ' 243 | 'insecure connection.' 244 | ) 245 | 246 | return conn 247 | 248 | 249 | class Body(object): 250 | """ 251 | Superclass for ResponseBody and RequestBody 252 | """ 253 | def parseContentType(self, ctype): 254 | """ 255 | Parse the Content-Type header, returning the media-type and any 256 | parameters 257 | """ 258 | if ctype is None: 259 | self.mediatype = 'application/octet-stream' 260 | self.ctypeParameters = {'charset': 'ISO-8859-1'} 261 | return 262 | 263 | params = ctype.split(';') 264 | self.mediatype = params.pop(0).strip() 265 | 266 | # Parse parameters 267 | if len(params) > 0: 268 | params = map(lambda s: s.strip().split('='), params) 269 | paramDict = {} 270 | for attribute, value in params: 271 | # TODO: Find out if specifying an attribute multiple 272 | # times is even okay, and how it should be handled 273 | attribute = attribute.lower() 274 | if attribute in paramDict: 275 | if type(paramDict[attribute]) is not list: 276 | # Convert singleton value to value-list 277 | paramDict[attribute] = [paramDict[attribute]] 278 | # Insert new value along with pre-existing ones 279 | paramDict[attribute] += value 280 | else: 281 | # Insert singleton attribute value 282 | paramDict[attribute] = value 283 | self.ctypeParameters = paramDict 284 | else: 285 | self.ctypeParameters = {} 286 | 287 | if 'charset' not in self.ctypeParameters: 288 | self.ctypeParameters['charset'] = 'ISO-8859-1' 289 | # NB: INO-8859-1 is specified (RFC 2068) as the default 290 | # charset in case none is provided 291 | 292 | def mangled_mtype(self): 293 | """ 294 | Mangle the media type into a suitable function name 295 | """ 296 | return self.mediatype.replace('-', '_').replace('/', '_') 297 | 298 | 299 | class ResponseBody(Body): 300 | """ 301 | Decode a response from the server, respecting the Content-Type field 302 | """ 303 | def __init__(self, response): 304 | self.response = response 305 | self.body = response.read() 306 | self.parseContentType(self.response.getheader('Content-Type')) 307 | self.encoding = self.ctypeParameters['charset'] 308 | 309 | def decode_body(self): 310 | """ 311 | Decode (and replace) self.body via the charset encoding 312 | specified in the content-type header 313 | """ 314 | self.body = self.body.decode(self.encoding) 315 | 316 | def processBody(self): 317 | """ 318 | Retrieve the body of the response, encoding it into a usuable 319 | form based on the media-type (mime-type) 320 | """ 321 | handlerName = self.mangled_mtype() 322 | handler = getattr(self, handlerName, self.application_octect_stream) 323 | return handler() 324 | 325 | # media-type handlers 326 | 327 | def application_octect_stream(self): 328 | """ 329 | Handler for unknown media-types. It does absolutely no 330 | pre-processing of the response body, so it cannot mess it up 331 | """ 332 | return self.body 333 | 334 | def application_json(self): 335 | """ 336 | Handler for application/json media-type 337 | """ 338 | self.decode_body() 339 | 340 | try: 341 | pybody = json.loads(self.body) 342 | except ValueError: 343 | pybody = self.body 344 | 345 | return pybody 346 | 347 | text_javascript = application_json 348 | # XXX: This isn't technically correct, but we'll hope for the best. 349 | # Patches welcome! 350 | # Insert new media-type handlers here 351 | 352 | 353 | class RequestBody(Body): 354 | """ 355 | Encode a request body from the client, respecting the Content-Type 356 | field 357 | """ 358 | def __init__(self, body, headers): 359 | self.body = body 360 | self.headers = headers 361 | self.parseContentType(self.headers.get('content-type', None)) 362 | self.encoding = self.ctypeParameters['charset'] 363 | 364 | def encodeBody(self): 365 | """ 366 | Encode (and overwrite) self.body via the charset encoding 367 | specified in the request headers. This should be called by the 368 | media-type handler when appropriate 369 | """ 370 | self.body = self.body.encode(self.encoding) 371 | 372 | def process(self): 373 | """ 374 | Process the request body by applying a media-type specific 375 | handler to it. 376 | """ 377 | if self.body is None: 378 | return None 379 | 380 | handlerName = self.mangled_mtype() 381 | handler = getattr(self, handlerName, self.application_octet_stream) 382 | return handler() 383 | 384 | # media-type handlers 385 | 386 | def application_octet_stream(self): 387 | """ 388 | Handler for binary data and unknown media-types. Importantly, 389 | it does absolutely no pre-processing of the body, which means it 390 | will not mess it up. 391 | """ 392 | return self.body 393 | 394 | def application_json(self): 395 | self.body = json.dumps(self.body) 396 | self.encodeBody() 397 | return self.body 398 | 399 | # Insert new Request media-type handlers here 400 | 401 | 402 | class ConnectionProperties(object): 403 | __slots__ = ['api_url', 'url_prefix', 'secure_http', 'extra_headers'] 404 | 405 | def __init__(self, **props): 406 | # Initialize attribute slots 407 | for key in self.__slots__: 408 | setattr(self, key, None) 409 | 410 | # Fill attribute slots with custom values 411 | for key, val in props.items(): 412 | if key not in ConnectionProperties.__slots__: 413 | raise TypeError("Invalid connection property: " + str(key)) 414 | else: 415 | setattr(self, key, val) 416 | 417 | def constructUrl(self, url): 418 | if self.url_prefix is None: 419 | return url 420 | return self.url_prefix + url 421 | 422 | def filterEmptyHeaders(self): 423 | if self.extra_headers is not None: 424 | self.extra_headers = self._filterEmptyHeaders(self.extra_headers) 425 | 426 | def _filterEmptyHeaders(self, headers): 427 | newHeaders = {} 428 | for header in headers.keys(): 429 | if header is not None and header != "": 430 | newHeaders[header] = headers[header] 431 | 432 | return newHeaders 433 | -------------------------------------------------------------------------------- /agithub/test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2012-2016 Jonathan Paugh and contributors 3 | # See COPYING for license details 4 | from __future__ import print_function 5 | from agithub.GitHub import GitHub 6 | 7 | ## 8 | # Test harness 9 | ### 10 | 11 | # Test results 12 | Pass = 'Pass' 13 | Fail = 'Fail' 14 | Skip = 'Skip' 15 | 16 | 17 | class Test(object): 18 | _the_label = 'test' 19 | _the_testno = 0 20 | tests = {} 21 | 22 | def gatherTests(self, testObj): 23 | for test in dir(self): 24 | if test.startswith('test_'): 25 | self.tests[test] = getattr(testObj, test) 26 | print(self.tests) 27 | 28 | def doTestsFor(self, api): 29 | """Run all tests over the given API session""" 30 | results = [] 31 | for name, test in self.tests.items(): 32 | self._the_label = name 33 | results.append(self.runTest(test, api)) 34 | 35 | fails = skips = passes = 0 36 | for res in results: 37 | if res == Pass: 38 | passes += 1 39 | elif res == Fail: 40 | fails += 1 41 | elif res == Skip: 42 | skips += 1 43 | else: 44 | raise ValueError('Bad test result ' + (res)) 45 | 46 | print( 47 | '\n' 48 | ' Results\n' 49 | '--------------------------------------\n' 50 | 'Tests Run: ', len(results), '\n' 51 | ' Passed: ', passes, '\n' 52 | ' Failed: ', fails, '\n' 53 | ' Skipped: ', skips 54 | ) 55 | 56 | def runTest(self, test, api): 57 | """Run a single test with the given API session""" 58 | self._the_testno += 1 59 | (stat, _) = test(api) 60 | 61 | global Pass, Skip, Fail 62 | 63 | if stat in [Pass, Fail, Skip]: 64 | return stat 65 | elif stat < 400: 66 | result = Pass 67 | elif stat >= 500: 68 | result = Skip 69 | else: 70 | result = Fail 71 | 72 | self.label(result) 73 | return result 74 | 75 | def setlabel(self, lbl): 76 | """Set the global field _the_label, which is used by runTest""" 77 | self._the_label += ' ' + lbl 78 | 79 | def label(self, result): 80 | """Print out a test label showing the result""" 81 | print(result + ':', self._the_testno, self._the_label) 82 | 83 | def haveAuth(self, api): 84 | username = getattr(api.client, 'username', NotImplemented) 85 | if username == NotImplemented or username is None: 86 | return False 87 | else: 88 | return True 89 | 90 | 91 | ## 92 | # Tests 93 | ### 94 | class Basic (Test): 95 | def __init__(self): 96 | self.gatherTests(self) 97 | 98 | def test_zen(self, api): 99 | self.setlabel('Zen') 100 | return api.zen.get() 101 | 102 | def test_head(self, api): 103 | self.setlabel('HEAD') 104 | return api.head() 105 | 106 | def test_userRepos(self, api): 107 | if not self.haveAuth(api): 108 | return (Skip, ()) 109 | 110 | return api.user.repos.head() 111 | 112 | ## 113 | # Utility 114 | ### 115 | 116 | 117 | # Session initializers 118 | def initAnonymousSession(klass): 119 | return klass() 120 | 121 | 122 | def initAuthenticatedSession(klass, **kwargs): 123 | for k in kwargs: 124 | if k not in ['username', 'password', 'token']: 125 | raise ValueError('Invalid test parameter: ' + str(k)) 126 | 127 | return klass(**kwargs) 128 | 129 | 130 | # UI 131 | def yesno(ans): 132 | """Convert user input (Yes or No) to a boolean""" 133 | ans = ans.lower() 134 | if ans == 'y' or ans == 'yes': 135 | return True 136 | else: 137 | return False 138 | 139 | 140 | ## 141 | # Main 142 | ### 143 | 144 | if __name__ == '__main__': 145 | anonSession = initAnonymousSession(GitHub) 146 | authSession = None 147 | 148 | ans = input( 149 | 'Some of the tests require an authenticated session. ' 150 | 'Do you want to provide a username and password [y/N]? ' 151 | ) 152 | 153 | if yesno(ans): 154 | username = input('Username: ') 155 | password = input('Password (plain text): ') 156 | authSession = initAuthenticatedSession( 157 | GitHub, 158 | username=username, 159 | password=password, 160 | ) 161 | 162 | tests = filter(lambda var: var.startswith('test_'), globals().copy()) 163 | tester = Basic() 164 | 165 | print('Unauthenticated tests') 166 | tester.doTestsFor(anonSession) 167 | 168 | print() 169 | if authSession is None: 170 | print('Skipping Authenticated tests') 171 | else: 172 | print('Authenticated tests') 173 | tester.doTestsFor(authSession) 174 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [egg_info] 2 | tag_build = 3 | tag_date = 0 4 | tag_svn_revision = 0 5 | 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from os import path 3 | 4 | here = path.abspath(path.dirname(__file__)) 5 | with open(path.join(here, 'README.md')) as f: 6 | long_description = f.read() 7 | 8 | version = '2.2.2' 9 | 10 | setup(name='agithub', 11 | version=version, 12 | description="A lightweight, transparent syntax for REST clients", 13 | long_description=long_description, 14 | long_description_content_type='text/markdown', 15 | classifiers=[ 16 | 'Development Status :: 5 - Production/Stable', 17 | 'Environment :: Console', 18 | 'Intended Audience :: Developers', 19 | 'Operating System :: OS Independent', 20 | 'Programming Language :: Python :: 2.6', 21 | 'Programming Language :: Python :: 3', 22 | 'Topic :: Utilities', 23 | ], 24 | keywords=['api', 'REST', 'GitHub', 'Facebook', 'SalesForce'], 25 | author='Jonathan Paugh', 26 | author_email='jpaugh@gmx.us', 27 | url='https://github.com/mozilla/agithub', 28 | license='MIT', 29 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 30 | include_package_data=True, 31 | zip_safe=False, 32 | ) 33 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38, py39, py310, py311, py312, flake8 3 | 4 | [gh-actions] 5 | python = 6 | 3.8: py38 7 | 3.9: py39 8 | 3.10: py310 9 | 3.11: py311 10 | 3.12: py312, flake8 11 | 12 | [testenv:flake8] 13 | basepython = python 14 | deps = flake8 15 | commands = flake8 agithub setup.py 16 | 17 | [testenv] 18 | setenv = 19 | PYTHONPATH = {toxinidir} 20 | deps = pytest 21 | commands = 22 | pytest {posargs} 23 | --------------------------------------------------------------------------------