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.
- {asyncgh-0.3.2 → asyncgh-0.5.0}/PKG-INFO +1 -1
- {asyncgh-0.3.2 → asyncgh-0.5.0}/pyproject.toml +1 -1
- {asyncgh-0.3.2 → asyncgh-0.5.0}/src/asyncgh/__init__.py +2 -0
- {asyncgh-0.3.2 → asyncgh-0.5.0}/src/asyncgh/client.py +18 -6
- {asyncgh-0.3.2 → asyncgh-0.5.0}/src/asyncgh/endpoints.py +34 -1
- {asyncgh-0.3.2 → asyncgh-0.5.0}/tests/test_client.py +26 -0
- {asyncgh-0.3.2 → asyncgh-0.5.0}/tests/test_endpoints.py +47 -0
- {asyncgh-0.3.2 → asyncgh-0.5.0}/.gitignore +0 -0
- {asyncgh-0.3.2 → asyncgh-0.5.0}/LICENSE +0 -0
- {asyncgh-0.3.2 → asyncgh-0.5.0}/README.md +0 -0
- {asyncgh-0.3.2 → asyncgh-0.5.0}/src/asyncgh/py.typed +0 -0
- {asyncgh-0.3.2 → asyncgh-0.5.0}/tests/conftest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: asyncgh
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -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
|
-
|
|
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
|