Skip to content

Commit 99fa9d0

Browse files
committed
Restructure github_async into a lazy-loaded package
Splits the single-file `ddev/utils/github_async.py` into a package with the client and HTTP-shape primitives in `client.py` and one model per submodule under `models/`. Both `__init__.py` files use PEP 562 module-level `__getattr__` so importing one symbol only loads the submodule that defines it -- in particular `from ddev.utils.github_async.models import PullRequest` does not pull in the workflow or comment models. Adds two new endpoints used by upcoming work: - `AsyncGitHubClient.create_pull_request(owner, repo, title, head, base, body, draft)` - `AsyncGitHubClient.add_labels_to_issue(owner, repo, issue_number, labels)` Expands the `PullRequest` model with the typical fields callers need (id, state, draft, title, body, user, assignees, requested_reviewers, labels, created_at/updated_at/closed_at/merged_at, head, base) plus three small sub-models (`GitHubUser`, `Label`, `PullRequestRef`). Only `number` and `html_url` are required; the rest default to None/[] so partial payloads parse fine and `extra='ignore'` keeps the schema forward-compatible. Adds `FakeAsyncGitHubClient` and the `fake_async_github` pytest fixture in `tests/helpers/github_async.py`. The fake records every call and offers `mock_response(method, response, /, *, once=False, **match_kwargs)` for stubbing replies. Responses can be `BaseException` instances (raised), `GitHubResponse` instances (passed through), or inner data (auto-wrapped). `once=True` adds to a per-method FIFO queue so tests can model retry sequences. Sticky mocks (no `once`) match the most-recent registration. `assert_called_with` / `assert_called_once_with` perform strict-equality checks on kwargs; `assert_all_responses_consumed()` asserts the one-shot queue was drained.
1 parent c6b1901 commit 99fa9d0

14 files changed

Lines changed: 1251 additions & 109 deletions

File tree

ddev/changelog.d/23685.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Restructure `ddev.utils.github_async` into a package with lazy model imports, add `create_pull_request` and `add_labels_to_issue` endpoints, and add a `FakeAsyncGitHubClient` test helper with a `mock_response` API.

ddev/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ include = ["src"]
7878
python-version = "3.13"
7979
scripts = ["ddev"]
8080

81+
[tool.pytest.ini_options]
82+
asyncio_mode = "auto"
83+
8184
# Keep Black configuration to generate models through validate
8285
# Switch to Ruff after it provides a Python API
8386
[tool.black]
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""Async GitHub REST API client.
5+
6+
Typical usage::
7+
8+
from ddev.utils.github_async import async_github_client
9+
from ddev.utils.github_async.models import PullRequest
10+
11+
async with async_github_client(token=my_token) as client:
12+
response = await client.create_pull_request(...)
13+
14+
Both this package's top-level symbols and the ``models`` subpackage use PEP 562
15+
``__getattr__`` to load submodules on demand. Importing a model from
16+
``ddev.utils.github_async.models`` does not load the HTTP client (so ``httpx``
17+
stays out of ``sys.modules`` for callers that only need the types).
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import TYPE_CHECKING, Any
23+
24+
if TYPE_CHECKING:
25+
# Re-exports for type checkers / IDE autocomplete only; do not execute at runtime.
26+
# The `X as X` aliases mark these as explicit re-exports for linters.
27+
from .client import DEFAULT_BASE_URL as DEFAULT_BASE_URL
28+
from .client import GITHUB_API_VERSION as GITHUB_API_VERSION
29+
from .client import AsyncGitHubClient as AsyncGitHubClient
30+
from .client import GitHubResponse as GitHubResponse
31+
from .client import PaginationData as PaginationData
32+
from .client import async_github_client as async_github_client
33+
34+
# Map of exported name -> submodule (relative to this package) that defines it.
35+
_MODULE_BY_NAME: dict[str, str] = {
36+
'AsyncGitHubClient': 'client',
37+
'async_github_client': 'client',
38+
'GITHUB_API_VERSION': 'client',
39+
'DEFAULT_BASE_URL': 'client',
40+
'GitHubResponse': 'client',
41+
'PaginationData': 'client',
42+
}
43+
44+
45+
def __getattr__(name: str) -> Any:
46+
try:
47+
module_name = _MODULE_BY_NAME[name]
48+
except KeyError:
49+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}') from None
50+
51+
import importlib
52+
53+
module = importlib.import_module(f'.{module_name}', __name__)
54+
value = getattr(module, name)
55+
globals()[name] = value
56+
return value
57+
58+
59+
def __dir__() -> list[str]:
60+
return sorted(set(globals()) | _MODULE_BY_NAME.keys())
61+
62+
63+
__all__ = sorted(_MODULE_BY_NAME)

