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