pyfj code generator
The generator turns the vendored Spec into pyfj's typed surface. This document specifies it. The surface it must produce is architecture.md.
Principles
- Offline and deterministic: reads
spec/openapi.jsononly; no network; same input → byte-identical output. - No hand-editing:
src/pyfj/_generated/**is produced. Humans editcodegen/and regenerate. - Drift-checked: CI runs the generator and fails if the working tree changes.
- Reviewable: every Operation's namespace/method assignment is visible in
codegen/naming-report.md; deviations live incodegen/mapping.toml. - Complete: every Operation is either generated or excluded in
codegen/registry.tomlwith a reason; a test enforces this.
Inputs
spec/openapi.json— Swagger 2.0, Forgejo 16.0.4.codegen/mapping.toml— curated overrides:
# Namespace override for a path prefix
[namespaces."/user"]
name = "user"
# Method name overrides, keyed by operationId
[methods.createOrgRepoDeprecated]
name = "create"
deprecated = true
# Model name overrides, keyed by definition name
[models."SomeAwkwardName"]
class_name = "AwkwardName"
codegen/registry.toml— exclusions from generation:
[[exclusions]]
operation = "getActionsRun"
reason = "Requires the automatic Actions job token; not usable with user credentials."
Pipeline
- Load and validate the Spec (Swagger 2.0; fail loudly on unknown shapes).
- Resolve
$refs — definitions, parameters, and top-level reusable responses. - Build the IR: models, operations (method, path segments, parameters, body, responses, produces, deprecated flag).
- Assign namespaces and method names (algorithm below), apply
mapping.toml, emitnaming-report.md. - Emit
_generated/models/— one module per model. - Emit
_generated/api/— one module per namespace, sync and async methods from the same IR. - Format with ruff.
The generator is importable and testable; uv run python -m codegen runs it end to end.
Naming
Names must be Pythonic, stable, and collision-free. The algorithm below is the starting point; the naming report is reviewed, and overrides land in mapping.toml.
- Namespaces come from static path segments:
repos,orgs,user,users,teams,admin,notifications,packages,settings,activitypub; sub-resources nest (repos.issues,repos.issues.comments). Standalone endpoints fall intomisc. - Singular parameter segments (
{index},{id}) never become namespaces; they become method parameters (index=) on the resource's methods. - Method verb, from HTTP method and path shape:
GETcollection →list;GETitem →getPOSTcollection →create; action-stylePOST→ verb from the operationId (merge,fork,sync,add, ...)PATCH/PUT→update;DELETE→delete- A trailing static segment that is not a sub-resource names the method:
.../pulls/{index}/merge→client.repos.pulls.merge(...) - Collisions are resolved in
mapping.toml; the generator fails on unresolved ones. - Deprecated Operations are generated and flagged (
@deprecatedis deferred until the Python floor reaches 3.13; today they carry a docstring note and the report's deprecated column).
Worked examples:
| Operation | Call site |
|---|---|
repoGet GET /repos/{owner}/{repo} |
client.repos.get(owner, repo) |
issueCreateIssue POST /repos/{owner}/{repo}/issues |
client.repos.issues.create(owner, repo, title=..., body=...) |
issueListComments GET /repos/{owner}/{repo}/issues/{index}/comments |
client.repos.issues.comments.list(owner, repo, index) |
repoMergePullRequest POST /repos/{owner}/{repo}/pulls/{index}/merge |
client.repos.pulls.merge(owner, repo, index) |
Spec quirks the emitter must handle
The shapes the emitter must tolerate:
- Bodies: nearly all use
$ref'd schemas, with only one inline; most schemas have no required fields, and bodies whose models do have required fields are emitted as effectively required. A handful of operations have body fields colliding with path parameters (owner/repo/indexin the issue block/dependency endpoints;usernamein admin org creation) — those fields are exposed flat with a mechanicalbody_prefix (body_owner,body_repo,body_index,body_username). - Multipart: file-upload operations (issue comment attachment, issue attachment, release attachment).
- Non-JSON produces:
text/html(markdown, markup) andtext/plain(job logs, commit/PR diffs, signing keys, gpg key token) →str;application/zip,application/octet-stream,application/gzip(artifacts, archives, raw/media content) →bytes. - Odd success codes:
202,205(→None), and206(job logs →str, the same text body as its 200). - Primitive JSON responses:
bool,list[str],dict[str, str],dict[str, int]decode through the runtime'sdecodeoverloads, like models and text. - 3xx in the Spec:
303(org member GET),304(issue subscription PUT/DELETE) — surfaced asAPIErrorsince redirects are not followed. - Deprecated operations: generated and flagged.
- Parameters: paginated list operations accept
page+limit; some operations take none. - operationIds: unique across the Spec — usable as stable keys for overrides and reports.
- Security: declared globally only, with no per-operation overrides; pyfj authenticates whenever credentials are configured, and endpoints that allow anonymous access work without them.
Model emission rules
- One module per definition (
_generated/models/repository.py→Repository), re-exported frompyfj.modelsandpyfj. model_config = ConfigDict(extra="allow", populate_by_name=True).- Optionality/nullability follows the Spec; string enums →
StrEnum; integer enums →IntEnum;date-time→datetime;date→date. - Spec descriptions become class docstrings; field descriptions become pydantic
Field(description=...)values. - A generated model never imports from
_generated.api.
Endpoint emission rules
- Signatures, bodies, pagination, and error behaviour follow architecture.md exactly.
- Body requiredness follows the model, not the Spec's
requiredflag: a body whose model has required fields is always sent, and those fields are required keyword-only parameters. Bodies whose models have no required fields keep the any-field-provided guard (and send no body when all flat fields areNone). Nocast(...)or# ty: ignoreis ever needed: parameters that feed required model fields are declared required. - Methods call the runtime request hook; they perform no I/O themselves.
- Impersonation is never a generated keyword:
sudois client-scoped runtime state (client.sudo,client.sudo_as()), and the hook reads it when it builds the request. No method signature, docstring, or call site carries it. - Every method docstring carries: one-line summary, description (HTML stripped),
Args(from parameter descriptions),Returns,Raises(the mapped statuses), a deprecation note when applicable, and theoperationIdfor tracing back to the Spec.Returnscarries the Spec's response description only: the return type lives in the annotation on the signature, empty (204/205) responses readNo content.instead of the Spec's schema boilerplate, and the section is omitted when there is nothing to say.Args/Raisesentries are Google-stylename: descriptionpairs, and wrapped continuations are indented deeper than the entry so docstring parsers such as griffe do not read them as new entries. - Generated modules import only from
pyfj._runtime,pyfj._generated.models, and the standard library.
Why builtins.list appears
This is deliberate, not a leftover:
builtins.list[...]in annotations: namespace classes define a method namedlist, and type checkers resolve annotation names in class scope — plainlist[Model]in such a class resolves to the method, not the builtin. The qualified form is the workaround. Renaming thelistmethods (a public API change) or using the deprecatedtyping.Liststyle are the only alternatives; both were rejected.
Regeneration workflow
uv run python -m codegen # regenerate
git diff --stat -- src/pyfj/_generated # review
- Changes under
_generated/are committed together with the generator/config change that caused them (or with the Spec bump). - The CI
codegen-driftjob re-runs the generator and fails on any diff.
Generator tests
- Unit: Spec loading,
$refresolution, IR edge cases; naming (collisions, overrides, deprecated); emission golden files against a synthetic mini-spec covering: required and optional flat bodies,body_collision prefixes, multipart, 204/205, text/plain, bytes, enums, dates, deprecated operations, collisions. - Determinism: running the generator twice yields identical bytes.
- Drift: running on the real Spec reproduces the committed tree.
Acceptance criteria
- [ ] Every operation accounted for: generated or excluded with a reason.
- [ ]
naming-report.mdreviewed;mapping.tomlcomplete and commented. - [ ] Golden tests cover every quirk row above.
- [ ] Drift check green in CI.