github-rest-api 0.40.0__py3-none-any.whl → 0.41.0__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,11 @@
1
1
  """GitHub REST APIs."""
2
2
 
3
- from .github import Organization, Repository, RepositoryType, User
3
+ from .github import (
4
+ Organization,
5
+ Repository,
6
+ RepositoryType,
7
+ SecretVisibility,
8
+ User,
9
+ )
4
10
 
5
- __all__ = ["Organization", "Repository", "RepositoryType", "User"]
11
+ __all__ = ["Organization", "Repository", "RepositoryType", "SecretVisibility", "User"]
github_rest_api/github.py CHANGED
@@ -1,10 +1,25 @@
1
1
  """Simple wrapper of GitHub REST APIs."""
2
2
 
3
3
  from abc import ABCMeta, abstractmethod
4
+ from base64 import b64encode
5
+ from collections.abc import Sequence
4
6
  from enum import StrEnum
5
7
  from typing import Any, Callable
6
8
  from pathlib import Path
7
9
  import requests
10
+ from nacl import encoding, public
11
+
12
+ URL_API = "https://api.github.com"
13
+
14
+
15
+ def _encrypt_secret(public_key: str, value: str) -> str:
16
+ """Encrypt a secret value using a LibSodium sealed box.
17
+ :param public_key: The base64-encoded public key to encrypt against.
18
+ :param value: The plaintext secret value to encrypt.
19
+ """
20
+ pkey = public.PublicKey(public_key.encode(), encoding.Base64Encoder)
21
+ encrypted = public.SealedBox(pkey).encrypt(value.encode())
22
+ return b64encode(encrypted).decode()
8
23
 
9
24
 
10
25
  def build_http_headers(token: str) -> dict[str, str]:
@@ -36,6 +51,12 @@ class GitHub:
36
51
  def _get(
37
52
  self, url: str, raise_for_status: bool = True, **kwargs
38
53
  ) -> requests.Response:
