- name
- python-generator
- description
- Design of the ejected Redocly `python` client generator. Read it, and update it, before changing generators/python/.
# The `python` generator — its skill
This file is the DESIGN of your ejected `python` generator (`generators/python/`):
**to change the generator, edit this skill first, then make the code match it** — a diff
to `generators/python/` that has no covering sentence here is incomplete.
## What it emits
One self-contained `<stem>.py`: typed dataclass models, a sync `Client` and an async
`AsyncClient`, and the embedded runtime. Python ≥ 3.9; the only dependency is
[httpx](https://www.python-httpx.org/) (`pip install httpx`).
## Design decisions that must hold
- **The file name is an importable module name.** The `--output` stem follows the TypeScript
convention (`openapi.client.ts`), and `openapi.client.py` cannot be imported by name — nor
can hyphens or a leading digit. The stem is converted with
`identifierFor(stem, snake)`, so `rebilly-core.client.ts` emits
`rebilly_core_client.py` and `import rebilly_core_client` just works.
- **Models are dataclasses by default**, required fields first (a dataclass constraint),
optionals `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`;
decode/encode is reflective (`_decode.py`, `get_type_hints`) — no per-model codecs.
- **The file passes `mypy --strict`.** It lands in the user's own package, so their
strictest checker reads it: every function is annotated, the operation table is typed,
and a decoded body is `cast` to the declared return type (`decode` returns `Any`).
- **A `writeOnly` property is optional** even when required: requests and responses share
one model, and a response never carries it. It is `Optional[T] = None`, so a response
decodes without it and a request still sends it.
- **`models: pydantic` emits `BaseModel` classes instead**, for the FastAPI-shaped half of
the ecosystem that expects them. A wire name becomes `Field(alias=…)` with
`populate_by_name=True`, so `_field_map` is not emitted in this mode — the alias is the
mapping. Everything else is unchanged: the same class names, the same field names, the
same `Optional[T] = None`, the same enums and union aliases, the same client and runtime.
Switching modes must not change a call site.
- **A discriminated union carries its discriminator into the pydantic annotation.** The
decoder hands a whole object tree to `model_validate`, so a union nested in a model is
resolved by pydantic and never reaches the `DISCRIMINATORS` table that dataclass mode
walks. Pydantic resolves it correctly from `Annotated[Union[...], Field(discriminator=…)]`,
which it accepts only when every member types that property as a `Literal` — and the
mapping already pins one value per member, so the members get `Literal["cat"]`. Such a
union registers no table entry: pydantic owns it at every depth, and the `Literal` makes
the decoder's member probe exact. A union whose members never declare the property keeps
the plain `Union` and the table entry, and pydantic then matches nested members its own
way — the description is what has to change there.
- **One runtime serves both model modes.** `_decode.py` dispatches on the target: a class
with `model_validate` is validated by pydantic, a dataclass is hydrated reflectively, and
`encode` mirrors that with `model_dump(by_alias=True, exclude_none=True, mode="json")`.
A second runtime variant per mode would double the surface that has to stay in step, and
pydantic's `ValidationError` already subclasses `ValueError`, so union member probing
needs no new except clause.
- **`models: pydantic` adds a dependency, and the header says so.** The default mode keeps
httpx as the only requirement; the pydantic header asks for both. A mode that quietly
needed a package the file never named would fail at import with nothing to act on.
- **Every parameter is its own argument, so their names share one namespace** with the
arguments the method declares itself (`body`, `headers`, `timeout`, `retry`, `idempotency_key`). Build them with
`uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two
locations (`id` in the path AND in the query), and a `def` that declared one name twice is a `SyntaxError`. The
wire name is untouched, so the request is unchanged.
- **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`;
reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`.
- **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]`
aliases, emitted after the classes and the other aliases they name, because a
module-level assignment evaluates at import. A DISCRIMINATED union registers its dispatch table in the runtime's
`DISCRIMINATORS` registry (`DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})`),
and `decode()` routes through it — `isinstance` narrowing works on decoded members.
Undiscriminated unions decode by trying each member in order (the first that
hydrates wins — see `_decode.py`). **allOf** is flattened via `flattenAllOf`.
- **Auth keys match the other languages.** `auth={"apiKey": {...}}` is the documented key —
the same spelling TypeScript and PHP use, and the same as the scheme kind — with
`api_key` accepted as an alias so a snake_case config keeps working.
- **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result`
dataclass — the only generator with both modes outside TypeScript.
- **Dates:** `dateType: Date` annotates `format: date-time` as `datetime` and `date` as
`date`; `_decode.py` parses ISO strings into them and `encode()` writes `isoformat()`
back. The default (`string`) keeps the wire shape.
- **Response headers:** an operation that DECLARES success-response headers gains a
`<op>_with_headers()` variant (sync and async) returning `Envelope[T]` — `data`,
`headers` (coerced to int/bool/str with snake_case keys; absent/unparsable values
omitted), and the raw `response`. Operations without declared headers get no
variant, and the base method stays body-only.
- **Servers:** when the description declares servers, a `Servers` class is emitted with
one static method per server; server VARIABLES become keyword arguments defaulting to
the spec's defaults (`Servers.production(organization_id="org_x")`), so templated base
URLs need no manual string building. The client's baked default stays `servers[0]`
with variable defaults substituted.
- **Stream bodies pass through.** For an untyped `multipart/form-data` operation
(`isUntypedMultipartBody`) or a binary operation (`isBinaryContentType`), the body
argument is `Any`, and a body that is `bytes`/`bytearray`, a `str`, a
file-like object (a callable `.read`), or an iterator/async iterator of bytes goes to httpx
`content=`, never through `json=` or `to_multipart`; a `dict` body of an untyped
multipart operation keeps the `to_multipart` path. The runtime sets the operation's
declared content type only when the caller set none (matched case-insensitively), and
never for a multipart body, whose boundary is the caller's. A body that cannot be
replayed (a file-like object or iterator, as opposed to `bytes` or `str`) is sent in
exactly one attempt, whatever the retry policy says. The per-attempt `timeout` still
applies. Both the sync and the async `send` behave the same. Typed multipart bodies
are unaffected: they go straight to `to_multipart` and never through stream detection,
so a model field named `read` cannot turn one into a raw upload.
- **Parity surface:** auth (bearer/basic/apiKey), retries with `Retry-After` + jittered
backoff, timeouts, idempotency keys, middleware, pagination (`<op>_pages()` /
`<op>_items()` + `aiter` mirrors), SSE (`iter_sse`/`aiter_sse`), multipart.
- The runtime is hand-written in `runtime/*.py` in this folder and embedded as strings at prepare
time — generator code never builds runtime logic from templates.
Under `--runtime module` the same sources are written as sibling `_*.py` files instead
(package-relative imports become sibling imports; the client star-imports each module).
- **`on_settled` sees every sent call once.** Both `send()` and `send_async()` call each
middleware's `on_settled(context, call)` after the last attempt, where `call` holds the final
`response` or the `error`, `attempts`, and `duration_ms` (from the first attempt to the end
of the `on_response` hooks). A timeout is `ApiTimeoutError`; a cancelled async call is
`asyncio.CancelledError`. A call that fails in `on_request` does not reach it. Every hook
runs; on a successful call the first hook exception fails the call, and on a failed call
the original error wins. SSE streams open outside `send()`, so they reach no middleware.
- **Telemetry is opt-in at generation time.** With `client.telemetry: on` the generator also
writes `<module>_telemetry.py` (from `telemetry/telemetry.py` in this folder, embedded at
prepare time) beside the client, in both runtime modes, with the generator version baked
in. The client file does not change. The module imports nothing from the client; the
application passes `telemetry(sdk_name=..., sdk_version=..., client=...)` in the client's
`middleware`, and the open window is sent at interpreter exit.
A timeout of the caller, such as `asyncio.wait_for`, cancels the task like any cancel, so
it counts as `aborted`; only the client's own `timeout` counts as `timeout`.
- Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) —
the dogfooding guard fails otherwise.
- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes
`<stem>.python.md`: the security schemes, then one section per operation with its parameters,
body, response type, and behavior notes. The call snippets come from this generator's own
`sample` hook, so the page can only show the syntax of the SDK beside it, and the layout
comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy
through `@redocly/client-generator`. Pagination on the page is decided by
`paginationRuleFor`, the same helper this generator resolves pagination with.
## The modify loop
1. Edit this skill: state the new behavior or decision.
2. Make `generators/python/` match it.
3. Run `redocly generate-client` and inspect the `git diff` of the generated output —
generated files are never hand-edited.
Newer built-in versions merge in with `redocly eject-generator python --update`.
Ver no GitHub