ddev/src/ddev/utils/github_async.py renamed to ddev/src/ddev/utils/github_async/client.py

Lines changed: 100 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
"""Async GitHub API client for triggering and monitoring GitHub Actions workflows"""
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""Async HTTP client for the GitHub REST API."""
5+
6+
from __future__ import annotations
27

38
import re
49
from collections.abc import AsyncIterator
@@ -9,14 +14,23 @@
914
import httpx
1015
from pydantic import BaseModel, ConfigDict, Field
1116

17+
from .models import (
18+
ArtifactsList,
19+
IssueComment,
20+
Label,
21+
PullRequest,
22+
PullRequestReviewComment,
23+
WorkflowRun,
24+
)
25+
1226
GITHUB_API_VERSION = "2022-11-28"
1327
DEFAULT_BASE_URL = "https://api.github.com"
1428

1529
_LINK_RE = re.compile(r'<([^>]+)>;\s*rel="([^"]+)"')
1630

1731

1832
# ---------------------------------------------------------------------------
19-
# Pagination
33+
# Pagination + response wrappers
2034
# ---------------------------------------------------------------------------
2135

2236

@@ -45,11 +59,6 @@ def from_header(cls, header: str | None) -> Self:
4559
)
4660

4761

48-
# ---------------------------------------------------------------------------
49-
# Response and domain models
50-
# ---------------------------------------------------------------------------
51-
52-
5362
class GitHubResponse[T](BaseModel):
5463
"""Generic wrapper for a GitHub API response."""
5564

@@ -59,75 +68,6 @@ class GitHubResponse[T](BaseModel):
5968
headers: dict[str, str] = Field(default_factory=dict)
6069

6170

62-
class WorkflowRun(BaseModel):
63-
"""A GitHub Actions workflow run."""
64-
65-
model_config = ConfigDict(extra="ignore")
66-
67-
id: int
68-
name: str | None = None
69-
status: str
70-
conclusion: str | None = None
71-
html_url: str | None = None
72-
created_at: str | None = None
73-
updated_at: str | None = None
74-
75-
76-
class Artifact(BaseModel):
77-
"""A GitHub Actions artifact."""
78-
79-
model_config = ConfigDict(extra="ignore")
80-
81-
id: int
82-
name: str
83-
size_in_bytes: int | None = None
84-
url: str | None = None
85-
archive_download_url: str | None = None
86-
expired: bool
87-
88-
89-
class ArtifactsList(BaseModel):
90-
"""A list of artifacts with a total count."""
91-
92-
model_config = ConfigDict(extra="ignore")
93-
94-
total_count: int
95-
artifacts: list[Artifact]
96-
97-
98-
class IssueComment(BaseModel):
99-
"""A GitHub issue (or PR) comment."""
100-
101-
model_config = ConfigDict(extra="ignore")
102-
103-
id: int
104-
body: str
105-
user: dict[str, Any] | None = None
106-
created_at: str | None = None
107-
updated_at: str | None = None
108-
html_url: str | None = None
109-
110-
111-
class PullRequestReviewComment(BaseModel):
112-
"""An inline review comment on a pull request diff."""
113-
114-
model_config = ConfigDict(extra="ignore")
115-
116-
id: int
117-
body: str
118-
path: str
119-
commit_id: str
120-
html_url: str | None = None
121-
created_at: str | None = None
122-
updated_at: str | None = None
123-
user: dict[str, Any] | None = None
124-
125-
126-
# ---------------------------------------------------------------------------
127-
# Client
128-
# ---------------------------------------------------------------------------
129-
130-
13171
class AsyncGitHubClient:
13272
"""
13373
Async HTTP client for the GitHub REST API.
@@ -335,6 +275,78 @@ async def create_issue_comment(
335275
)
336276
return self._parse_response(response, IssueComment)
337277

