├── .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 |
--------------------------------------------------------------------------------