54
+ """Send a GET request to a GitHub REST API endpoint.
55
+ :param url: The endpoint URL to request.
56
+ :param raise_for_status: Whether to raise on a non-2xx response.
57
+ :param kwargs: Additional keyword arguments (e.g. `params`) forwarded
58
+ to `requests.get`.
59
+ """
39
60
  resp = requests.get(
40
61
  url=url,
41
62
  headers=self._headers,
@@ -49,6 +70,13 @@ class GitHub:
49
70
  def _post(
50
71
  self, url: str, headers=None, raise_for_status: bool = True, **kwargs
51
72
  ) -> requests.Response:
73
+ """Send a POST request to a GitHub REST API endpoint.
74
+ :param url: The endpoint URL to request.
75
+ :param headers: Request headers; defaults to the standard auth headers.
76
+ :param raise_for_status: Whether to raise on a non-2xx response.
77
+ :param kwargs: Additional keyword arguments (e.g. `json`) forwarded
78
+ to `requests.post`.
79
+ """
52
80
  if headers is None:
53
81
  headers = self._headers
54
82
  resp = requests.post(
@@ -67,17 +95,32 @@ class GitHub:
67
95
  resp.raise_for_status()
68
96
  return resp
69
97
 
70
- def _put(self, url, raise_for_status: bool = True) -> requests.Response:
98
+ def _put(
99
+ self, url: str, raise_for_status: bool = True, **kwargs
100
+ ) -> requests.Response:
101
+ """Send a PUT request to a GitHub REST API endpoint.
102
+ :param url: The endpoint URL to request.
103
+ :param raise_for_status: Whether to raise on a non-2xx response.
104
+ :param kwargs: Additional keyword arguments (e.g. `json`) forwarded
105
+ to `requests.put`.
106
+ """
71
107
  resp = requests.put(
72
108
  url=url,
73
109
  headers=self._headers,
74
110
  timeout=10,
111
+ **kwargs,
75
112
  )
76
113
  if raise_for_status:
77
114
  resp.raise_for_status()
78
115
  return resp
79
116
 
80
117
  def _patch(self, url, raise_for_status: bool = True, **kwargs) -> requests.Response:
118
+ """Send a PATCH request to a GitHub REST API endpoint.
119
+ :param url: The endpoint URL to request.
120
+ :param raise_for_status: Whether to raise on a non-2xx response.
121
+ :param kwargs: Additional keyword arguments (e.g. `json`) forwarded
122
+ to `requests.patch`.
123
+ """
81
124
  resp = requests.patch(
82
125
  url=url,
83
126
  headers=self._headers,
@@ -118,8 +161,7 @@ class Repository(GitHub):
118
161
  """
119
162
  super().__init__(token)
120
163
  self._repo = repo
121
- self._url = "https://api.github.com/repos"
122
- self._url_repo = f"{self._url}/{repo}"
164
+ self._url_repo = f"{URL_API}/repos/{repo}"
123
165
  self._url_tags = f"{self._url_repo}/tags"
124
166
  self._url_transfer = f"{self._url_repo}/transfer"
125
167
  self._url_pull = f"{self._url_repo}/pulls"
@@ -127,6 +169,7 @@ class Repository(GitHub):
127
169
  self._url_refs = f"{self._url_repo}/git/refs"
128
170
  self._url_issues = f"{self._url_repo}/issues"
129
171
  self._url_releases = f"{self._url_repo}/releases"
172
+ self._url_secrets = f"{self._url_repo}/actions/secrets"
130
173
 
131
174
  def get_releases(self, n: int = 0) -> list[dict[str, Any]]:
132
175
  """List releases in this repository."""
@@ -157,8 +200,6 @@ class Repository(GitHub):
157
200
  For more details, please refer to
158
201
  https://docs.github.com/en/rest/releases/releases#create-a-release.
159
202
  """
160
- if not isinstance(json, dict):
161
- raise ValueError("A dict value is required for `json`.")
162
203
  return self._post(
163
204
  url=self._url_releases,
164
205
  json=json,
@@ -171,7 +212,9 @@ class Repository(GitHub):
171
212
  path = Path(path)
172
213
  with path.open(mode="rb") as fin:
173
214
  return self._post(
174
- url=f"{self._url_releases.replace('api', 'uploads', 1)}/{release}/assets",
215
+ url=f"{self._url_releases.replace('api', 'uploads', 1)}/{
216
+ release
217
+ }/assets",
175
218
  params={
176
219
  "name": name,
177
220
  },
@@ -193,8 +236,6 @@ class Repository(GitHub):
193
236
  about the pull request to be created.
194
237
  It's passed to the json parameter of requests.post.
195
238
  """
196
- if not isinstance(json, dict):
197
- raise ValueError("A dict value is required for `json`.")
198
239
  if not ("head" in json and "base" in json):
199
240
  raise ValueError("The data dict must contains keys head and base!")
200
241
  # return an existing PR
@@ -217,8 +258,6 @@ class Repository(GitHub):
217
258
  """Merge a pull request in this repository.
218
259
  :param pr_number: The number of the pull quest to be merged.
219
260
  """
220
- if not isinstance(pr_number, int):
221
- raise ValueError("An integer value is required for `pr_number`.")
222
261
  return self._put(
223
262
  url=f"{self._url_pull}/{pr_number}/merge",
224
263
  ).json()
@@ -228,10 +267,6 @@ class Repository(GitHub):
228
267
  :param update: The branch to update.
229
268
  :param upstream: The upstream branch.
230
269
  """
231
- if not isinstance(update, str):
232
- raise ValueError("A string value is required for `update`.")
233
- if not isinstance(upstream, str):
234
- raise ValueError("A string value is required for `upstream`.")
235
270
  pr = self.create_pull_request(
236
271
  {
237
272
  "base": update,
@@ -250,8 +285,6 @@ class Repository(GitHub):
250
285
 
251
286
  :param pr_number: The number of the pull request.
252
287
  """
253
- if not isinstance(pr_number, int):
254
- raise ValueError("An integer value is required for `pr_number`.")
255
288
  return self._extract_all(url=f"{self._url_pull}/{pr_number}/files", n=n)
256
289
 
257
290
  def get_branches(self, n: int = 0) -> list[dict[str, Any]]:
@@ -272,8 +305,6 @@ class Repository(GitHub):
272
305
  """Delete a reference from this repository.
273
306
  :param ref: The reference to delete from this repository.
274
307
  """
275
- if not isinstance(ref, str):
276
- raise ValueError("A string value is required for `ref`.")
277
308
  return self._delete(
278
309
  url=f"{self._url_refs}/{ref}",
279
310
  )
@@ -284,6 +315,36 @@ class Repository(GitHub):
284
315
  """
285
316
  return self.delete_ref(ref=f"heads/{branch}")
286
317
 
318
+ def delete_secret(self, name: str) -> requests.Response:
319
+ """Delete a secret from this repository.
320
+ :param name: The name of the secret to delete.
321
+ """
322
+ return self._delete(
323
+ url=f"{self._url_secrets}/{name}",
324
+ )
325
+
326
+ def get_secret_public_key(self) -> dict[str, Any]:
327
+ """Get the public key for encrypting secrets in this repository."""
328
+ return self._get(url=f"{self._url_secrets}/public-key").json()
329
+
330
+ def create_or_update_secret(
331
+ self, name: str, value: str, public_key: dict[str, Any]
332
+ ) -> requests.Response:
333
+ """Create or update a secret in this repository.
334
+ :param name: The name of the secret.
335
+ :param value: The plaintext value of the secret.
336
+ :param public_key: A public key (as returned by `get_secret_public_key`)
337
+ to encrypt the secret with. Fetch it once and reuse it to avoid a
338
+ redundant request when creating or updating multiple secrets.
339
+ """
340
+ return self._put(
341
+ url=f"{self._url_secrets}/{name}",
342
+ json={
343
+ "encrypted_value": _encrypt_secret(public_key["key"], value),
344
+ "key_id": public_key["key_id"],
345
+ },
346
+ )
347
+
287
348
  def pr_has_change(
288
349
  self, pr_number: int, pred: Callable[[str], bool] = lambda _: True
289
350
  ) -> bool:
@@ -313,10 +374,6 @@ class Repository(GitHub):
313
374
  :param issue_number: The number of the issue.
314
375
  :param body: Body text of the new comment.
315
376
  """
316
- if not isinstance(issue_number, int):
317
- raise ValueError("An integer value is required for `issue_number`.")
318
- if not isinstance(body, str):
319
- raise ValueError("A string message is required for `body`.")
320
377
  return self._post(
321
378
  url=f"{self._url_issues}/{issue_number}/comments",
322
379
  json={"body": body},
@@ -344,6 +401,12 @@ class RepositoryType(StrEnum):
344
401
  PRIVATE = "private"
345
402
 
346
403
 
404
+ class SecretVisibility(StrEnum):
405
+ ALL = "all"
406
+ PRIVATE = "private"
407
+ SELECTED = "selected"
408
+
409
+
347
410
  class Owner(GitHub, metaclass=ABCMeta):
348
411
  """An abstract owner class representing an organization or user."""
349
412
 
@@ -354,6 +417,7 @@ class Owner(GitHub, metaclass=ABCMeta):
354
417
  """
355
418
  super().__init__(token)
356
419
  self._owner = owner
420
+ self._url_owner = ""
357
421
  self._url_repos = ""
358
422
  self._url_create_repo = ""
359
423
 
@@ -376,6 +440,14 @@ class Owner(GitHub, metaclass=ABCMeta):
376
440
  def create_repository(
377
441
  self, name: str, description: str = "", private: bool = True, **kwargs
378
442
  ) -> dict[str, Any]:
443
+ """Create a repository for this owner.
444
+ :param name: The name of the repository.
445
+ :param description: A short description of the repository.
446
+ :param private: Whether the repository is private.
447
+ :param kwargs: Additional keyword arguments forwarded to `_post`
448
+ (e.g. `params` or `raise_for_status`). Note `json` is already set
449
+ from the other parameters and must not be passed here.
450
+ """
379
451
  data = {
380
452
  "name": name,
381
453
  "description": description,
@@ -400,8 +472,9 @@ class User(Owner):
400
472
  self._set_urls()
401
473
 
402
474
  def _set_urls(self) -> None:
403
- self._url_repos = f"https://api.github.com/users/{self._owner}/repos"
404
- self._url_create_repo = "https://api.github.com/user/repos"
475
+ self._url_owner = f"{URL_API}/users/{self._owner}"
476
+ self._url_repos = f"{self._url_owner}/repos"
477
+ self._url_create_repo = f"{URL_API}/user/repos"
405
478
 
406
479
 
407
480
  class Organization(Owner):
@@ -416,5 +489,54 @@ class Organization(Owner):
416
489
  self._set_urls()
417
490
 
418
491
  def _set_urls(self) -> None:
419
- self._url_repos = f"https://api.github.com/orgs/{self._owner}/repos"
492
+ self._url_owner = f"{URL_API}/orgs/{self._owner}"
493
+ self._url_repos = f"{self._url_owner}/repos"
420
494
  self._url_create_repo = self._url_repos
495
+ self._url_secrets = f"{self._url_owner}/actions/secrets"
496
+
497
+ def delete_secret(self, name: str) -> requests.Response:
498
+ """Delete an organization secret.
499
+ :param name: The name of the secret to delete.
500
+ """
501
+ return self._delete(
502
+ url=f"{self._url_secrets}/{name}",
503
+ )
504
+
505
+ def get_secret_public_key(self) -> dict[str, Any]:
506
+ """Get the public key for encrypting secrets in this organization."""
507
+ return self._get(url=f"{self._url_secrets}/public-key").json()
508
+
509
+ def create_or_update_secret(
510
+ self,
511
+ name: str,
512
+ value: str,
513
+ public_key: dict[str, Any],
514
+ visibility: SecretVisibility = SecretVisibility.ALL,
515
+ selected_repository_ids: Sequence[int] = (),
516
+ ) -> requests.Response:
517
+ """Create or update an organization secret.
518
+ :param name: The name of the secret.
519
+ :param value: The plaintext value of the secret.
520
+ :param public_key: A public key (as returned by `get_secret_public_key`)
521
+ to encrypt the secret with. Fetch it once and reuse it to avoid a
522
+ redundant request when creating or updating multiple secrets.
523
+ :param visibility: Which repositories can access the secret
524
+ (all, private, or selected).
525
+ :param selected_repository_ids: Repository IDs that can access the secret
526
+ when visibility is `selected`.
527
+ """
528
+ if selected_repository_ids and visibility != SecretVisibility.SELECTED:
529
+ raise ValueError(
530
+ "`selected_repository_ids` can only be provided when `visibility` is 'selected'."
531
+ )
532
+ json: dict[str, Any] = {
533
+ "encrypted_value": _encrypt_secret(public_key["key"], value),
534
+ "key_id": public_key["key_id"],
535
+ "visibility": visibility,
536
+ }
537
+ if selected_repository_ids:
538
+ json["selected_repository_ids"] = list(selected_repository_ids)
539
+ return self._put(
540
+ url=f"{self._url_secrets}/{name}",
541
+ json=json,
542
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-rest-api
3
- Version: 0.40.0
3
+ Version: 0.41.0
4
4
  Summary: Simple wrapper of GitHub REST APIs.
5
5
  Author-email: Ben Du <longendu@yahoo.com>
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -10,6 +10,7 @@ Classifier: Programming Language :: Python :: 3.14
10
10
  Requires-Python: <4,>=3.12
11
11
  Requires-Dist: dulwich>=0.25.1
12
12
  Requires-Dist: psutil>=5.9.4
13
+ Requires-Dist: pynacl>=1.5
13
14
  Requires-Dist: pyyaml>=6
14
15
  Requires-Dist: requests>=2.28.2
15
16
  Requires-Dist: tenacity>=9.1.4
@@ -1,5 +1,5 @@
1
- github_rest_api/__init__.py,sha256=P-BmW8wD0Uhk0kt3xIBTfWiFY2oQj17zCIQ02pAqb6c,160
2
- github_rest_api/github.py,sha256=FK1F5lXKtBgL_nizm3xuxQ2i5eRuycbX2bfWePuF1PM,14575
1
+ github_rest_api/__init__.py,sha256=Vx8CfSReDGbrQmTDSgCJ2Z9mjb2amk-t6SUmidFEbVs,223
2
+ github_rest_api/github.py,sha256=qMTTY0iVBcYNxxIvwcebsbx792jU1VXgWk4Wg8xfuNY,19456
3
3
  github_rest_api/utils.py,sha256=rs5ZQlHGbPoKvf4f52VeG4FwqwOFePaEPYPhWgfSGWA,2697
4
4
  github_rest_api/scripts/__init__.py,sha256=NkHTngnnWXPc0JmO5mqVKjDf4JPofuwWjDW1SU3aE7Y,36
5
5
  github_rest_api/scripts/utils.py,sha256=xWswlfwUZI9ocuwQprhAHP1PRt8_tbs6UxyvQTtoVMY,4587
@@ -22,7 +22,7 @@ github_rest_api/scripts/github/workflows/create_pr_to_main.yaml,sha256=mgHPlewkC
22
22
  github_rest_api/scripts/github/workflows/remove_branch.yaml,sha256=KDpB76ovrhuRdP0HN5j6Th9gjIQWSDdh8b4b4yAgtoI,544
23
23
  github_rest_api/scripts/github/workflows/python/lint.yaml,sha256=toP2oUdVIKyu_n20Dr14k5cJYxN8rPksfbX4sfflXxc,699
24
24
  github_rest_api/scripts/github/workflows/python/test.yaml,sha256=86MljYEWX1E9kJYmhMCLwu46WNEWvoyO9PwqeRew540,887
25
- github_rest_api-0.40.0.dist-info/METADATA,sha256=ZDhv1ZLUjzwW_eRwYdYhSQRGZahYi387LHh0E0UBKaw,825
26
- github_rest_api-0.40.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
27
- github_rest_api-0.40.0.dist-info/entry_points.txt,sha256=eXMEdbMEpuE1nsTfUiKSILgS4awe09NwKn0N2_fZ9H0,567
28
- github_rest_api-0.40.0.dist-info/RECORD,,
25
+ github_rest_api-0.41.0.dist-info/METADATA,sha256=uJFonjOAa-XZUqgSJ-NyWLZ0wuXThi2LdCgaM-Yj2mA,852
26
+ github_rest_api-0.41.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
27
+ github_rest_api-0.41.0.dist-info/entry_points.txt,sha256=eXMEdbMEpuE1nsTfUiKSILgS4awe09NwKn0N2_fZ9H0,567
28
+ github_rest_api-0.41.0.dist-info/RECORD,,