Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ jobs:
- name: Run linting
run: uv run ruff check .

# introspection_sdk/_sync/ is generated from introspection_sdk/_async/
# by scripts/gen_sync.py; see "Sync code generation" in AGENTS.md.
- name: Check the generated sync code is up to date
run: |
uv run python scripts/gen_sync.py
git diff --exit-code -- introspection_sdk/_sync
untracked=$(git ls-files --others --exclude-standard introspection_sdk/_sync)
if [ -n "$untracked" ]; then
echo "Generated but not committed: $untracked"
exit 1
fi

- 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.3,
"reporters": ["console"]
}
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ repos:
pass_filenames: false
require_serial: true

- id: gen-sync
name: generated sync code is up to date
entry: >-
sh -c 'uv run python scripts/gen_sync.py &&
git diff --exit-code -- introspection_sdk/_sync &&
test -z "$(git ls-files --others --exclude-standard introspection_sdk/_sync)"'
language: system
files: ^(introspection_sdk/_(a)?sync/|scripts/gen_sync\.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
70 changes: 68 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,60 @@ 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/gen_sync.py` deletes
`introspection_sdk/_sync/` and generates the sync twins into it 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/gen_sync.py ... Do not edit" header.

```shell
uv run python scripts/gen_sync.py # regenerate _sync/ after editing _async/
```

- Edit `_async/`, never `_sync/`, then regenerate and commit both. The
`Lint & Type Check` job and the `gen-sync` pre-commit hook run the
script and fail on any diff or untracked file under `_sync/`.
- The public modules (`runner_resources/tasks.py`, `client.py`, ...)
re-export both twins, so no import path changes. Helper functions and
constants stay in the `_async/` module, and unasync copies them into the
sync twin. Only what both twins must share as one object lives in a
sibling `_<module>_common.py`: a class both use (`AuthSession`,
`RawResponse`, `RuntimeHandle`, ...) or process-wide state
(`UNKNOWN_EVENT_SKIPS`), plus the helpers those classes call. It cannot
live in the public module, which imports the twins.
- 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`,
...). Name a new async helper so these rules cover it (`_sleep_async`,
not `_asleep`). What is not one token, such as `Awaitable[...]` or a
cross-reference in a docstring, goes through the regular expressions in
`SUBSTITUTIONS`; when that needs a new rule, prefer a hand-written pair
of helpers (`_sleep` / `_sleep_async` in `_backoff.py`) or prose that
reads right in both twins. ruff then sorts the imports, drops the ones
only the async code used, and formats `_sync/`. The `for x in y: yield x`
an `async for` leaves stays as written: `pyproject.toml` turns off
`UP028` and `C416` for `_sync/`.
- 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 +219,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 +271,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 +286,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 @@
"""Re-exported by the public modules.

See "Sync code generation" in AGENTS.md.
"""
Loading
Loading