Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/api-compat.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Fails a PR that breaks the public API against the latest v* release
# reachable from HEAD (griffe's own CLI). To break it on purpose, see "Public
# API compatibility" in AGENTS.md: a `!` in the PR title plus the
# `api-break` label.
# reachable from HEAD (griffe's own CLI, plus scripts/griffe_reexports.py so a
# class moved behind a re-export is still compared member by member). To break
# it on purpose, see "Public API compatibility" in AGENTS.md: a `!` in the PR
# title plus the `api-break` label.
#
# Its own workflow, not a job in ci.yml, because the override lives in PR
# metadata: it has to run again when the title is edited or a label is added
Expand Down Expand Up @@ -63,7 +64,8 @@ jobs:
run: |
against=$(git describe --tags --abbrev=0 --match 'v*' HEAD)
if uv run griffe check introspection_sdk --search . \
--against "$against" --format github; then
--against "$against" -e scripts/griffe_reexports.py \
--format github; then
exit 0
fi
pr=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ jobs:
- name: Run linting
run: uv run ruff check .

# introspection_sdk/_sync/ is generated from introspection_sdk/_async/
# by scripts/unasync.py; see "Sync code generation" in AGENTS.md.
- name: Check the generated sync code is up to date
run: uv run python scripts/unasync.py --check

- name: Run type checking
run: uv run ty check

Expand Down
9 changes: 7 additions & 2 deletions .jscpd.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
"path": ["introspection_sdk"],
"pattern": "**/*.py",
"format": ["python"],
"ignore": ["**/.venv/**", "**/__pycache__/**", "**/*_pb2.py"],
"ignore": [
"**/.venv/**",
"**/__pycache__/**",
"**/*_pb2.py",
"introspection_sdk/_sync/**"
],
"gitignore": true,
"minTokens": 50,
"minLines": 5,
"mode": "weak",
"kind": ["exact", "renamed"],
"ignoreIdentifiers": true,
"threshold": 29.3,
"threshold": 16.7,
"reporters": ["console"]
}
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ repos:
pass_filenames: false
require_serial: true

- id: unasync
name: generated sync code is up to date
entry: uv run python scripts/unasync.py --check
language: system
files: ^(introspection_sdk/_(a)?sync/|scripts/unasync\.py$)
pass_filenames: false

# Enforces the coverage ratchet defined in [tool.coverage.report].
# Runs on the manual stage by default (`prek run --hook-stage manual coverage`)
# so it doesn't block fast iteration; CI runs the same check on every PR.
Expand Down
64 changes: 62 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,54 @@ uv run ty check
These are enforced by `.pre-commit-config.yaml`. Don't disable hooks with
`--no-verify` — fix the underlying issue.

## Sync code generation

