Design notes
Pipeline: loader (bundle refs) -> parser (spec to IR) -> render + Jinja templates (IR to source) -> ruff (tidy).
The IR (ir.py) is plain dataclasses; check and diff work on it, never on generated text.
1. Reference resolution without inlining
Inlining $refs breaks on recursive schemas, so the loader never inlines. It only makes sure every reference points into
the root document: an external reference common.yaml#/components/schemas/Money is copied into the root's
components.schemas.Money (renamed Money2, Money3, ... on a clash with different content) and the $ref is rewritten
to #/components/schemas/Money. The copy is itself walked with its file as the base, so nested relative references
and references back into the same external file are imported too. An (file, pointer) cache is filled before recursing,
which is what makes cycles terminate.
a.yaml: Price: {$ref: common.yaml#/components/schemas/Money}
common: Money: {properties: {unit: {$ref: '#/components/schemas/Unit'}}} (internal to common.yaml)
result: Price -> #/components/schemas/Money, Money.unit -> #/components/schemas/Unit, both copied into a.yaml
The parser then registers every #/components/schemas/X once (ref_names) before parsing its body, so a recursive
model refers to its own name and the generated module relies on from __future__ import annotations plus
Model.model_rebuild() at the end of the module.
2. Schema to type expression
resolve_type(node, hint) returns a Python type string and a nullable flag. Named schemas become definitions
(ModelDef, EnumDef, AliasDef); anything structural that is inline either becomes a definition named from hint
(objects with properties, multi-value enums) or stays an expression (list[...], unions, dict[str, V]).
Worked example, Pet with an inline enum and an inline object:
Pet:
properties:
kind: {type: string, enum: [a, b]} # -> enum class PetKind
owner: {type: object, properties: {n: {type: string}}} # -> model PetOwner
tags: {type: array, items: {type: string}} # -> list[str]
allOf is flattened by normalize: each part is dereferenced and normalized recursively, properties dicts are merged
(later parts win) and required lists are unioned; other keys (type, description, ...) are taken from the last part
that defines them. A schema that is just allOf: [one $ref] becomes an alias to that class instead of a copy.
3. Discriminated unions
Pydantic needs every union member to carry a Literal field named by the discriminator. Specs often declare the
property as a plain string and only list the value in mapping. So after all models exist the parser rewrites each
member: value = reverse of mapping, else the member's schema name, and the member's discriminator field becomes
Literal["value"] (added if missing). Given
Pet: {oneOf: [Cat, Dog], discriminator: {propertyName: petType, mapping: {cat: Cat, dog: Dog}}}
the output is Cat.pet_type: Literal["cat"] = Field(alias="petType") and
Pet = Annotated[Cat | Dog, Field(discriminator="pet_type")]. Pydantic accepts either the alias or the field name, and
validating {"petType": "bird"} raises. If some member is not an object schema the union is emitted without a
discriminator and check reports it.
4. Module ordering
Aliases are evaluated at import time, classes are not (annotations are lazy). So models.py is ordered: enums, classes,
aliases (topologically sorted, so Ids = list[PetType2] follows PetType2), then model_rebuild() calls. Class bodies
may mention aliases defined later because they are only resolved on rebuild.
5. Method naming
method_name("getPetById", "pets"): tokens get pet by id; the tag contributes {pets, pet}; remaining
get by id -> get_by_id. If everything would be dropped the full name is kept. Collisions inside one resource fall back
to the full snake-cased operationId, then a numeric suffix, and are reported by check. A method called list would
shadow the builtin inside the class body and break later list[...] annotations under mypy, so resource modules that
contain such a method write builtins.list[...] instead.
6. Requests
Generated methods are thin; all behavior is in _core.py, shipped inside each package so the SDK has no runtime
dependency on clientsmith:
self._transport.request("GET", "/pets/{petId}",
path=[Param("petId", pet_id, "simple", False)],
query=[...], headers=[...], cookies=[...],
body={"json": body}, # or content / form / files+form
security=[["api_key"], ["petstore_auth"]], # alternatives
responses={"200": Pet, "404": Problem, "default": Problem})
Per attempt the transport builds a fresh httpx.Request, applies the selected authenticators, sends, and decides:
401 and an authenticator can refresh -> refresh once, resend (not counted as a retry)
transport error / retryable status -> RetryConfig.should_retry(method, response, attempt)
sleep(Retry-After or min(backoff*2**attempt, max_backoff)), attempt += 1
otherwise -> map the response (parse or raise)
With max_retries=2 and a server answering 429, 429, 200 the client makes three requests and sleeps twice. Because the
whole request is rebuilt per attempt and file objects are read to bytes up front, retries are safe for uploads.
OAuth2 client credentials: the token is fetched with HTTP Basic client authentication on first use, cached with its
expires_in minus a 30 second margin, and dropped on a 401.
7. Errors
responses maps the status ("404", then "4XX", then "default") to a type. For status >= 300 the body is parsed
with that type; success picks the specific subclass (NotFoundError.body is typed Problem), validation failure falls
back to the generic ClientError/ServerError with the raw JSON so a subclass never lies about its body type.
If a status is documented with different schemas on different operations, the subclass declares the union of them.
8. Pagination iterators
Cursor style, as generated:
def list_iter(self, *, limit=None, status=None) -> Iterator[Pet]:
cursor: str | None = None
while True:
page = self.list(limit=limit, status=status, cursor=cursor)
yield from page.items
cursor = page.next_cursor
if not cursor:
return
Offset style stops on an empty page or when offset >= total; next-link style calls request("GET", page.next, ...)
with the absolute URL (httpx lets an absolute URL override base_url).
9. Why ruff post-processing
Templates import generously (datetime, UUID, every model) and emit simple layout. ruff check --select F401,I,RUF022
--fix removes unused imports and sorts imports and __all__, then ruff format normalizes layout, with --isolated
so the user's own ruff config cannot change generated output. This keeps the templates short and the output stable;
the cost is a subprocess per file (about 10 files), well under a second.
10. Testing strategy
- Golden files (
tests/golden/*.py.txt) pin the rendering of each schema construct;UPDATE_GOLDEN=1rewrites them. - Two specs are generated for real and must pass
ruff check,ruff format --checkandmypy --strict: the petstore andtests/specs/tricky.yaml(keywords, odd names, 3.1 types, external refs, collisions, many auth schemes). - The committed
examples/generatedmust equal a fresh generation, so a template change cannot be committed without regenerating the example. - End-to-end tests run the generated petstore client against
examples/mock_server.pyon an ephemeral port.