github-bot-api 0.5.1__tar.gz → 0.6.0__tar.gz
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.
- github-bot-api-0.5.1/LICENSE.txt → github_bot_api-0.6.0/LICENSE +2 -2
- github_bot_api-0.6.0/PKG-INFO +52 -0
- github_bot_api-0.6.0/README.md +24 -0
- github_bot_api-0.6.0/pyproject.toml +82 -0
- github_bot_api-0.6.0/src/github_bot_api/__init__.py +8 -0
- github_bot_api-0.6.0/src/github_bot_api/app.py +188 -0
- github_bot_api-0.6.0/src/github_bot_api/app_test.py +16 -0
- github_bot_api-0.6.0/src/github_bot_api/event.py +95 -0
- github_bot_api-0.6.0/src/github_bot_api/flask.py +61 -0
- github_bot_api-0.6.0/src/github_bot_api/signature.py +47 -0
- github_bot_api-0.6.0/src/github_bot_api/tests/test_import.py +2 -0
- github_bot_api-0.6.0/src/github_bot_api/token.py +149 -0
- github_bot_api-0.6.0/src/github_bot_api/utils/functions.py +29 -0
- github_bot_api-0.6.0/src/github_bot_api/utils/mime.py +24 -0
- github_bot_api-0.6.0/src/github_bot_api/utils/types.py +12 -0
- github_bot_api-0.6.0/src/github_bot_api/webhook.py +72 -0
- github-bot-api-0.5.1/MANIFEST.in +0 -6
- github-bot-api-0.5.1/PKG-INFO +0 -82
- github-bot-api-0.5.1/README.md +0 -69
- github-bot-api-0.5.1/package.yml +0 -29
- github-bot-api-0.5.1/setup.cfg +0 -4
- github-bot-api-0.5.1/setup.py +0 -58
- github-bot-api-0.5.1/src/github_bot_api/__init__.py +0 -9
- github-bot-api-0.5.1/src/github_bot_api/app.py +0 -213
- github-bot-api-0.5.1/src/github_bot_api/event.py +0 -95
- github-bot-api-0.5.1/src/github_bot_api/flask.py +0 -60
- github-bot-api-0.5.1/src/github_bot_api/signature.py +0 -48
- github-bot-api-0.5.1/src/github_bot_api/token.py +0 -147
- github-bot-api-0.5.1/src/github_bot_api/utils/mime.py +0 -25
- github-bot-api-0.5.1/src/github_bot_api/utils/types.py +0 -13
- github-bot-api-0.5.1/src/github_bot_api/webhook.py +0 -72
- github-bot-api-0.5.1/src/github_bot_api.egg-info/PKG-INFO +0 -82
- github-bot-api-0.5.1/src/github_bot_api.egg-info/SOURCES.txt +0 -22
- github-bot-api-0.5.1/src/github_bot_api.egg-info/dependency_links.txt +0 -1
- github-bot-api-0.5.1/src/github_bot_api.egg-info/not-zip-safe +0 -1
- github-bot-api-0.5.1/src/github_bot_api.egg-info/requires.txt +0 -12
- github-bot-api-0.5.1/src/github_bot_api.egg-info/top_level.txt +0 -1
- {github-bot-api-0.5.1 → github_bot_api-0.6.0}/src/github_bot_api/py.typed +0 -0
- {github-bot-api-0.5.1 → github_bot_api-0.6.0}/src/github_bot_api/utils/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
3
|
+
Copyright (c) 2022 Niklas Rosenstein
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
this software and associated documentation files (the "Software"), to deal in
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: github-bot-api
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: API for creating GitHub bots and webhooks in Python.
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Niklas Rosenstein
|
|
7
|
+
Author-email: rosensteinniklas@gmail.com
|
|
8
|
+
Requires-Python: >=3.8,<4.0
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Dist: PyGithub (>=1.58,<2.0)
|
|
18
|
+
Requires-Dist: PyJWT (>=2.6.0,<3.0.0)
|
|
19
|
+
Requires-Dist: cryptography (>=42.0.5,<43.0.0)
|
|
20
|
+
Requires-Dist: flask
|
|
21
|
+
Requires-Dist: requests (>=2.28.2,<3.0.0)
|
|
22
|
+
Requires-Dist: urllib3 (>=1.26.15,<2.0.0)
|
|
23
|
+
Project-URL: Bug Tracker, https://github.com/NiklasRosenstein/python-github-bot-api/issues
|
|
24
|
+
Project-URL: Documentation, https://niklasrosenstein.github.io/python-github-bot-api/
|
|
25
|
+
Project-URL: Repository, https://github.com/NiklasRosenstein/python-github-bot-api
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<p align="center"><img src="https://i.imgur.com/5SiDsz8.png"></p>
|
|
29
|
+
<h1 align="center">python-github-bot-api</h1>
|
|
30
|
+
<p align="center">
|
|
31
|
+
<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
|
+
|
|
33
|
+
[PyGithub]: https://pypi.org/project/PyGithub/
|
|
34
|
+
|
|
35
|
+
A thin Python library for creating GitHub bots and webhooks in Python with [PyGithub].
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from github import Github
|
|
39
|
+
from github_bot_api import GithubApp
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
app = GithubApp(
|
|
43
|
+
user_agent='my-bot/0.0.0',
|
|
44
|
+
app_id="67890",
|
|
45
|
+
private_key=Path("app-private.key").read_text(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
client: Github = app.installation_client(12345)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For more examples, check out the [documentation](https://niklasrosenstein.github.io/python-github-bot-api/).
|
|
52
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<p align="center"><img src="https://i.imgur.com/5SiDsz8.png"></p>
|
|
2
|
+
<h1 align="center">python-github-bot-api</h1>
|
|
3
|
+
<p align="center">
|
|
4
|
+
<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>
|
|
5
|
+
|
|
6
|
+
[PyGithub]: https://pypi.org/project/PyGithub/
|
|
7
|
+
|
|
8
|
+
A thin Python library for creating GitHub bots and webhooks in Python with [PyGithub].
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from github import Github
|
|
12
|
+
from github_bot_api import GithubApp
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
app = GithubApp(
|
|
16
|
+
user_agent='my-bot/0.0.0',
|
|
17
|
+
app_id="67890",
|
|
18
|
+
private_key=Path("app-private.key").read_text(),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
client: Github = app.installation_client(12345)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For more examples, check out the [documentation](https://niklasrosenstein.github.io/python-github-bot-api/).
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[tool.poetry]
|
|
6
|
+
name = "github-bot-api"
|
|
7
|
+
version = "0.6.0"
|
|
8
|
+
description = "API for creating GitHub bots and webhooks in Python."
|
|
9
|
+
authors = ["Niklas Rosenstein <rosensteinniklas@gmail.com>"]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
packages = [{ include = "github_bot_api", from = "src" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.8",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11"
|
|
21
|
+
]
|
|
22
|
+
keywords = []
|
|
23
|
+
|
|
24
|
+
[tool.poetry.urls]
|
|
25
|
+
"Bug Tracker" = "https://github.com/NiklasRosenstein/python-github-bot-api/issues"
|
|
26
|
+
Documentation = "https://niklasrosenstein.github.io/python-github-bot-api/"
|
|
27
|
+
Repository = "https://github.com/NiklasRosenstein/python-github-bot-api"
|
|
28
|
+
|
|
29
|
+
[tool.poetry.dependencies]
|
|
30
|
+
python = "^3.8"
|
|
31
|
+
cryptography = "^42.0.5"
|
|
32
|
+
flask = { version = "*", optional = true }
|
|
33
|
+
PyGithub = { version = "^1.58", optional = true }
|
|
34
|
+
PyJWT = "^2.6.0"
|
|
35
|
+
requests = "^2.28.2"
|
|
36
|
+
urllib3 = "^1.26.15"
|
|
37
|
+
|
|
38
|
+
[tool.poetry.dev-dependencies]
|
|
39
|
+
black = "*"
|
|
40
|
+
flake8 = "*"
|
|
41
|
+
isort = "*"
|
|
42
|
+
mypy = "*"
|
|
43
|
+
pytest = "*"
|
|
44
|
+
types-flask = "*"
|
|
45
|
+
types-requests = "*"
|
|
46
|
+
|
|
47
|
+
[tool.slap]
|
|
48
|
+
typed = true
|
|
49
|
+
|
|
50
|
+
[tool.slap.test]
|
|
51
|
+
check = "slap check"
|
|
52
|
+
mypy = "dmypy run src"
|
|
53
|
+
pytest = "pytest -vv"
|
|
54
|
+
black = "black --check src"
|
|
55
|
+
isort = "isort --check-only src"
|
|
56
|
+
flake8 = "flake8 src"
|
|
57
|
+
|
|
58
|
+
[tool.slap.run]
|
|
59
|
+
fmt = "black src && isort src"
|
|
60
|
+
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
python_version = "3.8"
|
|
63
|
+
explicit_package_bases = true
|
|
64
|
+
mypy_path = ["src"]
|
|
65
|
+
namespace_packages = true
|
|
66
|
+
pretty = true
|
|
67
|
+
show_error_codes = true
|
|
68
|
+
show_error_context = true
|
|
69
|
+
# strict = true
|
|
70
|
+
warn_no_return = true
|
|
71
|
+
warn_redundant_casts = true
|
|
72
|
+
warn_unreachable = true
|
|
73
|
+
warn_unused_ignores = true
|
|
74
|
+
check_untyped_defs = true
|
|
75
|
+
|
|
76
|
+
[tool.isort]
|
|
77
|
+
profile = "black"
|
|
78
|
+
line_length = 120
|
|
79
|
+
combine_as_imports = true
|
|
80
|
+
|
|
81
|
+
[tool.black]
|
|
82
|
+
line-length = 120
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Registry for GitHub event handlers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import typing as t
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
import urllib3
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .token import InstallationTokenSupplier, JwtSupplier, TokenInfo
|
|
16
|
+
from .utils.functions import coalesce
|
|
17
|
+
|
|
18
|
+
T = t.TypeVar("T")
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
user_agent = f"python/{sys.version.split()[0]} github-bot-api/{__version__}"
|
|
21
|
+
|
|
22
|
+
if t.TYPE_CHECKING:
|
|
23
|
+
import github
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass
|
|
27
|
+
class GithubClientSettings:
|
|
28
|
+
"""
|
|
29
|
+
Settings for constructing a #github.Github client object.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
base_url: t.Optional[str] = None
|
|
33
|
+
user_agent: t.Optional[str] = None
|
|
34
|
+
timeout: t.Optional[int] = None
|
|
35
|
+
per_page: t.Optional[int] = None
|
|
36
|
+
verify: t.Optional[bool] = None
|
|
37
|
+
retry: t.Optional[urllib3.Retry] = None
|
|
38
|
+
|
|
39
|
+
def update(self, other: "GithubClientSettings") -> "GithubClientSettings":
|
|
40
|
+
result = GithubClientSettings()
|
|
41
|
+
for field in dataclasses.fields(self):
|
|
42
|
+
value = getattr(other, field.name)
|
|
43
|
+
if value is None:
|
|
44
|
+
value = getattr(self, field.name)
|
|
45
|
+
setattr(result, field.name, value)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
def make_client(self, login_or_token: t.Optional[str] = None, jwt: t.Optional[str] = None) -> "github.Github":
|
|
49
|
+
import github
|
|
50
|
+
import github.Consts
|
|
51
|
+
|
|
52
|
+
return github.Github(
|
|
53
|
+
login_or_token=login_or_token,
|
|
54
|
+
jwt=jwt,
|
|
55
|
+
base_url=self.base_url or github.Consts.DEFAULT_BASE_URL,
|
|
56
|
+
user_agent=self.user_agent or "PyGithub/Python",
|
|
57
|
+
timeout=coalesce(self.timeout, github.Consts.DEFAULT_TIMEOUT),
|
|
58
|
+
per_page=coalesce(self.per_page, github.Consts.DEFAULT_PER_PAGE),
|
|
59
|
+
verify=coalesce(self.verify, True),
|
|
60
|
+
retry=self.retry,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclasses.dataclass
|
|
65
|
+
class GithubApp:
|
|
66
|
+
"""
|
|
67
|
+
Represents a GitHub application and all the required details.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
PUBLIC_GITHUB_V3_API_URL = "https://api.github.com"
|
|
71
|
+
|
|
72
|
+
user_agent: str
|
|
73
|
+
"""User agent of the application. This will be respected in #get_user_agent()."""
|
|
74
|
+
|
|
75
|
+
app_id: int
|
|
76
|
+
"""GitHub Application ID."""
|
|
77
|
+
|
|
78
|
+
private_key: str
|
|
79
|
+
"""RSA private key to sign the JWT with."""
|
|
80
|
+
|
|
81
|
+
v3_api_url: str = PUBLIC_GITHUB_V3_API_URL
|
|
82
|
+
"""GitHub API base URL. Defaults to the public GitHub API."""
|
|
83
|
+
|
|
84
|
+
def __post_init__(self):
|
|
85
|
+
self._jwt_supplier = JwtSupplier(self.app_id, self.private_key)
|
|
86
|
+
self._lock = threading.Lock()
|
|
87
|
+
self._installation_tokens: t.Dict[int, InstallationTokenSupplier] = {}
|
|
88
|
+
|
|
89
|
+
def _get_base_github_client_settings(self) -> GithubClientSettings:
|
|
90
|
+
return GithubClientSettings(self.v3_api_url, self.get_user_agent())
|
|
91
|
+
|
|
92
|
+
def get_user_agent(self, installation_id: t.Optional[int] = None) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Create a user agent string for the PyGithub client, including the installation if specified.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
user_agent = f"{self.user_agent} PyGithub/python (app_id={self.app_id}"
|
|
98
|
+
if installation_id:
|
|
99
|
+
user_agent += f", installation_id={installation_id})"
|
|
100
|
+
return user_agent
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def jwt(self) -> TokenInfo:
|
|
104
|
+
"""
|
|
105
|
+
Returns the JWT for your GitHub application. The JWT is the token to use with GitHub application APIs.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
return self._jwt_supplier()
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def jwt_supplier(self) -> JwtSupplier:
|
|
112
|
+
"""
|
|
113
|
+
Returns a new #JwtSupplier that is used for generating JWT tokens for your GitHub application.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
return JwtSupplier(self.app_id, self.private_key)
|
|
117
|
+
|
|
118
|
+
def app_client(self, settings: t.Union[GithubClientSettings, t.Dict[str, t.Any], None] = None) -> "github.Github":
|
|
119
|
+
"""
|
|
120
|
+
Returns a PyGithub client for your GitHub application.
|
|
121
|
+
|
|
122
|
+
Note that the client's token will expire after 10 minutes and you will have to create a new client or update the
|
|
123
|
+
client's token with the value returned by #jwt. It is recommended that you create a new client for each atomic
|
|
124
|
+
operation you perform.
|
|
125
|
+
|
|
126
|
+
This requires you to install `PyGithub>=1.58`.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
if isinstance(settings, dict):
|
|
130
|
+
settings = GithubClientSettings(**settings)
|
|
131
|
+
elif settings is None:
|
|
132
|
+
settings = GithubClientSettings()
|
|
133
|
+
|
|
134
|
+
settings = self._get_base_github_client_settings().update(settings)
|
|
135
|
+
return settings.make_client(jwt=self.jwt.value)
|
|
136
|
+
|
|
137
|
+
def __requestor(self, auth_header: str, installation_id: int) -> t.Dict[str, str]:
|
|
138
|
+
return requests.post(
|
|
139
|
+
self.v3_api_url.rstrip("/") + f"/app/installations/{installation_id}/access_tokens",
|
|
140
|
+
headers={"Authorization": auth_header, "User-Agent": user_agent},
|
|
141
|
+
).json()
|
|
142
|
+
|
|
143
|
+
def get_installation_token_supplier(self, installation_id: int) -> InstallationTokenSupplier:
|
|
144
|
+
"""
|
|
145
|
+
Create an #InstallationTokenSupplier for your GitHub application to act within the scope of the given
|
|
146
|
+
*installation_id*.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
with self._lock:
|
|
150
|
+
return self._installation_tokens.setdefault(
|
|
151
|
+
installation_id,
|
|
152
|
+
InstallationTokenSupplier(
|
|
153
|
+
self._jwt_supplier,
|
|
154
|
+
installation_id,
|
|
155
|
+
self.__requestor,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def installation_token(self, installation_id: int) -> TokenInfo:
|
|
160
|
+
"""
|
|
161
|
+
A short-hand to retrieve a new installation token for the given *installation_id*.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
return self.get_installation_token_supplier(installation_id)()
|
|
165
|
+
|
|
166
|
+
def installation_client(
|
|
167
|
+
self,
|
|
168
|
+
installation_id: int,
|
|
169
|
+
settings: t.Union[GithubClientSettings, t.Dict[str, t.Any], None] = None,
|
|
170
|
+
) -> "github.Github":
|
|
171
|
+
"""
|
|
172
|
+
Returns a PyGithub client for your GitHub application to act in the scope of the given *installation_id*.
|
|
173
|
+
|
|
174
|
+
Note that the client's token will expire after 10 minutes and you will have to create a new client or update the
|
|
175
|
+
client's token with the value returned by #jwt. It is recommended that you create a new client for each atomic
|
|
176
|
+
operation you perform.
|
|
177
|
+
|
|
178
|
+
This requires you to install `PyGithub>=1.58`.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
if isinstance(settings, dict):
|
|
182
|
+
settings = GithubClientSettings(**settings)
|
|
183
|
+
elif settings is None:
|
|
184
|
+
settings = GithubClientSettings()
|
|
185
|
+
|
|
186
|
+
token = self.installation_token(installation_id).value
|
|
187
|
+
settings = self._get_base_github_client_settings().update(settings)
|
|
188
|
+
return settings.make_client(login_or_token=token)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
from github_bot_api.app import GithubApp
|
|
4
|
+
from github_bot_api.token import TokenInfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_simple_app():
|
|
8
|
+
"""
|
|
9
|
+
Regression test for PyGithub 1.58+ where such code failed on:
|
|
10
|
+
|
|
11
|
+
AttributeError: module 'github.MainClass' has no attribute 'DEFAULT_TIMEOUT'
|
|
12
|
+
"""
|
|
13
|
+
token = TokenInfo(1, "Bearer", "token")
|
|
14
|
+
with patch("github_bot_api.token.InstallationTokenSupplier._new_token", return_value=token):
|
|
15
|
+
app = GithubApp("UA/0.0.0", 42, "private_key")
|
|
16
|
+
app.installation_client(1)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstraction of a GitHub Webhook event.
|
|
3
|
+
|
|
4
|
+
Reference: https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/webhook-events-and-payloads
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import typing as t
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .signature import check_signature
|
|
13
|
+
from .utils.mime import get_mime_components
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Event:
|
|
20
|
+
"""
|
|
21
|
+
Represents a GitHub webhook event.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
""" The name of the event. Could be `pull_request`, for example."""
|
|
26
|
+
|
|
27
|
+
delivery_id: str
|
|
28
|
+
"""The delivery ID of the event."""
|
|
29
|
+
|
|
30
|
+
signature: t.Optional[str]
|
|
31
|
+
"""The signature of the event. Will only be set if a Webhook-secret is configured on the
|
|
32
|
+
client side (e.g. in [`Webhook.secret`][github_bot_api.webhook.Webhook] / if the *webhook_secret* parameter is
|
|
33
|
+
passed to [`accept_event()`][github_bot_api.event.accept_event])."""
|
|
34
|
+
|
|
35
|
+
user_agent: str
|
|
36
|
+
"""The user agent invoking the webhook."""
|
|
37
|
+
|
|
38
|
+
payload: t.Dict[str, t.Any]
|
|
39
|
+
"""The event payload."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def accept_event(
|
|
43
|
+
headers: t.Mapping[str, str],
|
|
44
|
+
raw_body: bytes,
|
|
45
|
+
webhook_secret: t.Optional[str] = None,
|
|
46
|
+
) -> Event:
|
|
47
|
+
"""
|
|
48
|
+
Converts thee HTTP *headers* and the *raw_body* to an #Event object.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
headers: The HTTP headers. Must have `X-Github-Event`, `X-Github-Delivery`, `User-Agent`, `Content-Type`.
|
|
52
|
+
May have `X-Hub-Signature` or `X-Hub-Signature-256`.
|
|
53
|
+
raw_body: The raw request body for the event. This is converted into a JSON payload.
|
|
54
|
+
webhook_secret: If specified, the `X-Hub-Signature` or `X-Hub-Signature-256` headers are used to verify
|
|
55
|
+
the signature of the payload. If not specified, the client does not validate the signature.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
event_name = headers.get("X-GitHub-Event")
|
|
59
|
+
delivery_id = headers.get("X-GitHub-Delivery")
|
|
60
|
+
signature_1 = headers.get("X-Hub-Signature")
|
|
61
|
+
signature_256 = headers.get("X-Hub-Signature-256")
|
|
62
|
+
user_agent = headers.get("User-Agent")
|
|
63
|
+
content_type = headers.get("Content-Type")
|
|
64
|
+
|
|
65
|
+
if not event_name or not delivery_id or not user_agent or not content_type:
|
|
66
|
+
raise InvalidRequest("missing required headers")
|
|
67
|
+
if webhook_secret is not None and not signature_1 and not signature_256:
|
|
68
|
+
raise InvalidRequest("webhook secret is configured but no signature header was received")
|
|
69
|
+
|
|
70
|
+
mime_type, parameters = get_mime_components(content_type)
|
|
71
|
+
if mime_type != "application/json":
|
|
72
|
+
raise InvalidRequest(f"expected Content-Type: application/json, got {content_type}")
|
|
73
|
+
encoding = dict(parameters).get("encoding", "UTF-8")
|
|
74
|
+
|
|
75
|
+
if webhook_secret is not None:
|
|
76
|
+
if signature_256:
|
|
77
|
+
check_signature(signature_256, raw_body, webhook_secret.encode("ascii"), algo="sha256")
|
|
78
|
+
elif signature_1:
|
|
79
|
+
check_signature(signature_1, raw_body, webhook_secret.encode("ascii"), algo="sha1")
|
|
80
|
+
else:
|
|
81
|
+
raise RuntimeError
|
|
82
|
+
|
|
83
|
+
return Event(
|
|
84
|
+
event_name,
|
|
85
|
+
delivery_id,
|
|
86
|
+
signature_256 or signature_1,
|
|
87
|
+
user_agent,
|
|
88
|
+
json.loads(raw_body.decode(encoding)),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class InvalidRequest(Exception):
|
|
93
|
+
"""
|
|
94
|
+
Raised when an invalid request is passed to #accept_event().
|
|
95
|
+
"""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask binding for handling GitHub webhook events.
|
|
3
|
+
|
|
4
|
+
Note that you need to install the `flask` module separately.
|
|
5
|
+
|
|
6
|
+
# Example
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from github_bot_api import Event, Webhook
|
|
10
|
+
from github_bot_api.flask import create_flask_app
|
|
11
|
+
|
|
12
|
+
def on_any_event(event: Event) -> bool:
|
|
13
|
+
print(event)
|
|
14
|
+
return True
|
|
15
|
+
|
|
16
|
+
webhook = Webhook(secret=None)
|
|
17
|
+
webhook.listen('*', on_any_event)
|
|
18
|
+
|
|
19
|
+
import os; os.environ['FLASK_ENV'] = 'development'
|
|
20
|
+
flask_app = create_flask_app(__name__, webhook)
|
|
21
|
+
flask_app.run()
|
|
22
|
+
```
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import typing as t
|
|
26
|
+
|
|
27
|
+
import flask
|
|
28
|
+
|
|
29
|
+
from .event import accept_event
|
|
30
|
+
from .webhook import Webhook
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_event_handler(webhook: Webhook) -> t.Callable[[], t.Tuple[t.Text, int, t.Dict[str, str]]]:
|
|
34
|
+
"""
|
|
35
|
+
Creates an event handler flask view that interprets the received HTTP request as a GitHub application
|
|
36
|
+
event and dispatches it via #webhook.dispatch().
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def event_handler():
|
|
40
|
+
event = accept_event(
|
|
41
|
+
t.cast(t.Mapping[str, str], flask.request.headers), flask.request.get_data(), webhook.secret
|
|
42
|
+
)
|
|
43
|
+
webhook.dispatch(event)
|
|
44
|
+
return "", 202, {}
|
|
45
|
+
|
|
46
|
+
return event_handler
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_flask_app(
|
|
50
|
+
name: str,
|
|
51
|
+
webhook: Webhook,
|
|
52
|
+
path: str = "/event-handler",
|
|
53
|
+
) -> flask.Flask:
|
|
54
|
+
"""
|
|
55
|
+
Creates a new #flask.Flask application with a `POST` event handler under the given *path* (defaulting
|
|
56
|
+
to `/event-handler`). This is a useful shorthand to attach your #Webhook to an HTTP server.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
flask_app = flask.Flask(name)
|
|
60
|
+
flask_app.route(path, methods=["POST"])(create_event_handler(webhook))
|
|
61
|
+
return flask_app
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper to check the signature of a GitHub event request.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_signature(payload: bytes, secret: bytes, algo: str = "sha256") -> str:
|
|
9
|
+
"""
|
|
10
|
+
Computes the HMAC signature of *payload* given the specified *secret* and the given hashing *algo*.
|
|
11
|
+
|
|
12
|
+
# Parmeters
|
|
13
|
+
payload: The payload for which the signature should be computed.
|
|
14
|
+
secret: The secret string that is used in conjunction to generate the signature.
|
|
15
|
+
algo: The hash algorithm to use, must be `sha1` or `sha256`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
if algo in {"sha1", "sha256"}:
|
|
19
|
+
return f"{algo}={hmac.new(secret, payload, algo).hexdigest()}"
|
|
20
|
+
raise ValueError(f"algo must be {{sha1, sha256}}, got {algo!r}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_signature(sig: str, payload: bytes, secret: bytes, algo: str = "sha256") -> None:
|
|
24
|
+
"""
|
|
25
|
+
Compares the provided signature *sig* with the computed signature of the *payload* and
|
|
26
|
+
raises a #SignatureMismatchException if they do not match. This function uses constant-time
|
|
27
|
+
string comparison to prevent timing analysis.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
computed = compute_signature(payload, secret, algo)
|
|
31
|
+
if not hmac.compare_digest(sig, computed):
|
|
32
|
+
raise SignatureMismatchException(sig, computed)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SignatureMismatchException(Exception):
|
|
36
|
+
"""
|
|
37
|
+
Raised if a signature can not be verified with #check_signature().
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_MSG = "The provided signature does not match the computed signature of the payload."
|
|
41
|
+
|
|
42
|
+
def __init__(self, provided: str, computed: str) -> None:
|
|
43
|
+
self.provided = provided
|
|
44
|
+
self.computed = computed
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
return f"{self._MSG}\n provided: {self.provided}\n computed: {self.computed}"
|