Every class or function with an `Async*` twin is written once, as async
code under `introspection_sdk/_async/`. `scripts/unasync.py` generates the
sync twin into `introspection_sdk/_sync/` with
[unasync](https://github.com/python-trio/unasync), pinned in the `dev`
extra, the way httpx/httpcore and urllib3 do. Each generated file opens with
a "Generated by scripts/unasync.py ... Do not edit" header.

```shell
uv run python scripts/unasync.py # regenerate _sync/ after editing _async/
uv run python scripts/unasync.py --check # what CI runs: fails on any diff
```

- Edit `_async/`, never `_sync/`, then regenerate and commit both. The
`Lint & Type Check` job and the `unasync` pre-commit hook fail when
`_sync/` is out of date.
- The public modules (`runner_resources/tasks.py`, `client.py`, ...)
re-export both twins, so no import path changes. Code the twins share
lives in a sibling `_<module>_common.py` (`runner_resources/_tasks_common.py`,
`_http_common.py`, ...): a twin cannot import its own public module,
which imports the twin.
- unasync drops `async` and `await` and renames name tokens: `Async*` and
`_Async*` lose the prefix, `async_*` and `*_async` lose the affix, and
`TOKENS` maps the rest (`AsyncClient` to `Client`, `aclose` to `close`,
...). What is not one token, such as `asyncio.sleep(`, `Awaitable[...]`
or a cross-reference in a docstring, goes through the regular expressions
in `SUBSTITUTIONS`. ruff then sorts the imports, drops the ones only the
async code used, turns the `for x in y: yield x` an `async for` leaves
into `yield from`, and formats the file.
- A block only the async twin has, such as `__aenter__` on the async
client, is fenced by `# unasync: async-only` and `# unasync: end` and left
out of the sync file. The same fence keeps an async docstring that says
something the sync one does not: fence the async docstring and write the
sync twin's docstring right after `# unasync: end`.
- Code that genuinely differs stays hand-written; don't bend it to fit the
generator. Today that is `Pager` / `AsyncPager` (`__getattr__` against
`__await__`), `stream_resumable` / `stream_resumable_async` (a test patches
`introspection_sdk.resumable._retry_delay`, which a generated module would
not see), `EmailCodeAuth` / `AsyncEmailCodeAuth` (the sync twin takes
thread locks), `Annotations` / `AsyncAnnotations` (`list` resolves email
filters before paging in sync and on the first fetch in async) and
`RuntimeHandle` / `AsyncRuntimeHandle` (`runtime_id` is a property against
a coroutine).
- A new twin goes in `_async/`. Regenerate, then re-export both names from
the public module. If the sync name is not the async name minus `Async`,
add the pair to `TOKENS`.

## Dependency check

```shell
Expand Down Expand Up @@ -165,7 +213,9 @@ copies, so `exact` stays listed). The threshold is a ratchet: don't raise it
to land a change, factor the clone out instead. When a refactor lowers
duplication, read the `Total` row's duplicated-lines percentage and lower
`threshold` to just above it in the same PR. A generated file goes in the
config's `ignore` list.
config's `ignore` list: `introspection_sdk/_sync/` is there because it
repeats `introspection_sdk/_async/` by construction (see "Sync code
generation"), and counting it would hide every new copy.

## When in doubt

Expand Down Expand Up @@ -215,7 +265,8 @@ latest `v*` release tag (resolved with `git describe --tags --abbrev=0
--match 'v*' HEAD`, never hard-coded). Run the same check locally:

```shell
uv run griffe check introspection_sdk --search . --against v0.29.0
uv run griffe check introspection_sdk --search . --against v0.29.0 \
-e scripts/griffe_reexports.py
```

Pass the tag CI uses, `git describe --tags --abbrev=0 --match 'v*' HEAD`.
Expand All @@ -229,6 +280,15 @@ name without a leading underscore. Imported names are public only through
are internal and free to change. To keep a new helper out of the contract,
give it a leading underscore or leave it out of `__all__`.

`-e scripts/griffe_reexports.py` loads a griffe extension that replaces
each public alias whose target lives in `introspection_sdk` with the target
object, on the old release and the new code alike. Without it griffe
compares a public class against a re-export of it only shallowly: once it
has seen the old object's path it skips the members of the alias's target,
so a class moved behind a re-export (into `_async/` or `_sync/`, say) would
pass with a parameter removed. griffe has no option for this; keep the
extension in the command until it does.

Reported: a public object removed or renamed, a parameter removed, moved,
made required or changed kind, a parameter default changed, a return or
attribute type changed, a base class removed, and a changed attribute
Expand Down
4 changes: 4 additions & 0 deletions introspection_sdk/_async/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Async twins re-exported by the public modules.

See "Sync code generation" in AGENTS.md.
"""
226 changes: 226 additions & 0 deletions introspection_sdk/_async/_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""Async twins re-exported by :mod:`introspection_sdk._http`."""

from __future__ import annotations

import asyncio
from collections.abc import AsyncIterator, Mapping
from typing import TYPE_CHECKING, Any

import httpx2 as httpx

from introspection_sdk._backoff import _is_retryable_status, _retry_delay
from introspection_sdk._errors import (
NetworkError,
RunnerExpiredError,
_parse_retry_after,
error_from_response,
)

if TYPE_CHECKING:
from introspection_sdk.auth import (
AsyncCredentialProvider,
)
from introspection_sdk._http_common import (
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BASE,
RawResponse,
_clean_params,
)


class _AsyncHttpClient:
# unasync: async-only (the sync twin's docstring follows)
"""Thin wrapper around ``httpx.AsyncClient`` for REST calls.

The async twin of :class:`_HttpClient`. Used both for the CP-facing
client (on ``AsyncIntrospectionClient``) and for the DP-facing
client (on an ``AsyncRunner``). The two only differ in their base
URL and bearer token.
"""

# unasync: end
"""Thin wrapper around ``httpx.Client`` for REST calls.

Used both for the CP-facing client (on ``IntrospectionClient``)
and for the DP-facing client (on a ``Runner``). The two only
differ in their base URL and bearer token.
"""

def __init__(
self,
*,
api_url: str,
token: str,
cp_session: str | None = None,
additional_headers: Mapping[str, str] | None = None,
timeout: float = 30.0,
transport: httpx.AsyncBaseTransport | None = None,
max_retries: int = DEFAULT_MAX_RETRIES,
retry_base: float = DEFAULT_RETRY_BASE,
credentials: AsyncCredentialProvider | None = None,
) -> None:
self._closed = False
self._credentials = credentials
self._client = httpx.AsyncClient(
base_url=api_url.rstrip("/"),
timeout=timeout,
transport=transport,
)
self._auth_headers: dict[str, str] = (
{"Cookie": f"intro_cp_session={cp_session}"}
if cp_session
else {}
if credentials is not None
else {"Authorization": f"Bearer {token}"}
)
if additional_headers:
self._auth_headers.update(additional_headers)
self._max_retries = max_retries
self._retry_base = retry_base

async def aclose(self) -> None:
self._closed = True
await self._client.aclose()

def _check_open(self) -> None:
"""Raise a typed error once this client has been closed.

``Runner.refresh()`` closes the client it replaces, and
``Runner.close()`` closes the live one, but a namespace handle the
caller already holds keeps a reference to it. Without this the next
call surfaced httpx's bare ``RuntimeError: Cannot send a request, as
the client has been closed``, bypassing the typed-error guarantee
``_check_open`` on the Runner exists to give.
"""
if self._closed:
raise RunnerExpiredError(
"This client has been closed (the Runner was closed or "
"refreshed); take a fresh namespace handle from the Runner.",
status_code=0,
)

async def _request_headers(
self, extra: Mapping[str, str] | None
) -> dict[str, str]:
req_headers = dict(self._auth_headers)
if self._credentials is not None and "Cookie" not in req_headers:
authorization = await self._credentials.authorization()
if authorization:
req_headers["Authorization"] = authorization
if extra:
req_headers.update(extra)
return req_headers

async def request(
self,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
json: Any = None,
files: Mapping[str, Any] | None = None,
data: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
expect: str = "json",
) -> Any:
self._check_open()
# Auto-retry retryable statuses, honouring ``Retry-After`` as a
# backoff floor when present: ``429`` on any method (the request was
# rejected and never processed, so retrying is side-effect-safe for
# writes too) and ``502``/``503``/``504`` on idempotent ``GET`` calls
# only. Multipart uploads aren't retried.
retries = 0 if files is not None else self._max_retries
idempotent = method.upper() == "GET"
attempt = 0
reauthorized = files is not None
while True:
req_headers = await self._request_headers(headers)
try:
res = await self._client.request(
method,
path,
params=_clean_params(params),
json=json,
files=files,
data=data,
headers=req_headers,
)
except httpx.HTTPError as exc:
raise NetworkError(str(exc)) from exc
if (
res.status_code == 401
and self._credentials is not None
and not reauthorized
):
reauthorized = True
if await self._credentials.refresh_after_unauthorized(
req_headers.get("Authorization")
):
continue
if (
_is_retryable_status(res.status_code, idempotent)
and attempt < retries
):
delay = _retry_delay(
attempt,
_parse_retry_after(res.headers.get("retry-after")),
self._retry_base,
)
attempt += 1
await asyncio.sleep(delay)
continue
if res.status_code >= 400:
raise error_from_response(res)
if expect == "empty":
return None
if expect == "bytes":
return res.content
if expect == "raw":
return RawResponse(content=res.content, headers=res.headers)
return res.json()

async def stream_bytes(
self,
path: str,
*,
params: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
) -> AsyncIterator[bytes]:
req_headers = await self._request_headers(headers)
try:
async with self._client.stream(
"GET",
path,
params=_clean_params(params),
headers=req_headers,
) as res:
if res.status_code >= 400:
await res.aread()
raise error_from_response(res)
async for chunk in res.aiter_bytes():
yield chunk
except httpx.HTTPError as exc:
raise NetworkError(str(exc)) from exc

async def stream_sse_lines(
self,
path: str,
*,
params: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
) -> AsyncIterator[str]:
self._check_open()
req_headers = await self._request_headers(
{"Accept": "text/event-stream", **(headers or {})}
)
try:
async with self._client.stream(
"GET", path, params=_clean_params(params), headers=req_headers
) as res:
if res.status_code >= 400:
await res.aread()
raise error_from_response(res)
async for line in res.aiter_lines():
yield line
except httpx.HTTPError as exc:
raise NetworkError(str(exc)) from exc
Loading
Loading