278+
async def create_pull_request(
279+
self,
280+
owner: str,
281+
repo: str,
282+
title: str,
283+
head: str,
284+
base: str,
285+
body: str = "",
286+
draft: bool = False,
287+
timeout: float | None = None,
288+
) -> GitHubResponse[PullRequest]:
289+
"""
290+
Calls the GitHub API to create a pull request.
291+
292+
GitHub API Documentation:
293+
https://docs.github.com/en/rest/pulls/pulls#create-a-pull-request
294+
295+
Args:
296+
owner: Repository owner (user or organisation).
297+
repo: Repository name.
298+
title: Pull request title.
299+
head: Name of the branch containing the changes.
300+
base: Name of the branch to merge into.
301+
body: Pull request body.
302+
draft: Whether to open the pull request as a draft.
303+
timeout: Optional timeout for this specific request. Defaults to the client's default_timeout.
304+
305+
Returns:
306+
GitHubResponse[PullRequest]: The validated pull request data and headers.
307+
"""
308+
response = await self._request(
309+
"POST",
310+
f"/repos/{owner}/{repo}/pulls",
311+
timeout=timeout,
312+
json={"title": title, "head": head, "base": base, "body": body, "draft": draft},
313+
)
314+
return self._parse_response(response, PullRequest)
315+
316+
async def add_labels_to_issue(
317+
self,
318+
owner: str,
319+
repo: str,
320+
issue_number: int,
321+
labels: list[str],
322+
timeout: float | None = None,
323+
) -> GitHubResponse[list[Label]]:
324+
"""
325+
Calls the GitHub API to add one or more labels to an issue or pull request.
326+
327+
GitHub API Documentation:
328+
https://docs.github.com/en/rest/issues/labels#add-labels-to-an-issue
329+
330+
Args:
331+
owner: Repository owner (user or organisation).
332+
repo: Repository name.
333+
issue_number: Issue or pull request number.
334+
labels: Labels to add. Existing labels on the issue are preserved.
335+
timeout: Optional timeout for this specific request. Defaults to the client's default_timeout.
336+
337+
Returns:
338+
GitHubResponse[list[Label]]: The full label list resulting from the operation (preserves
339+
any pre-existing labels alongside the newly added ones).
340+
"""
341+
response = await self._request(
342+
"POST",
343+
f"/repos/{owner}/{repo}/issues/{issue_number}/labels",
344+
timeout=timeout,
345+
json={"labels": labels},
346+
)
347+
labels_out = [Label.model_validate(item) for item in response.json()]
348+
return GitHubResponse[list[Label]].model_validate({"data": labels_out, "headers": dict(response.headers)})
349+
338350
async def create_pr_review_comment(
339351
self,
340352
owner: str,
@@ -358,18 +370,22 @@ async def create_pr_review_comment(
358370
owner: Repository owner (user or organisation).
359371
repo: Repository name.
360372
pull_number: Pull request number.
361-
body: Markdown body text of the review comment.
373+
body: Markdown body text of the comment.
362374
commit_id: SHA of the commit to comment on.
363-
path: Relative path of the file to comment on.
364-
position: Line index in the diff (deprecated but still supported by the API).
365-
line: Line number in the file to comment on (used with `side`).
366-
side: Side of the diff to comment on — ``"LEFT"`` or ``"RIGHT"``.
375+
path: Path of the file to comment on.
376+
position: Line index in the diff (mutually exclusive with line/side).
377+
line: Line number in the file (newer style, paired with side).
378+
side: 'LEFT' or 'RIGHT' (newer style, paired with line).
367379
timeout: Optional timeout for this specific request. Defaults to the client's default_timeout.
368380
369381
Returns:
370-
GitHubResponse[PullRequestReviewComment]: The validated review comment data and headers.
382+
GitHubResponse[PullRequestReviewComment]: The validated comment data and headers.
371383
"""
372-
payload: dict[str, Any] = {"body": body, "commit_id": commit_id, "path": path}
384+
payload: dict[str, Any] = {
385+
"body": body,
386+
"commit_id": commit_id,
387+
"path": path,
388+
}
373389
if position is not None:
374390
payload["position"] = position
375391
if line is not None:
@@ -386,7 +402,7 @@ async def create_pr_review_comment(
386402

387403

388404
# ---------------------------------------------------------------------------
389-
# Context manager helper
405+
# Async context manager
390406
# ---------------------------------------------------------------------------
391407

392408

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""Lazy re-exports for the async GitHub client's domain models.
5+
6+
Each model class lives in its own submodule (e.g. ``pull_request.py``). The
7+
re-exports below let callers write::
8+
9+
from ddev.utils.github_async.models import PullRequest
10+
11+
without having to know which submodule the class lives in, and without
12+
eagerly importing every submodule when only one is used.
13+
14+
Mechanism: PEP 562's module-level ``__getattr__`` hook. The first time a
15+
name is accessed, the matching submodule is imported on demand and the
16+
resolved attribute is cached on the package so subsequent accesses are free.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from typing import TYPE_CHECKING, Any
22+
23+
if TYPE_CHECKING:
24+
# Re-exports for type checkers / IDE autocomplete. These imports do not
25+
# execute at runtime (they live behind `TYPE_CHECKING`), so they do not
26+
# break the lazy-loading guarantee. The `X as X` aliases mark these as
27+
# explicit re-exports for linters.
28+
from .comment import IssueComment as IssueComment
29+
from .comment import PullRequestReviewComment as PullRequestReviewComment
30+
from .label import Label as Label
31+
from .pull_request import PullRequest as PullRequest
32+
from .pull_request import PullRequestRef as PullRequestRef
33+
from .user import GitHubUser as GitHubUser
34+
from .workflow import Artifact as Artifact
35+
from .workflow import ArtifactsList as ArtifactsList
36+
from .workflow import WorkflowRun as WorkflowRun
37+
38+
# Map of exported attribute name -> submodule (relative to this package) that
39+
# defines it. Submodules are imported on demand by `__getattr__`.
40+
_MODULE_BY_NAME: dict[str, str] = {
41+
'Artifact': 'workflow',
42+
'ArtifactsList': 'workflow',
43+
'GitHubUser': 'user',
44+
'IssueComment': 'comment',
45+
'Label': 'label',
46+
'PullRequest': 'pull_request',
47+
'PullRequestRef': 'pull_request',
48+
'PullRequestReviewComment': 'comment',
49+
'WorkflowRun': 'workflow',
50+
}
51+
52+
53+
def __getattr__(name: str) -> Any:
54+
try:
55+
module_name = _MODULE_BY_NAME[name]
56+
except KeyError:
57+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}') from None
58+
59+
import importlib
60+
61+
module = importlib.import_module(f'.{module_name}', __name__)
62+
value = getattr(module, name)
63+
# Cache so subsequent `from .models import Foo` is a plain dict lookup.
64+
globals()[name] = value
65+
return value
66+
67+
68+
def __dir__() -> list[str]:
69+
return sorted(set(globals()) | _MODULE_BY_NAME.keys())
70+
71+
72+
__all__ = sorted(_MODULE_BY_NAME)

0 commit comments

Comments
 (0)