github-bot-api 0.7.0__py3-none-any.whl → 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  __author__ = "Niklas Rosenstein <nrosenstein@palantir.com>"
2
- __version__ = "0.7.0"
2
+ __version__ = "0.7.1"
3
3
 
4
4
  from .app import GithubApp
5
5
  from .event import Event, accept_event
github_bot_api/app.py CHANGED
@@ -6,8 +6,11 @@ import dataclasses
6
6
  import logging
7
7
  import sys
8
8
  import threading
9
+ import time
9
10
  import typing as t
11
+ from urllib.parse import parse_qs, urlencode
10
12
 
13
+ import deprecated
11
14
  import requests
12
15
  import urllib3
13
16
 
@@ -75,9 +78,22 @@ class GithubApp:
75
78
  app_id: int
76
79
  """GitHub Application ID."""
77
80
 
78
- private_key: str
81
+ private_key: str = dataclasses.field(repr=False)
79
82
  """RSA private key to sign the JWT with."""
80
83
 
84
+ client_id: t.Optional[str] = None
85
+ """The GitHub App's OAuth client ID. This is required for OAuth2 authorization URL generation. Can be omitted
86
+ if the app does not use OAuth2."""
87
+
88
+ client_secret: t.Optional[str] = dataclasses.field(default=None, repr=False)
89
+ """The GitHub App's OAuth client secret. This is required for OAuth2 authorization URL generation. Can be omitted
90
+ if the app does not use OAuth2. Note that this must be specified if #client_id is specified."""
91
+
92
+ redirect_uri: t.Optional[str] = None
93
+ """The GitHub App's OAuth redirect URI. This is required for OAuth2 authorization URL generation. Can be omitted
94
+ if the app does not use OAuth2. This field is optional, but required for the web authorization flow with
95
+ #oauth2_web_application_flow_url()."""
96
+
81
97
  v3_api_url: str = PUBLIC_GITHUB_V3_API_URL
82
98
  """GitHub API base URL. Defaults to the public GitHub API."""
83
99
 
@@ -86,6 +102,13 @@ class GithubApp:
86
102
  self._lock = threading.Lock()
87
103
  self._installation_tokens: t.Dict[int, InstallationTokenSupplier] = {}
88
104
 
105
+ if self.client_id is not None:
106
+ if self.client_secret is None:
107
+ raise ValueError("client_secret must be specified if client_id is specified.")
108
+ if self.redirect_uri is not None:
109
+ if self.client_id is None:
110
+ raise ValueError("redirect_uri does not make sense without client_id.")
111
+
89
112
  def _get_base_github_client_settings(self) -> GithubClientSettings:
90
113
  return GithubClientSettings(self.v3_api_url, self.get_user_agent())
91
114
 
@@ -140,7 +163,11 @@ class GithubApp:
140
163
  headers={"Authorization": auth_header, "User-Agent": user_agent},
141
164
  ).json()
142
165
 
166
+ @deprecated.deprecated(reason="Use .installation_token_supplier() instead.", version="0.8.0")
143
167
  def get_installation_token_supplier(self, installation_id: int) -> InstallationTokenSupplier:
