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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asyncgh
3
- Version: 0.3.0
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
+ [![PyPI](https://img.shields.io/pypi/v/asyncgh)](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
+ [![PyPI](https://img.shields.io/pypi/v/asyncgh)](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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "asyncgh"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Minimal async GitHub REST + GraphQL transport: auth, retry, pagination -- no generated models"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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 = (await client.api_json("GET", "/user")).get("login")
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
- body = json.loads(put_route.calls[0].request.content)
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
- body = json.loads(put_route.calls[0].request.content)
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