asyncgh 0.3.0__tar.gz → 0.3.2__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.
- {asyncgh-0.3.0 → asyncgh-0.3.2}/PKG-INFO +3 -1
- {asyncgh-0.3.0 → asyncgh-0.3.2}/README.md +2 -0
- {asyncgh-0.3.0 → asyncgh-0.3.2}/pyproject.toml +1 -1
- {asyncgh-0.3.0 → asyncgh-0.3.2}/src/asyncgh/client.py +15 -0
- {asyncgh-0.3.0 → asyncgh-0.3.2}/src/asyncgh/endpoints.py +1 -1
- {asyncgh-0.3.0 → asyncgh-0.3.2}/tests/test_client.py +35 -0
- {asyncgh-0.3.0 → asyncgh-0.3.2}/tests/test_endpoints.py +13 -12
- {asyncgh-0.3.0 → asyncgh-0.3.2}/.gitignore +0 -0
- {asyncgh-0.3.0 → asyncgh-0.3.2}/LICENSE +0 -0
- {asyncgh-0.3.0 → asyncgh-0.3.2}/src/asyncgh/__init__.py +0 -0
- {asyncgh-0.3.0 → asyncgh-0.3.2}/src/asyncgh/py.typed +0 -0
- {asyncgh-0.3.0 → asyncgh-0.3.2}/tests/conftest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: asyncgh
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Minimal async GitHub REST + GraphQL transport: auth, retry, pagination -- no generated models
|
|
5
5
|
Project-URL: Homepage, https://github.com/hugoh/gh-workflows/tree/main/asyncgh
|
|
6
6
|
Project-URL: Issues, https://github.com/hugoh/gh-workflows/issues
|
|
@@ -25,6 +25,8 @@ Description-Content-Type: text/markdown
|
|
|
25
25
|
|
|
26
26
|
# asyncgh
|
|
27
27
|
|
|
28
|
+
[](https://pypi.org/project/asyncgh/)
|
|
29
|
+
|
|
28
30
|
A minimal async GitHub API transport — the plumbing an account-wide config
|
|
29
31
|
tool needs before any of its domain logic: authentication, a connection,
|
|
30
32
|
retrying error handling, REST pagination, and GraphQL.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# asyncgh
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/asyncgh/)
|
|
4
|
+
|
|
3
5
|
A minimal async GitHub API transport — the plumbing an account-wide config
|
|
4
6
|
tool needs before any of its domain logic: authentication, a connection,
|
|
5
7
|
retrying error handling, REST pagination, and GraphQL.
|
|
@@ -144,6 +144,8 @@ class GitHubClient:
|
|
|
144
144
|
)
|
|
145
145
|
self._http: httpx2.AsyncClient | None = None
|
|
146
146
|
self._lock = asyncio.Lock()
|
|
147
|
+
self._viewer: str | None = None
|
|
148
|
+
self._viewer_lock = asyncio.Lock()
|
|
147
149
|
|
|
148
150
|
async def _get_http(self) -> httpx2.AsyncClient:
|
|
149
151
|
if self._http is None:
|
|
@@ -160,6 +162,19 @@ class GitHubClient:
|
|
|
160
162
|
)
|
|
161
163
|
return self._http
|
|
162
164
|
|
|
165
|
+
async def viewer(self) -> str:
|
|
166
|
+
"""The login of the authenticated user (`GET /user`), cached for the
|
|
167
|
+
life of this client -- it's the same account for as long as the
|
|
168
|
+
client's token doesn't change, so repeated callers (e.g. fetch_repos
|
|
169
|
+
on every invocation) share one request instead of each firing their
|
|
170
|
+
own.
|
|
171
|
+
"""
|
|
172
|
+
if self._viewer is None:
|
|
173
|
+
async with self._viewer_lock:
|
|
174
|
+
if self._viewer is None:
|
|
175
|
+
self._viewer = (await self.api_json("GET", "/user"))["login"]
|
|
176
|
+
return self._viewer
|
|
177
|
+
|
|
163
178
|
async def aclose(self) -> None:
|
|
164
179
|
if self._http is not None:
|
|
165
180
|
await self._http.aclose()
|
|
@@ -47,7 +47,7 @@ async def fetch_repos(
|
|
|
47
47
|
only ever returns public repos.
|
|
48
48
|
"""
|
|
49
49
|
client = client or _get_default_client()
|
|
50
|
-
viewer =
|
|
50
|
+
viewer = await client.viewer()
|
|
51
51
|
if owner == viewer:
|
|
52
52
|
repos = await client.paginated(
|
|
53
53
|
"GET", "/user/repos", params={"affiliation": "owner", "per_page": "100"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import json
|
|
2
3
|
|
|
3
4
|
import httpx
|
|
@@ -388,6 +389,40 @@ async def test_github_client_max_retries_overrides_the_default(
|
|
|
388
389
|
await gh.aclose()
|
|
389
390
|
|
|
390
391
|
|
|
392
|
+
async def test_viewer_caches_the_authenticated_login_across_calls(
|
|
393
|
+
httpx2_mock: respx.Router,
|
|
394
|
+
):
|
|
395
|
+
route = httpx2_mock.get(f"{API_BASE}/user").mock(
|
|
396
|
+
return_value=httpx.Response(200, json={"login": "hugoh"})
|
|
397
|
+
)
|
|
398
|
+
gh = GitHubClient(token="t")
|
|
399
|
+
assert await gh.viewer() == "hugoh"
|
|
400
|
+
assert await gh.viewer() == "hugoh"
|
|
401
|
+
assert route.call_count == 1
|
|
402
|
+
await gh.aclose()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def test_viewer_concurrent_callers_share_one_request(
|
|
406
|
+
httpx2_mock: respx.Router,
|
|
407
|
+
):
|
|
408
|
+
# The mocked transport normally resolves without ever suspending, so
|
|
409
|
+
# concurrent callers would run to completion one at a time regardless of
|
|
410
|
+
# locking and never actually race. Force a real suspension point inside
|
|
411
|
+
# the request so the second caller's check-then-await genuinely
|
|
412
|
+
# interleaves with the first's, the way it would over a real network
|
|
413
|
+
# call.
|
|
414
|
+
async def delayed_response(request):
|
|
415
|
+
await asyncio.sleep(0)
|
|
416
|
+
return httpx.Response(200, json={"login": "hugoh"})
|
|
417
|
+
|
|
418
|
+
route = httpx2_mock.get(f"{API_BASE}/user").mock(side_effect=delayed_response)
|
|
419
|
+
gh = GitHubClient(token="t")
|
|
420
|
+
results = await asyncio.gather(*(gh.viewer() for _ in range(5)))
|
|
421
|
+
assert results == ["hugoh"] * 5
|
|
422
|
+
assert route.call_count == 1
|
|
423
|
+
await gh.aclose()
|
|
424
|
+
|
|
425
|
+
|
|
391
426
|
async def test_github_client_as_context_manager_closes_on_exit(
|
|
392
427
|
httpx2_mock: respx.Router,
|
|
393
428
|
):
|
|
@@ -111,6 +111,17 @@ def _sealed_box_key() -> tuple[public.PrivateKey, str]:
|
|
|
111
111
|
return private_key, public_key_b64
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
def _assert_put_body_encrypts(
|
|
115
|
+
put_route: respx.Route, private_key: public.PrivateKey, expected_value: bytes
|
|
116
|
+
) -> None:
|
|
117
|
+
body = json.loads(put_route.calls[0].request.content)
|
|
118
|
+
assert body["key_id"] == "key-id-123"
|
|
119
|
+
decrypted = public.SealedBox(private_key).decrypt(
|
|
120
|
+
base64.b64decode(body["encrypted_value"])
|
|
121
|
+
)
|
|
122
|
+
assert decrypted == expected_value
|
|
123
|
+
|
|
124
|
+
|
|
114
125
|
async def test_get_repo_public_key_returns_key_and_id(httpx2_mock: respx.Router):
|
|
115
126
|
httpx2_mock.get(f"{API_BASE}/repos/hugoh/repo/actions/secrets/public-key").mock(
|
|
116
127
|
return_value=httpx.Response(
|
|
@@ -139,12 +150,7 @@ async def test_set_repo_secret_encrypts_and_puts_with_key_id(
|
|
|
139
150
|
await set_repo_secret("hugoh", "repo", "NAME", "the-value")
|
|
140
151
|
|
|
141
152
|
assert put_route.call_count == 1
|
|
142
|
-
|
|
143
|
-
assert body["key_id"] == "key-id-123"
|
|
144
|
-
decrypted = public.SealedBox(private_key).decrypt(
|
|
145
|
-
base64.b64decode(body["encrypted_value"])
|
|
146
|
-
)
|
|
147
|
-
assert decrypted == b"the-value"
|
|
153
|
+
_assert_put_body_encrypts(put_route, private_key, b"the-value")
|
|
148
154
|
|
|
149
155
|
|
|
150
156
|
async def test_set_repo_secret_reuses_a_passed_in_public_key(
|
|
@@ -166,9 +172,4 @@ async def test_set_repo_secret_reuses_a_passed_in_public_key(
|
|
|
166
172
|
public_key={"key": public_key_b64, "key_id": "key-id-123"},
|
|
167
173
|
)
|
|
168
174
|
|
|
169
|
-
|
|
170
|
-
assert body["key_id"] == "key-id-123"
|
|
171
|
-
decrypted = public.SealedBox(private_key).decrypt(
|
|
172
|
-
base64.b64decode(body["encrypted_value"])
|
|
173
|
-
)
|
|
174
|
-
assert decrypted == b"the-value"
|
|
175
|
+
_assert_put_body_encrypts(put_route, private_key, b"the-value")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|