168
+ return self.installation_token_supplier(installation_id)
169
+
170
+ def installation_token_supplier(self, installation_id: int) -> InstallationTokenSupplier:
144
171
  """
145
172
  Create an #InstallationTokenSupplier for your GitHub application to act within the scope of the given
146
173
  *installation_id*.
@@ -186,3 +213,168 @@ class GithubApp:
186
213
  token = self.installation_token(installation_id).value
187
214
  settings = self._get_base_github_client_settings().update(settings)
188
215
  return settings.make_client(login_or_token=token)
216
+
217
+ def oauth2_web_application_flow_url(self, state: t.Optional[str] = None) -> str:
218
+ """
219
+ Returns the URL for a user to begin the OAuth2 web authorization flow.
220
+
221
+ Preconditions:
222
+ - You must have provided a `client_id` when constructing the #GithubApp instance.
223
+
224
+ Documentation: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-web-application-flow-to-generate-a-user-access-token
225
+ """
226
+
227
+ if self.client_id is None:
228
+ raise ValueError("client_id must be specified to generate OAuth2 authorization URL.")
229
+ if self.client_secret is None:
230
+ raise ValueError("client_secret must be specified to generate OAuth2 authorization URL.")
231
+ if not self.redirect_uri:
232
+ raise ValueError("redirect_uri must be specified to generate OAuth2 authorization URL.")
233
+
234
+ params = {
235
+ "client_id": self.client_id,
236
+ "redirect_uri": self.redirect_uri,
237
+ }
238
+ if state is not None:
239
+ params["state"] = state
240
+
241
+ url = self.v3_api_url.replace("api.", "").replace("/api/v3", "").rstrip("/")
242
+ return f"{url}/login/oauth/authorize?" + urlencode(params)
243
+
244
+ def oauth2_device_flow(self) -> "OAuth2DeviceCodeFlow":
245
+ """
246
+ Makes a request to GitHub to request a device code for the OAuth2 device flow.
247
+
248
+ Prerequisites:
249
+
250
+ - "Enable Device Flow" must be checked in your GitHub app's settings.
251
+ - You must have provided a `client_id` when constructing the #GithubApp instance.
252
+
253
+ Documentation: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-device-flow-to-generate-a-user-access-token
254
+ """
255
+
256
+ params = {"client_id": self.client_id}
257
+ url = self.v3_api_url.replace("api.", "").replace("/api/v3", "").rstrip("/")
258
+ response = requests.post(f"{url}/login/device/code", params=params)
259
+ response.raise_for_status()
260
+ payload = {k: v[0] for k, v in parse_qs(response.text).items()}
261
+ return OAuth2DeviceCodeFlow(
262
+ device_code=payload["device_code"],
263
+ user_code=payload["user_code"],
264
+ verification_uri=payload["verification_uri"],
265
+ expires_in=int(payload["expires_in"]),
266
+ interval=int(payload["interval"]),
267
+ app=self,
268
+ )
269
+
270
+ def oauth2_access_token(
271
+ self, *, code: t.Optional[str] = None, device_code: t.Optional[str] = None
272
+ ) -> t.Optional["OAuth2TokenInfo"]:
273
+ """
274
+ Makes a request to GitHub to exchange an OAuth2 code for an access token.
275
+
276
+ Important: You must provide the correct `code` or `device_code` parameter, but not both.
277
+
278
+ Documentation: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#generating-a-user-access-token-when-a-user-installs-your-app
279
+
280
+ Returns None if the token is not yet available (in the case of the device flow). Otherwise, returns a
281
+ #TokenInfo object. If an error occurs, an #AccessTokenError is raised.
282
+ """
283
+
284
+ params = {"client_id": self.client_id, "client_secret": self.client_secret}
285
+ if code is not None:
286
+ params["code"] = code
287
+ elif device_code is not None:
288
+ params["device_code"] = device_code
289
+ params["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code"
290
+ else:
291
+ raise ValueError("You must provide either a code or a device_code.")
292
+
293
+ url = self.v3_api_url.replace("api.", "").replace("/api/v3", "").rstrip("/")
294
+ response = requests.post(f"{url}/login/oauth/access_token", params=params)
295
+ payload = {k: v[0] for k, v in parse_qs(response.text).items()}
296
+
297
+ if (error := payload.get("error")) == "authorization_pending":
298
+ return None
299
+ elif error is not None:
300
+ return OAuth2TokenInfo(
301
+ access_token=payload["access_token"],
302
+ expires_in=int(payload["expires_in"]),
303
+ scope=payload.get("scope"),
304
+ token_type=payload["token_type"],
305
+ refresh_token=payload.get("refresh_token"),
306
+ refresh_token_expires_in=int(payload["refresh_token_expires_in"])
307
+ if "refresh_token_expires_in" in payload
308
+ else None,
309
+ )
310
+ else:
311
+ raise AccessTokenError(error) # type: ignore[arg-type]
312
+
313
+
314
+ @dataclasses.dataclass
315
+ class OAuth2DeviceCodeFlow:
316
+ device_code: str
317
+ user_code: str
318
+ verification_uri: str
319
+ expires_in: int
320
+ interval: int
321
+
322
+ # If you want to use this object to poll for the token, you need the GitHub App.
323
+ app: t.Optional[GithubApp] = None
324
+
325
+ def wait_for_token(
326
+ self, aborted: t.Optional[threading.Event] = None, max_duration: t.Optional[float] = None
327
+ ) -> "OAuth2TokenInfo":
328
+ """
329
+ Polls GitHub for the access token until it is available.
330
+
331
+ If you pass in a threading.Event object, it will be used to abort the polling when the
332
+ event is set. If *max_duration* is specified, the polling will stop after that many seconds and
333
+ raise a #TimeoutError if the token is not available by then.
334
+ """
335
+
336
+ assert self.app is not None, "You must set the 'app' attribute to use this method."
337
+
338
+ tstart = time.perf_counter()
339
+
340
+ while True:
341
+ if max_duration is not None and time.perf_counter() - tstart > max_duration:
342
+ raise TimeoutError("Timed out waiting for token.")
343
+ if aborted and aborted.is_set():
344
+ raise RuntimeError("Polling for token was aborted.")
345
+ try:
346
+ if token := self.app.oauth2_access_token(device_code=self.device_code):
347
+ return token
348
+ except AccessTokenError as e:
349
+ if e.error == "slow_down":
350
+ time.sleep(5)
351
+ else:
352
+ raise
353
+ time.sleep(self.interval)
354
+
355
+
356
+ @dataclasses.dataclass
357
+ class OAuth2TokenInfo:
358
+ access_token: str
359
+ expires_in: int
360
+ token_type: str
361
+ scope: t.Optional[str] = None
362
+ refresh_token: t.Optional[str] = None
363
+ refresh_token_expires_in: t.Optional[int] = None
364
+
365
+ @property
366
+ def auth_header(self) -> str:
367
+ return f"{self.token_type} {self.access_token}"
368
+
369
+
370
+ @dataclasses.dataclass
371
+ class AccessTokenError(Exception):
372
+ error: t.Literal[
373
+ "slow_down",
374
+ "expired_token",
375
+ "unsupported_grant_type",
376
+ "incorrect_client_credentials",
377
+ "incorrect_device_code",
378
+ "access_denied",
379
+ "device_flow_disabled",
380
+ ]
github_bot_api/token.py CHANGED
@@ -59,7 +59,7 @@ def create_jwt(app_id: int, expires_in: int, private_key: str) -> TokenInfo:
59
59
 
60
60
  now = int(time.time())
61
61
  exp = now + expires_in
62
- payload = {"iss": app_id, "iat": now, "exp": exp}
62
+ payload = {"iss": str(app_id), "iat": now, "exp": exp}
63
63
  token = jwt.encode(payload, private_key, algorithm="RS256")
64
64
  return TokenInfo(exp, "Bearer", token)
65
65
 
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github-bot-api
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: API for creating GitHub bots and webhooks in Python.
5
5
  Author-Email: Niklas Rosenstein <rosensteinniklas@gmail.com>
6
6
  License: MIT
7
7
  Classifier: Intended Audience :: Developers
8
8
  Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.9
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
13
12
  Classifier: Programming Language :: Python :: 3.12
@@ -15,15 +14,14 @@ Classifier: Programming Language :: Python :: 3.13
15
14
  Project-URL: Bug Tracker, https://github.com/NiklasRosenstein/python-github-bot-api/issues
16
15
  Project-URL: Documentation, https://niklasrosenstein.github.io/python-github-bot-api/
17
16
  Project-URL: Repository, https://github.com/NiklasRosenstein/python-github-bot-api
18
- Requires-Python: <4.0,>=3.9
17
+ Requires-Python: <4.0,>=3.10
19
18
  Requires-Dist: cryptography<44.0.1,>=44.0.0
20
19
  Requires-Dist: pygithub>=2.5.0
21
20
  Requires-Dist: PyJWT<3.0.0,>=2.6.0
22
21
  Requires-Dist: requests<3.0.0,>=2.28.2
23
- Requires-Dist: urllib3<2.4.0,>=2.3.0
22
+ Requires-Dist: urllib3<2.6.4,>=2.6.3
24
23
  Description-Content-Type: text/markdown
25
24
 
26
- <p align="center"><img src="https://i.imgur.com/5SiDsz8.png"></p>
27
25
  <h1 align="center">python-github-bot-api</h1>
28
26
  <p align="center">
29
27
  <a href="https://pypi.org/project/github-bot-api"><img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/github-bot-api"></a></p>
@@ -32,18 +30,31 @@ Description-Content-Type: text/markdown
32
30
 
33
31
  A thin Python library for creating GitHub bots and webhooks in Python with [PyGithub].
34
32
 
33
+ ## Quickstart
34
+
35
35
  ```python
36
- from github import Github
37
36
  from github_bot_api import GithubApp
38
37
  from pathlib import Path
39
38
 
40
39
  app = GithubApp(
41
40
  user_agent='my-bot/0.0.0',
42
- app_id="67890",
41
+ app_id="12345",
43
42
  private_key=Path("app-private.key").read_text(),
44
43
  )
44
+ ```
45
45
 
46
- client: Github = app.installation_client(12345)
46
+ Create a PyGithub client for the app itself:
47
+
48
+ ```python
49
+ from github import Github
50
+ client: Github = app.app_client()
51
+ ```
52
+
53
+ Create a PyGithub client for an app's installation scope:
54
+
55
+ ```python
56
+ from github import Github
57
+ client: Github = app.installation_client(45678)
47
58
  ```
48
59
 
49
60
  For more examples, check out the [documentation](https://niklasrosenstein.github.io/python-github-bot-api/).
@@ -1,19 +1,19 @@
1
- github_bot_api-0.7.0.dist-info/METADATA,sha256=5LKKkccaGa0K0a1vHe6rUUMfXE0XW9spXjd5c-eQc64,1913
2
- github_bot_api-0.7.0.dist-info/WHEEL,sha256=thaaA2w1JzcGC48WYufAs8nrYZjJm8LqNfnXFOFyCC4,90
3
- github_bot_api-0.7.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
- github_bot_api-0.7.0.dist-info/licenses/LICENSE,sha256=bDVcEAS5rCEemhGT133WyYPEWhviXjCvQ46Pi_1gInQ,998
5
- github_bot_api/__init__.py,sha256=s4tkqx8qRBA66ELE6S583mEIi-REkLl_jW5mC4ubl1c,239
6
- github_bot_api/app.py,sha256=nRnQCkK_3OpPUknq8KpeWqMppgqunXTEsZAcArgC5mk,6575
1
+ github_bot_api-0.7.1.dist-info/METADATA,sha256=z8jP-zLCXol5QlS0LumejKZt3VZZ0KQ3_8KICmWQh60,2007
2
+ github_bot_api-0.7.1.dist-info/WHEEL,sha256=Wb0ASbVj8JvWHpOiIpPi7ucfIgJeCi__PzivviEAQFc,90
3
+ github_bot_api-0.7.1.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
+ github_bot_api-0.7.1.dist-info/licenses/LICENSE,sha256=bDVcEAS5rCEemhGT133WyYPEWhviXjCvQ46Pi_1gInQ,998
5
+ github_bot_api/__init__.py,sha256=TcfSurT9yvlflzPlzACB5PbOJXjOcE7CWbcOGTSWbrA,239
6
+ github_bot_api/app.py,sha256=BJh4yZuNnfGO9V8SwKwUMZZi4CMB0KJYHfYAfGmoKLA,14876
7
7
  github_bot_api/app_test.py,sha256=cKcdHKAVJ5DANWShQ0VG3BZgv1_fpEOvsiv0JYWWtGM,541
8
8
  github_bot_api/event.py,sha256=uxznCpkNfUGqjjvMxKUAX5TAUB_tbTaGhTyW8bSyaNU,3253
9
9
  github_bot_api/flask.py,sha256=2yVZW7_eor9m2Y_IsgpGPQ4rwtoVDAWSSyxeWnYZQKo,1583
10
10
  github_bot_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  github_bot_api/signature.py,sha256=ZMFEykII2FiutpL6qInfgIiqBZTkZbW4MJPWXlyZM84,1675
12
12
  github_bot_api/tests/test_import.py,sha256=BUyUDZW7imoS6Kqg7nHnmv3UKZ-yQPmAcf8DJhz_TX0,59
13
- github_bot_api/token.py,sha256=lWDnAs4WV7fsDcCTQcHNMjE6urier0J-Lm_cRsyF7Uk,4493
13
+ github_bot_api/token.py,sha256=84XoildTyc75Zn46n8EjpTRjFGY3IPPDoLqfgenwsMI,4498
14
14
  github_bot_api/utils/__init__.py,sha256=n1bnYdeb_bNDBKASWGywTRa0Ne9hMAkal3AuVZJgovI,5
15
15
  github_bot_api/utils/functions.py,sha256=4xqf7G3A-CXVIan2se4XRzSwnUqRdVizNoF3JCrMgOg,748
16
16
  github_bot_api/utils/mime.py,sha256=e5b0zB2biy7FgDnTWXNUELicFy6CQ7rm-nbA-R2xMaM,738
17
17
  github_bot_api/utils/types.py,sha256=0AMwej7xeVmJULIBGiAowbItZhFASM6pgua_gT5holY,233
18
18
  github_bot_api/webhook.py,sha256=syDxg6NSOaqdS-CzDRPYW-RY27zk6YgIEeWNGL8zhDI,2055
19
- github_bot_api-0.7.0.dist-info/RECORD,,
19
+ github_bot_api-0.7.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: pdm-backend (2.4.3)
2
+ Generator: pdm-backend (2.4.7)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any