asyncgh 0.3.2__tar.gz → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: asyncgh
3
- Version: 0.3.2
3
+ Version: 0.5.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "asyncgh"
3
- version = "0.3.2"
3
+ version = "0.5.0"
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"
@@ -30,6 +30,7 @@ from .endpoints import (
30
30
  get_repo_public_key,
31
31
  public_repos,
32
32
  set_repo_secret,
33
+ set_repo_variable,
33
34
  )
34
35
 
35
36
  __all__ = [
@@ -48,4 +49,5 @@ __all__ = [
48
49
  "paginated",
49
50
  "public_repos",
50
51
  "set_repo_secret",
52
+ "set_repo_variable",
51
53
  ]
@@ -106,6 +106,22 @@ def _should_retry_graphql(exc: Exception) -> bool | float:
106
106
  return _should_retry(exc)
107
107
 
108
108
 
109
+ def _format_graphql_errors(errors: list[dict]) -> str:
110
+ """Joins GraphQL `errors[]` into one message, appending each error's
111
+ `path` (e.g. `r3.pullRequests.nodes.0.commits`) when present -- a
112
+ partial-permission failure names only "Resource not accessible by
113
+ personal access token" otherwise, with no hint which repo or field.
114
+ """
115
+ parts = []
116
+ for error in errors:
117
+ message = error.get("message", str(error))
118
+ path = error.get("path")
119
+ if path:
120
+ message = f"{message} (at {'.'.join(str(segment) for segment in path)})"
121
+ parts.append(message)
122
+ return "; ".join(parts)
123
+
124
+
109
125
  def error_message(response: httpx2.Response) -> str:
110
126
  """Extracts GitHub's own `message` field from an error response body,
111
127
  falling back to the raw response text if the body isn't JSON, or isn't
@@ -294,13 +310,9 @@ class GitHubClient:
294
310
  if not errors:
295
311
  return body["data"]
296
312
  if any(error.get("type") == "RATE_LIMITED" for error in errors):
297
- raise _GraphQLRateLimited(
298
- "; ".join(
299
- error.get("message", str(error)) for error in errors
300
- )
301
- )
313
+ raise _GraphQLRateLimited(_format_graphql_errors(errors))
302
314
  raise GhError(
303
- "; ".join(error.get("message", str(error)) for error in errors),
315
+ _format_graphql_errors(errors),
304
316
  error_type=errors[0].get("type"),
305
317
  )
306
318
  except _GraphQLRateLimited as exc:
@@ -15,7 +15,7 @@ from typing import TypedDict, cast
15
15
 
16
16
  from nacl import encoding, public
17
17
 
18
- from .client import GitHubClient, _get_default_client
18
+ from .client import GhError, GitHubClient, _get_default_client, error_message
19
19
 
20
20
 
21
21
  class RepoJSON(TypedDict, total=False):
@@ -122,3 +122,36 @@ async def set_repo_secret(
122
122
  f"/repos/{owner}/{repo_name}/actions/secrets/{secret_name}",
123
123
  json={"encrypted_value": encrypted_value, "key_id": key_data["key_id"]},
124
124
  )
125
+
126
+
127
+ async def set_repo_variable(
128
+ owner: str,
129
+ repo_name: str,
130
+ name: str,
131
+ value: str,
132
+ *,
133
+ client: GitHubClient | None = None,
134
+ ) -> None:
135
+ """Creates or updates one repo's Actions variable via GitHub's REST API.
136
+
137
+ GitHub splits this across two endpoints -- POST .../actions/variables to
138
+ create, PATCH .../actions/variables/{name} to update -- so this PATCHes
139
+ first and falls back to POST on the 404 a not-yet-existing variable
140
+ returns. Unlike a secret, a variable's value is plaintext both in the
141
+ request and in GitHub's API responses.
142
+ """
143
+ client = client or _get_default_client()
144
+ response = await client.api_raw(
145
+ "PATCH",
146
+ f"/repos/{owner}/{repo_name}/actions/variables/{name}",
147
+ json={"name": name, "value": value},
148
+ )
149
+ if response.status_code == 404:
150
+ await client.api_json(
151
+ "POST",
152
+ f"/repos/{owner}/{repo_name}/actions/variables",
153
+ json={"name": name, "value": value},
154
+ )
155
+ return
156
+ if not response.is_success:
157
+ raise GhError(error_message(response), status_code=response.status_code)
@@ -244,6 +244,32 @@ async def test_graphql_raises_gh_error_when_errors_present_alongside_partial_dat
244
244
  await graphql("query { r0 r1 }")
245
245
 
246
246
 
247
+ async def test_graphql_error_message_includes_the_error_path(
248
+ httpx2_mock: respx.Router,
249
+ ):
250
+ httpx2_mock.post(f"{API_BASE}/graphql").mock(
251
+ return_value=httpx.Response(
252
+ 200,
253
+ json={
254
+ "data": {"r3": None},
255
+ "errors": [
256
+ {
257
+ "type": "FORBIDDEN",
258
+ "message": "Resource not accessible by personal access token",
259
+ "path": ["r3", "pullRequests", "nodes", 0, "commits"],
260
+ }
261
+ ],
262
+ },
263
+ )
264
+ )
265
+ with pytest.raises(
266
+ GhError,
267
+ match=r"personal access token \(at r3\.pullRequests\.nodes\.0\.commits\)",
268
+ ) as exc_info:
269
+ await graphql("query { r3 { pullRequests { nodes { commits } } } }")
270
+ assert exc_info.value.error_type == "FORBIDDEN"
271
+
272
+
247
273
  async def test_graphql_retries_on_transport_error(httpx2_mock: respx.Router):
248
274
  route = httpx2_mock.post(f"{API_BASE}/graphql").mock(
249
275
  side_effect=[
@@ -2,16 +2,19 @@ import base64
2
2
  import json
3
3
 
4
4
  import httpx
5
+ import pytest
5
6
  import respx
6
7
  from nacl import encoding, public
7
8
 
8
9
  from asyncgh import (
9
10
  API_BASE,
11
+ GhError,
10
12
  encrypt_secret_value,
11
13
  fetch_repos,
12
14
  get_repo_public_key,
13
15
  public_repos,
14
16
  set_repo_secret,
17
+ set_repo_variable,
15
18
  )
16
19
 
17
20
 
@@ -173,3 +176,47 @@ async def test_set_repo_secret_reuses_a_passed_in_public_key(
173
176
  )
174
177
 
175
178
  _assert_put_body_encrypts(put_route, private_key, b"the-value")
179
+
180
+
181
+ async def test_set_repo_variable_patches_an_existing_variable(
182
+ httpx2_mock: respx.Router,
183
+ ):
184
+ patch_route = httpx2_mock.patch(
185
+ f"{API_BASE}/repos/hugoh/repo/actions/variables/NAME"
186
+ ).mock(return_value=httpx.Response(204))
187
+
188
+ await set_repo_variable("hugoh", "repo", "NAME", "the-value")
189
+
190
+ assert patch_route.call_count == 1
191
+ assert json.loads(patch_route.calls[0].request.content) == {
192
+ "name": "NAME",
193
+ "value": "the-value",
194
+ }
195
+
196
+
197
+ async def test_set_repo_variable_creates_when_patch_returns_404(
198
+ httpx2_mock: respx.Router,
199
+ ):
200
+ httpx2_mock.patch(f"{API_BASE}/repos/hugoh/repo/actions/variables/NAME").mock(
201
+ return_value=httpx.Response(404, json={"message": "Not Found"})
202
+ )
203
+ post_route = httpx2_mock.post(
204
+ f"{API_BASE}/repos/hugoh/repo/actions/variables"
205
+ ).mock(return_value=httpx.Response(201))
206
+
207
+ await set_repo_variable("hugoh", "repo", "NAME", "the-value")
208
+
209
+ assert post_route.call_count == 1
210
+ assert json.loads(post_route.calls[0].request.content) == {
211
+ "name": "NAME",
212
+ "value": "the-value",
213
+ }
214
+
215
+
216
+ async def test_set_repo_variable_raises_on_a_non_404_error(httpx2_mock: respx.Router):
217
+ httpx2_mock.patch(f"{API_BASE}/repos/hugoh/repo/actions/variables/NAME").mock(
218
+ return_value=httpx.Response(422, json={"message": "Unprocessable"})
219
+ )
220
+
221
+ with pytest.raises(GhError):
222
+ await set_repo_variable("hugoh", "repo", "NAME", "the-value")
File without changes
File without changes
File without changes
File without changes
File without changes