asyncgh 0.1.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.

Potentially problematic release.


This version of asyncgh might be problematic. Click here for more details.

asyncgh/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """asyncgh -- the GitHub REST API transport shared by repo-admin's scripts.
2
+
3
+ Auth via the token `gh` already holds, one shared async client, raw and
4
+ raise-on-error request helpers, `Link`-header pagination, and thin wrappers
5
+ over the few endpoints more than one script needs (account-wide repo
6
+ listing, Actions secrets). No config-file loading, no repo filtering -- those
7
+ stay with the caller.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .client import (
13
+ API_BASE,
14
+ GhError,
15
+ aclose_client,
16
+ api_json,
17
+ api_request,
18
+ error_message,
19
+ graphql,
20
+ paginated,
21
+ )
22
+ from .endpoints import (
23
+ encrypt_secret_value,
24
+ fetch_repos_json,
25
+ public_repos_json,
26
+ set_repo_secret,
27
+ )
28
+
29
+ __all__ = [
30
+ "API_BASE",
31
+ "GhError",
32
+ "aclose_client",
33
+ "api_json",
34
+ "api_request",
35
+ "encrypt_secret_value",
36
+ "error_message",
37
+ "fetch_repos_json",
38
+ "graphql",
39
+ "paginated",
40
+ "public_repos_json",
41
+ "set_repo_secret",
42
+ ]
asyncgh/client.py ADDED
@@ -0,0 +1,242 @@
1
+ """GitHub REST API transport: auth, the shared async client, and the
2
+ request / pagination helpers every endpoint wrapper is built on.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import os
9
+ import subprocess
10
+ from typing import Any
11
+
12
+ import httpx2
13
+ import stamina
14
+
15
+ API_BASE = "https://api.github.com"
16
+
17
+ # Retry transport failures and transient statuses (429 + 5xx). `attempts` is
18
+ # total tries, so MAX_RETRIES=3 means one call plus three retries. There is no
19
+ # wall-clock timeout: GitHub's Retry-After on a secondary rate limit is often
20
+ # a minute or more, and honouring it is the whole point.
21
+ MAX_RETRIES = int(os.environ.get("GH_MAX_RETRIES", "3"))
22
+ RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
23
+ RETRY_WAIT_INITIAL = 1.0
24
+ RETRY_WAIT_MAX = 60.0
25
+ RETRY_WAIT_JITTER = 1.0
26
+
27
+
28
+ class GhError(RuntimeError):
29
+ """A GitHub API call -- or a caller's worker function -- failed
30
+ unexpectedly.
31
+
32
+ status_code is set for HTTP errors raised by api_json(), so callers can
33
+ branch on the real status code (e.g. 403 vs 404) instead of
34
+ string-matching an error message. Plain RuntimeError rather than a
35
+ reconcilekit.ReconcileError subclass -- asyncgh has no dependency on
36
+ reconcilekit, so it stays installable standalone; reconcilekit's own
37
+ run_parallel(..., error_cls=) only requires type[Exception], so GhError
38
+ still works there unchanged.
39
+ """
40
+
41
+ def __init__(self, message: str, *, status_code: int | None = None):
42
+ super().__init__(message)
43
+ self.status_code = status_code
44
+
45
+
46
+ def _auth_token() -> str:
47
+ """Reads the token `gh` already has -- keychain storage, SSO, and 2FA are
48
+ already solved by `gh auth login`, so this reuses that instead of
49
+ managing a separate credential.
50
+ """
51
+ result = subprocess.run(
52
+ ["gh", "auth", "token"], capture_output=True, text=True, check=False
53
+ )
54
+ if result.returncode != 0:
55
+ raise GhError(f"gh auth token failed: {result.stderr.strip()}")
56
+ return result.stdout.strip()
57
+
58
+
59
+ _client: httpx2.AsyncClient | None = None
60
+ _client_lock = asyncio.Lock()
61
+
62
+
63
+ async def _get_client() -> httpx2.AsyncClient:
64
+ # One shared AsyncClient rather than one per thread: there's no thread
65
+ # pool, and gh auth token only needs to be paid once per process.
66
+ global _client
67
+ if _client is None:
68
+ async with _client_lock:
69
+ if _client is None:
70
+ _client = httpx2.AsyncClient(
71
+ headers={
72
+ "Authorization": f"Bearer {_auth_token()}",
73
+ "Accept": "application/vnd.github+json",
74
+ "X-GitHub-Api-Version": "2022-11-28",
75
+ },
76
+ timeout=30,
77
+ )
78
+ return _client
79
+
80
+
81
+ async def aclose_client() -> None:
82
+ global _client
83
+ if _client is not None:
84
+ await _client.aclose()
85
+ _client = None
86
+
87
+
88
+ def _should_retry(exc: Exception) -> bool | float:
89
+ """stamina backoff hook. A `Retry-After` header (GitHub sends one on
90
+ secondary rate limits) sets the exact wait; otherwise transport errors
91
+ and RETRY_STATUSES responses retry on stamina's default backoff, and
92
+ everything else propagates.
93
+ """
94
+ response = getattr(exc, "response", None)
95
+ if response is not None:
96
+ header = response.headers.get("retry-after")
97
+ if header:
98
+ try:
99
+ return float(header)
100
+ except ValueError:
101
+ pass
102
+ return response.status_code in RETRY_STATUSES
103
+ return isinstance(exc, httpx2.TransportError)
104
+
105
+
106
+ async def api_request(
107
+ method: str, path: str, *, json: Any = None, params: dict | None = None
108
+ ) -> httpx2.Response:
109
+ """Makes one GitHub REST API call and returns the raw Response --
110
+ callers decide what a given status means for their endpoint (e.g. a 404
111
+ means "feature disabled" for vulnerability-alerts but "not found"
112
+ everywhere else). Raises GhError only for genuine transport failures
113
+ (DNS, timeout, connection reset); HTTP error statuses are returned, not
114
+ raised.
115
+
116
+ Transport errors and transient statuses (429, 5xx) are retried up to
117
+ MAX_RETRIES times, honouring `Retry-After`. GitHub's writes here are
118
+ idempotent (secret PUTs replace), so retrying any method is safe.
119
+ """
120
+ url = path if path.startswith("http") else f"{API_BASE}{path}"
121
+ http = await _get_client()
122
+ try:
123
+ async for attempt in stamina.retry_context(
124
+ on=_should_retry,
125
+ attempts=MAX_RETRIES + 1,
126
+ timeout=None,
127
+ wait_initial=RETRY_WAIT_INITIAL,
128
+ wait_max=RETRY_WAIT_MAX,
129
+ wait_jitter=RETRY_WAIT_JITTER,
130
+ ):
131
+ with attempt:
132
+ try:
133
+ response = await http.request(method, url, json=json, params=params)
134
+ except httpx2.TransportError:
135
+ raise
136
+ except httpx2.HTTPError as exc:
137
+ raise GhError(str(exc)) from exc
138
+ if response.status_code in RETRY_STATUSES:
139
+ response.raise_for_status()
140
+ return response
141
+ except httpx2.HTTPStatusError as exc:
142
+ return exc.response # retryable status, retries spent -- let the caller judge
143
+ except httpx2.TransportError as exc:
144
+ raise GhError(str(exc)) from exc
145
+ raise GhError("api_request retry loop exited without a response") # unreachable
146
+
147
+
148
+ def error_message(response: httpx2.Response) -> str:
149
+ """Extracts GitHub's own `message` field from an error response body,
150
+ falling back to the raw response text if the body isn't JSON.
151
+ """
152
+ try:
153
+ return response.json().get("message", response.text)
154
+ except ValueError:
155
+ return response.text
156
+
157
+
158
+ async def api_json(
159
+ method: str, path: str, *, json: Any = None, params: dict | None = None
160
+ ) -> dict:
161
+ """Like api_request, but raises GhError (with status_code and GitHub's
162
+ own error message) on any non-2xx response, and returns the parsed JSON
163
+ body -- or {} for a body-less response like 204 No Content -- on success.
164
+ """
165
+ response = await api_request(method, path, json=json, params=params)
166
+ if not response.is_success:
167
+ raise GhError(error_message(response), status_code=response.status_code)
168
+ return response.json() if response.content else {}
169
+
170
+
171
+ class _GraphQLRateLimited(Exception):
172
+ """Internal marker: GraphQL answered 200 with errors[].type ==
173
+ RATE_LIMITED, which api_request's transport/5xx retry never sees since
174
+ it only looks at the HTTP status. Retried at this layer instead.
175
+ """
176
+
177
+
178
+ async def graphql(query: str, variables: dict | None = None) -> dict:
179
+ """Runs one GraphQL query and returns its `data` object.
180
+
181
+ GraphQL always answers HTTP 200, even for query errors, so success lives
182
+ in the body: `errors` present (with `data` null or partial) raises
183
+ GhError with the joined messages -- callers have no partial-data story,
184
+ so partial loss is worse than a loud failure. RATE_LIMITED errors retry
185
+ (stamina, same backoff as api_request) since they're the one transient
186
+ GraphQL failure mode; other GraphQL errors (bad query, not found) are
187
+ not transient and raise immediately.
188
+ """
189
+ try:
190
+ async for attempt in stamina.retry_context(
191
+ on=lambda exc: isinstance(exc, _GraphQLRateLimited),
192
+ attempts=MAX_RETRIES + 1,
193
+ timeout=None,
194
+ wait_initial=RETRY_WAIT_INITIAL,
195
+ wait_max=RETRY_WAIT_MAX,
196
+ wait_jitter=RETRY_WAIT_JITTER,
197
+ ):
198
+ with attempt:
199
+ response = await api_request(
200
+ "POST",
201
+ "/graphql",
202
+ json={"query": query, "variables": variables or {}},
203
+ )
204
+ if not response.is_success:
205
+ raise GhError(
206
+ error_message(response), status_code=response.status_code
207
+ )
208
+ body = response.json()
209
+ errors = body.get("errors")
210
+ if errors:
211
+ if any(error.get("type") == "RATE_LIMITED" for error in errors):
212
+ raise _GraphQLRateLimited(
213
+ "; ".join(
214
+ error.get("message", str(error)) for error in errors
215
+ )
216
+ )
217
+ raise GhError(
218
+ "; ".join(error.get("message", str(error)) for error in errors)
219
+ )
220
+ return body["data"]
221
+ except _GraphQLRateLimited as exc:
222
+ raise GhError(str(exc)) from exc
223
+ raise GhError("graphql retry loop exited without a response") # unreachable
224
+
225
+
226
+ async def paginated(
227
+ method: str, path: str, *, params: dict | None = None
228
+ ) -> list[dict]:
229
+ """Follows GitHub's `Link: rel="next"` header, concatenating every page's
230
+ JSON array into one list.
231
+ """
232
+ items = []
233
+ url = path
234
+ query = params
235
+ while url:
236
+ response = await api_request(method, url, params=query)
237
+ if not response.is_success:
238
+ raise GhError(error_message(response), status_code=response.status_code)
239
+ items.extend(response.json())
240
+ url = response.links.get("next", {}).get("url")
241
+ query = None # the "next" link already carries the full query string
242
+ return items
asyncgh/endpoints.py ADDED
@@ -0,0 +1,62 @@
1
+ """Thin wrappers over the handful of GitHub REST endpoints shared across
2
+ repo-admin's scripts: account-wide repo listing and Actions secrets.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+
9
+ from nacl import encoding, public
10
+
11
+ from .client import api_json, paginated
12
+
13
+
14
+ async def fetch_repos_json(owner: str) -> list[dict]:
15
+ """Lists every repo for `owner`. When `owner` is the authenticated `gh`
16
+ user, uses /user/repos so private repos are included; otherwise falls
17
+ back to /users/{owner}/repos, which only ever returns public repos.
18
+ """
19
+ viewer = (await api_json("GET", "/user")).get("login")
20
+ if owner == viewer:
21
+ return await paginated(
22
+ "GET", "/user/repos", params={"affiliation": "owner", "per_page": "100"}
23
+ )
24
+ return await paginated("GET", f"/users/{owner}/repos", params={"per_page": "100"})
25
+
26
+
27
+ async def public_repos_json(owner: str) -> list[dict]:
28
+ """Lists `owner`'s public repos only, via /users/{owner}/repos -- unlike
29
+ fetch_repos_json, this always excludes private repos even when `owner`
30
+ is the authenticated user, so no /user call is needed to branch on it.
31
+ """
32
+ return await paginated("GET", f"/users/{owner}/repos", params={"per_page": "100"})
33
+
34
+
35
+ def encrypt_secret_value(public_key_b64: str, value: str) -> str:
36
+ """Encrypts `value` for GitHub's Actions secrets API using a repo's
37
+ public key, per GitHub's documented libsodium sealed-box scheme.
38
+ """
39
+ public_key = public.PublicKey(
40
+ public_key_b64.encode("utf-8"), encoding.Base64Encoder
41
+ )
42
+ encrypted = public.SealedBox(public_key).encrypt(value.encode("utf-8"))
43
+ return base64.b64encode(encrypted).decode("utf-8")
44
+
45
+
46
+ async def set_repo_secret(
47
+ owner: str, repo_name: str, secret_name: str, value: str
48
+ ) -> None:
49
+ """Sets one repo's Actions secret via GitHub's REST API: fetches the
50
+ repo's current public key, encrypts `value` for it, and PUTs the
51
+ result. The plaintext value never leaves this process -- it's encrypted
52
+ in memory before the request body is built.
53
+ """
54
+ key_data = await api_json(
55
+ "GET", f"/repos/{owner}/{repo_name}/actions/secrets/public-key"
56
+ )
57
+ encrypted_value = encrypt_secret_value(key_data["key"], value)
58
+ await api_json(
59
+ "PUT",
60
+ f"/repos/{owner}/{repo_name}/actions/secrets/{secret_name}",
61
+ json={"encrypted_value": encrypted_value, "key_id": key_data["key_id"]},
62
+ )
asyncgh/py.typed ADDED
File without changes
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: asyncgh
3
+ Version: 0.1.0
4
+ Summary: Minimal async GitHub REST + GraphQL transport: auth, retry, pagination -- no generated models
5
+ Project-URL: Homepage, https://github.com/hugoh/gh-workflows/tree/main/asyncgh
6
+ Project-URL: Issues, https://github.com/hugoh/gh-workflows/issues
7
+ Project-URL: Repository, https://github.com/hugoh/gh-workflows
8
+ Author: Hugo Haas
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: httpx2>=2.10
22
+ Requires-Dist: pynacl>=1.5
23
+ Requires-Dist: stamina>=25.2
24
+ Description-Content-Type: text/markdown
25
+
26
+ # asyncgh
27
+
28
+ A minimal async GitHub API transport — the plumbing an account-wide config
29
+ tool needs before any of its domain logic: authentication, a shared
30
+ connection, retrying error handling, REST pagination, and GraphQL.
31
+
32
+ There is no client object to construct and no config. It reads the token
33
+ `gh auth login` already stored, keeps one process-wide `httpx2.AsyncClient`,
34
+ and exposes plain functions.
35
+
36
+ ## Why not PyGithub / githubkit / gidgethub / fast.ai's `ghapi`?
37
+
38
+ | Library | Async | Shape | Trade-off vs `asyncgh` |
39
+ |---|---|---|---|
40
+ | [PyGithub](https://github.com/PyGithub/PyGithub) | no | typed objects | sync-only — a poor fit for anything doing concurrent, fan-out API calls |
41
+ | [githubkit](https://github.com/yanyongyu/githubkit) | yes | Pydantic models, generated from GitHub's OpenAPI spec | full, typed surface, but pulls in codegen'd models and `pydantic`; heavier than most scripts need |
42
+ | [gidgethub](https://github.com/gidgethub/gidgethub) | yes | sans-I/O, bring-your-own HTTP client | closest in spirit, but leaves auth, retry, and the client itself to the caller |
43
+ | [ghapi](https://ghapi.fast.ai) (fast.ai) | no | OpenAPI-generated, dynamic attribute access | lightweight like this package, but sync-only |
44
+ | **asyncgh** | yes | plain `dict`s, ~200 lines | no generated models, no schema — you read GitHub's own docs and index the JSON; retry and GraphQL are built in, not bolted on |
45
+
46
+ If you want typed responses and full API coverage, use `githubkit`. If you
47
+ want an async client with almost no code between you and the wire — a script
48
+ firing dozens of concurrent calls that reads a handful of fields per
49
+ response — that's what this is for.
50
+
51
+ ## API
52
+
53
+ Full API reference, generated from the docstrings:
54
+ [hugoh.github.io/gh-workflows/asyncgh](https://hugoh.github.io/gh-workflows/asyncgh/)
55
+ (rebuilt on every push that touches this package -- see
56
+ `.github/workflows/docs.yml`).
57
+
58
+ ## Auth
59
+
60
+ `Authorization: Bearer $(gh auth token)`, resolved lazily on the first
61
+ request and cached for the process. `gh` must be installed and logged in.
62
+
63
+ ## Retries
64
+
65
+ `api_request` retries transport errors (DNS, timeout, reset) and transient
66
+ statuses — 429, 500, 502, 503, 504 — via [`stamina`](https://stamina.hynek.me).
67
+ A `Retry-After` header sets the exact wait; otherwise exponential backoff with
68
+ jitter. `GH_MAX_RETRIES` (default 3) caps the retries; there is no wall-clock
69
+ timeout, so a minute-long `Retry-After` is honoured in full. When the retries
70
+ are spent the last response is returned unchanged for the caller to judge.
71
+
72
+ `graphql()` reuses that same retry for transport errors and 429/5xx, plus one
73
+ addition: GitHub answers GraphQL rate limiting with HTTP 200 and an
74
+ `errors[].type == "RATE_LIMITED"` body, which `api_request`'s status-based
75
+ retry never sees -- `graphql()` retries that case itself, on the same
76
+ backoff. Any other GraphQL error (bad query, not found) is not transient and
77
+ raises immediately.
78
+
79
+ ## Consumers
80
+
81
+ `repo-admin/` (in this repo) uses it for every account-wide GitHub command --
82
+ `repo_admin.py`'s `sync` subcommands and `activity.py` via `api_request`/
83
+ `api_json`/pagination; `digest.py` (wrapped by `../digest-action/`) is the
84
+ one driving `graphql()`.
@@ -0,0 +1,8 @@
1
+ asyncgh/__init__.py,sha256=iHHjQCUvKQUZ5sz8obhWTOS_p8XRrq2kx-oLMB6Su5o,957
2
+ asyncgh/client.py,sha256=L7td1RNmfqsEhFThkRTuGlGXDhx9710kx8d-nkMhaWE,9350
3
+ asyncgh/endpoints.py,sha256=FEDdEhRguDuHAmJRk1cec12pJA-R9Wd7SV7--yveAI0,2429
4
+ asyncgh/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ asyncgh-0.1.0.dist-info/METADATA,sha256=jG4Uc7q9YtZkYflQ7ndXxmn4OEZ8w9aACAdtsaM6twY,4207
6
+ asyncgh-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ asyncgh-0.1.0.dist-info/licenses/LICENSE,sha256=qCv-aayDkFcB09IGjnNETtrZ31NOA1tdCq4IJ7nZObc,1066
8
+ asyncgh-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hugo Haas
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.