Skip to content

Generator API reference

The generator package exposes one supported entry point — generate(...) — that returns a virtual filesystem (dict[str, GeneratedFile]). The CLI flushes that dict to disk via write_to_disk; tests inspect it directly.

Public entry point

generate

generate(
    api: APIModel,
    raw_spec: dict[str, Any] | str | Path,
    *,
    output_dir: Path,
    package: str,
    client_class: str,
    project_name: str | None = None,
    project_version: str = "0.1.0",
    python_version: str = "3.13",
    license: str = "Proprietary",
    author: str | None = None,
    templates_dir: Path | None = None,
    model_templates_dir: Path | None = None,
    shape: Shape = "auto"
) -> dict[str, GeneratedFile]

Build the virtual FS for the generated client project.

Parameters:

Name Type Description Default
api APIModel

parsed APIModel produced by okapipy.parser.api.parse.

required
raw_spec dict[str, Any] | str | Path

original OpenAPI document (path, URL string, or already-loaded dict). Forwarded to datamodel-code-generator for models.py emission.

required
output_dir Path

target directory the CLI will flush the virtual FS into.

required
package str

dotted package path for the generated client (e.g. "acme.commerce").

required
client_class str

PascalCase class name for the sync client; async sibling is Async<client_class>. Base classes carry an additional Base suffix.

required
project_name str | None

PEP 503 distribution name. Defaults to the last segment of package.

None
project_version str

initial version string emitted into pyproject.toml.

'0.1.0'
python_version str

pinned Python version for the generated project.

'3.13'
license str

SPDX identifier; drives the LICENSE placeholder.

'Proprietary'
author str | None

copyright holder for the generated LICENSE and PEP 621 authors entry in pyproject.toml. When omitted, the LICENSE falls back to the project name and pyproject.toml omits the authors block.

None
templates_dir Path | None

optional directory of user templates. Resolved before the packaged defaults (ChoiceLoader).

None
model_templates_dir Path | None

optional directory of datamodel-code-generator templates. Forwarded as dmcg's custom_template_dir.

None
shape Shape

response-shape policy for the generated client.

  • "auto" (default) — emit a dual-shape client. The constructor accepts a shape: "models" | "dicts" keyword and with_shape(...) returns a sibling switching shape at runtime. Bodies and returns are typed as Foo | dict[str, Any] / Foo | dict[str, Any] | None.
  • "models" — lock the client to typed Pydantic models. The shape= constructor option and with_shape(...) are dropped. Bodies are typed as Foo; returns as Foo | None.
  • "dicts" — lock the client to raw dicts. base/models.py is skipped, model imports are dropped, and bodies / returns are typed as dict[str, Any] / dict[str, Any] | None.
'auto'

Returns:

Type Description
dict[str, GeneratedFile]

A dict[str, GeneratedFile] mapping POSIX-style relative paths to

dict[str, GeneratedFile]

file content + lifecycle policy.

The virtual filesystem

vfs

Virtual filesystem with file-lifecycle metadata, manifest pruning, and drift detection.

The generator builds a dict[str, GeneratedFile] keyed on POSIX-style paths relative to the output directory. Each GeneratedFile carries the file's content plus its lifecycle policy:

  • one_shot=False (default): regenerated every run. write_to_disk always overwrites. Everything under base/ plus py.typed falls in this bucket.
  • one_shot=True: emitted exactly once on first generation. write_to_disk skips paths that already exist. User-layer stubs and the project skeleton use this lifecycle.

write_to_disk returns a WriteReport:

  • Pruning. Reads the previous base/_manifest.json from disk; deletes any base file in previous.base_files that isn't in the current VFS. User-layer files are never pruned.
  • Drift detection. Compares previous and current manifest edges; emits one warning per new/removed wiring on a one-shot user-layer parent that needs a manual edit.
  • Dry-run. dry_run=True computes the report without touching disk; WriteReport.would_change is True if anything would change. Powers okapipy generate --check.

GeneratedFile dataclass

GeneratedFile(content: str, one_shot: bool = False)

A single emitted file plus its lifecycle policy.

WriteReport dataclass

WriteReport(
    written: list[str] = list(),
    skipped: list[str] = list(),
    pruned: list[str] = list(),
    warnings: list[str] = list(),
    would_change: bool = False,
)

Outcome of a write_to_disk call.

would_change is True if writing would alter the on-disk state in any way (a base file's content differs, a one-shot file is missing, or a stale file would be pruned). The manifest itself is excluded from this check because its generated_at timestamp differs on every run.

write_to_disk

write_to_disk(
    vfs: dict[str, GeneratedFile], output_dir: Path, *, dry_run: bool = False
) -> WriteReport

Flush vfs to output_dir. Prune stale base files. Emit drift warnings.

Parameters:

Name Type Description Default
vfs dict[str, GeneratedFile]

the generator's virtual filesystem.

required
output_dir Path

target directory the project is being written into.

required
dry_run bool

when True, no files are written, deleted, or modified — the returned report reflects what would happen.

False

Models

models

datamodel-code-generator integration.

Loads the OpenAPI spec, runs flatten_inline_schemas to hoist anonymous inline schemas into components.schemas (so dmcg doesn't emit By / By1 / By2 duplicates for structurally identical shapes), writes the preprocessed document to a temp directory, invokes dmcg with output_model_type=PydanticV2BaseModel, and reads the resulting models.py back into the virtual FS. The user-supplied model_templates_dir is forwarded to dmcg as custom_template_dir; when omitted, the bundled relaxed templates under templates/model/ are used: every field is forced optional (| None), extra="allow" and populate_by_name=True, and the dmcg-generated Field(...) call is preserved verbatim so spec-level constraints (max_length, pattern, …) and metadata (title, description, examples) survive. When a field has an alias from snake_case_field=True, the bare alias= kwarg is rewritten to validation_alias=AliasChoices(snake, original), serialization_alias=original so payloads can be sent or received under either name.

emit_models

emit_models(
    raw_spec: dict[str, Any] | str | Path,
    model_templates_dir: Path | None,
    python_version: str,
) -> str

Render models.py content from raw_spec.

Returns the rendered Python source (banner-prefixed). The caller places it into the virtual FS under <package>/models.py.

Parameters:

Name Type Description Default
raw_spec dict[str, Any] | str | Path

original OpenAPI document — a path to a file/URL, or an already loaded dict. Forwarded to dmcg's input_ argument.

required
model_templates_dir Path | None

directory of dmcg Jinja templates that override the built-in ones; passed straight through to custom_template_dir. When None, the bundled relaxed templates under templates/model/ are used.

required
python_version str

target Python version, e.g. "3.13". Maps to dmcg's target_python_version enum.

required

public_names

public_names(source: str) -> set[str]

Return the set of top-level identifiers defined in source.

Used by the walker to filter from ..models import ... lines so they only reference symbols dmcg actually emitted. dmcg sometimes drops or aliases schemas (primitive aliases, empty objects, schemas resolving to Any), so a parser-recovered ref name like Data may not exist in the output. Includes class names, simple Name = ... assignments, and Name: ... = ... annotated assignments — covers both class Foo(BaseModel) and dmcg's Foo = list[Bar] / Foo: TypeAlias = ... outputs.

Manifest and edges

manifest

Cross-run manifest: tracks regenerated files and parser-tree edges.

The manifest is written to src/{package_path}/base/_manifest.json on every generation. It serves two operational purposes:

  • Pruning. base_files is the set of files the regenerated tree owns. On the next generation, previous.base_files - current.base_files is the set of stale files (their parser-tree node no longer exists in the spec) that write_to_disk deletes from disk. User-layer files are never tracked here and never pruned.
  • Drift detection. edges is the set of parent → child wirings the current parser tree implies. On the next generation, current.edges - previous.edges are NEW children whose user-layer parent stub (one-shot, never overwritten) doesn't yet wire them; the difference is converted to warnings telling the user the exact one-line edit to make.

Edges are stored abstractly — one entry per sync/async pair, not per emitted Python class. The drift-warning formatter expands one Edge into the two lines (sync + async) the user needs to add.

This module is intentionally narrow: it owns the dataclasses and the JSON-on-disk format, and nothing else. The graph-walking logic that produces edges from a parsed APIModel lives in okapipy.generator.edges, kept apart so that manifest.py does not depend on emit/stubs.py (which itself depends on vfs.py, which depends back on manifest.py).

MANIFEST_FILENAME module-attribute

MANIFEST_FILENAME = '_manifest.json'

Filename inside <package>/base/ where the manifest is stored.

serialize

serialize(manifest: Manifest) -> str

Render the manifest as a deterministic JSON string.

edges

Manifest edge computation: walks the parser tree and emits one Edge per wiring.

Lives in its own module so manifest.py can stay free of dependencies on emit/stubs.py and emit/walk.py. Without that split the import graph cycles (vfs → manifest → stubs → vfs), forcing function-local imports inside manifest.py. By concentrating the stubs/walk dependency here — at a higher layer than vfs.py — the cycle disappears and every import can stay at module scope.

compute_edges mirrors the auto-wiring logic in emit/stubs.py: each entry in the returned list represents the same __<factory>__ = ChildClass line a stub would emit. Drift detection on a later run computes current - previous over the manifest's edges field to surface child wirings the user hasn't yet hand-edited into their one-shot stubs.

compute_manifest

compute_manifest(api: APIModel, package: str, base_files: list[str]) -> Manifest

Build the manifest for the current generation.

base_files is the list of POSIX-style VFS keys that fall inside base/. The generated_at timestamp uses ISO-8601 with second precision in UTC so the manifest is reproducible across runs that happen within the same second.

Templating

templating

Jinja2 environment factory and post-format hook.

Single environment per generate() call. Loader is ChoiceLoader([user, packaged]) when the user passes templates_dir, otherwise just the packaged loader. Rendered Python files run through ruff format before they hit the virtual FS — non-Python files pass through unchanged.

make_environment

make_environment(templates_dir: Path | None) -> Environment

Build the Jinja2 environment used for the entire generation run.

templates_dir is searched first via FileSystemLoader; the packaged defaults in okapipy.generator.templates are searched second. StrictUndefined makes missing context variables fail loudly at render time rather than silently emitting "".

Errors

Generator error hierarchy.

GenerationError is the root; raise more specific subclasses for cases the user can act on (a template that didn't render, a template name that wasn't found, a ruff format failure carrying its stderr).

GenerationError

Bases: Exception

Base class for every error raised by the generator.

UnknownTemplateError

Bases: GenerationError

Raised when a referenced template name is not in the loader chain.

TemplateRenderError

TemplateRenderError(template_name: str, message: str)

Bases: GenerationError

Raised when Jinja2 fails to render a template (StrictUndefined, syntax, etc.).

template_name instance-attribute

template_name = template_name

FormatError

FormatError(template_name: str, stderr: str)

Bases: GenerationError

Raised when ruff format rejects a rendered Python file.

template_name instance-attribute

template_name = template_name

stderr instance-attribute

stderr = stderr

Generated runtime

These modules are vendored into every generated client as flat files directly under <package>/base/ (strategies.py, filters.py, sort.py, transport.py, exceptions.py, types.py). They're documented here because you'll import them in your user layer for custom strategies, custom filters, and custom transports — but you don't import them from the okapipy package itself at runtime.

Strategies

strategies

Pagination, filter, and sort strategies — Protocols and built-in implementations.

Strategies are stateless translators. Pagination strategies drive the iterator (initial, next, extract_items) and optionally produce count requests (supports_count, count_request_params, extract_count). Filter and sort strategies walk the user's Filter / Sort tree and produce wire parameters.

Strategies are duck-typed against the Protocols below — users do not have to inherit from them.

Pagination strategies own their default page size: each built-in requires a default_page_size argument that seeds initial(...) when the per-call page_size is None. The client no longer holds a default_page_size — it is a property of the wire dialect, which is exactly what the strategy models. Making it required (rather than int | None) is deliberate: a None would fall back to whatever default the backend chooses, leaving the client unable to tell what page size each request actually carries. Users can still override per-call via .page_size(n).

Filter and sort strategies return FilterEncoding / SortEncoding objects rather than bare params dicts. Each encoding carries both ordinary key/value params and an optional raw_query fragment for dialects whose expression must be emitted verbatim into the query string instead of as key=value pairs (e.g. RQL filters: ?and(eq(f1,v1),eq(f2,v2)), or RQL-style sort: ?ordering(+name,-created_at)). When both filter and sort produce raw fragments they are concatenated into the URL with & separators alongside the URL-encoded ordinary params (e.g. ?and(eq(f,v))&ordering(+name)&limit=100).

PaginationStrategy

Bases: Protocol

Drives the iterator and (optionally) the count and random-access requests.

supports_count / count_request_params / extract_count form the count capability. supports_random_access / page_params form the random-access capability — the ability to fetch the N-th page directly without walking pages 0..N-1. Cursor and link-header paginations are inherently sequential and report supports_random_access=False; offset and page-number paginations support it.

FilterStrategy

Bases: Protocol

Renders a Filter tree into a FilterEncoding.

SortStrategy

Bases: Protocol

Renders a Sort term list into a SortEncoding.

FilterEncoding dataclass

FilterEncoding(params: Mapping[str, Any] = dict(), raw_query: str | None = None)

The wire form of a Filter tree as produced by a FilterStrategy.

params are merged into the request's params= argument (key/value pairs that httpx URL-encodes). raw_query is a query-string fragment appended verbatim — required for expression-language filters like RQL where the operators (and(...), eq(...)) carry parentheses and commas that must not be split into separate parameters.

SortEncoding dataclass

SortEncoding(params: Mapping[str, Any] = dict(), raw_query: str | None = None)

The wire form of a Sort term list as produced by a SortStrategy.

Mirrors FilterEncoding. params are merged into the request's params= argument; raw_query is a query-string fragment appended verbatim — for sort dialects whose expression must not be URL-encoded as key=value pairs (e.g. an RQL-style ordering(+name,-created_at) term).

LimitOffsetPagination dataclass

LimitOffsetPagination(
    default_page_size: int,
    offset_param: str = "offset",
    limit_param: str = "limit",
    total_field: str | None = "total",
    total_header: str | None = None,
    content_range: bool = False,
)

?offset=&limit= pagination with configurable count source.

default_page_size is required — it seeds limit when the caller did not invoke .page_size(...). The per-call value passed to initial always wins when present.

total_field accepts a dotted path for envelopes that nest the total (e.g. "meta.pagination.total" reads body["meta"]["pagination"]["total"]).

PageNumberPagination dataclass

PageNumberPagination(
    default_page_size: int,
    page_param: str = "page",
    page_size_param: str = "page_size",
    start_page: int = 1,
    total_field: str | None = None,
    total_header: str | None = None,
)

?page=&page_size= pagination.

total_field accepts a dotted path for envelopes that nest the total (e.g. "meta.pagination.total" reads body["meta"]["pagination"]["total"]).

CursorPagination dataclass

CursorPagination(
    default_page_size: int,
    cursor_param: str = "cursor",
    next_cursor_field: str = "next_cursor",
    page_size_param: str | None = "page_size",
    content_range: bool = False,
)

Opaque-cursor pagination: ?cursor=... + a next-cursor field in the response.

page_size_param=None opts out of sending any size hint at all; otherwise default_page_size is used when the caller did not invoke .page_size(...).

LinkHeaderPagination dataclass

LinkHeaderPagination(
    default_page_size: int,
    page_size_param: str | None = "limit",
    content_range: bool = True,
    total_header: str | None = "X-Total-Count",
)

RFC 5988 Link: <…>; rel="next" pagination.

page_size_param=None opts out of sending any size hint at all; otherwise default_page_size is used when the caller did not invoke .page_size(...).

KeyValueFilter dataclass

KeyValueFilter()

Equality-only conjunctive filter: ?status=open&customer_id=42.

KeyOpValueFilter dataclass

KeyOpValueFilter()

Django-style operator-suffix filter: ?created_at__gte=…&status__in=….

SearchFilterStrategy dataclass

SearchFilterStrategy(param: str = 'q')

Single free-text param filter: ?q=…. Accepts only one Search leaf.

JsonFilterStrategy dataclass

JsonFilterStrategy(param: str = 'filter')

Encode the full Filter tree as a JSON expression in one query parameter.

CommaSignedSort dataclass

CommaSignedSort(param: str = 'sort')

?sort=-created_at,id — comma-joined, leading - for desc.

KeyDirectionSort dataclass

KeyDirectionSort(field_param: str = 'order_by', direction_param: str = 'order')

?order_by=created_at&order=desc — separate field/direction params.

Only single-term sort is meaningful for this encoding; multi-term raises.

JsonApiSort dataclass

JsonApiSort(param: str = 'sort')

JSON:API sort encoding (?sort=-created_at,id).

Identical wire format to CommaSignedSort with param='sort'; provided as a distinct class so user code can document the API style explicitly.

Filter and sort DSL

filters

Composable filter expressions.

Filter is the base class. Construct it directly with kwargs (Django-Q style) for the common case, or subclass for novel leaves (geo, JSON expressions, RSQL ASTs, etc.). Composition operators &, |, ~ build internal AndFilter / OrFilter / NotFilter nodes; concrete strategies walk the resulting tree at request time and produce wire parameters.

Filter

Filter(**kwargs: Any)

A filter expression. Subclassable for novel node types.

Default construction takes **kwargs and stores them on self.kwargs. Strategies inspect both type(node) and node.kwargs when encoding. Composition produces AndFilter, OrFilter, NotFilter — internal nodes that wrap operands.

iter_leaves

iter_leaves(of_type: type[Filter] | None = None) -> Iterator[Filter]

Yield leaf filters in tree order, optionally restricted to of_type.

Internal nodes (AndFilter / OrFilter / NotFilter) recurse; leaves either yield themselves (if matching) or skip.

without

without(of_type: type[Filter]) -> Filter | None

Return a copy of this tree with all leaves of of_type removed.

Returns None if the result is empty (every leaf was an instance).

AndFilter

AndFilter(left: Filter, right: Filter)

Bases: Filter

Conjunction of two filter expressions.

OrFilter

OrFilter(left: Filter, right: Filter)

Bases: Filter

Disjunction of two filter expressions.

NotFilter

NotFilter(operand: Filter)

Bases: Filter

Negation of a filter expression.

Search

Search(query: str)

Bases: Filter

Free-text query leaf used by search-style APIs (?q=…).

Construct as Search("running shoes"). The configured SearchFilterStrategy pulls self.query and emits the configured query parameter.

sort

Composable sort expressions.

Sort holds a list of (field, direction) terms. Composition via + appends; unary - flips every direction; Sort("-field") is shorthand for descending. Strategies (CommaSignedSort, KeyDirectionSort, JsonApiSort) walk the term list and emit the wire encoding their API expects.

Sort

Sort(field: str | None = None, direction: Direction = 'asc')

One or more sort terms. Composable via + and unary -.

Sort("created_at") ascending. Sort("-created_at") descending (the leading - is a shorthand). -Sort("created_at") flips direction. Adding two Sort instances concatenates their term lists; the empty Sort() is a valid neutral element.

Transport

transport

Retry policy and httpx transport wrapper for generated clients.

RetryTransport (sync) and AsyncRetryTransport (async) sit in front of any transport the user configures and re-issue the request when the response status is in RetryPolicy.retry_on_status or the underlying transport raises one of RetryPolicy.retry_on_exceptions.

Retries apply to GET requests only. Every other method bypasses the retry loop entirely and is forwarded to the wrapped transport untouched. This is deliberate: HTTP methods other than GET are not guaranteed to be idempotent, and silently re-issuing a POST/PATCH/PUT/DELETE after a failure would risk creating duplicate side effects on the server. Customers that need retry semantics for non-GET requests must add them at the application layer where the idempotency contract is known.

Backoff between attempts is exponential: delay = backoff * 2**attempt. A backoff of 0 means no sleep between attempts. total = 0 disables retries entirely.

RetryPolicy dataclass

RetryPolicy(
    total: int = 0,
    backoff: float = 0.0,
    retry_on_status: frozenset[int] = (lambda: frozenset({502, 503, 504}))(),
    retry_on_exceptions: tuple[type[BaseException], ...] = (httpx.TransportError,),
)

Configuration for RetryTransport.

total = 0 disables retries. backoff is the base seconds for exponential delay between attempts (delay = backoff * 2**attempt).

RetryTransport

RetryTransport(
    wrapped: BaseTransport,
    policy: RetryPolicy,
    sleep: Callable[[float], None] | None = None,
)

Bases: BaseTransport

A wrapper transport that retries GET requests per RetryPolicy.

Any non-GET request is forwarded to the wrapped transport untouched. GET requests are retried on matching status codes / exception types up to policy.total extra attempts, with exponential backoff.

AsyncRetryTransport

AsyncRetryTransport(wrapped: AsyncBaseTransport, policy: RetryPolicy)

Bases: AsyncBaseTransport

Async sibling of RetryTransport. Same GET-only rule.

Runtime exceptions

Exception hierarchy for generated clients.

Generated client code maps every non-2xx httpx response to an ApiError subclass so users have one tree to handle. Strategy errors live alongside — they share the same root because users will wrap their entire client call site in try: ... except ApiError.

ApiError

ApiError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: Exception

Base for every error raised by the generated client.

status_code instance-attribute

status_code = status_code

request instance-attribute

request = request

response instance-attribute

response = response

ClientError

ClientError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: ApiError

A 4xx response from the server.

ServerError

ServerError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: ApiError

A 5xx response from the server.

ResponseValidationError

ResponseValidationError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: ApiError

A 2xx response body failed Pydantic validation in models shape.

ConfigurationError

ConfigurationError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: ApiError

A user-supplied strategy or option is invalid.

UnsupportedFilterError

UnsupportedFilterError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: ApiError

The configured FilterStrategy cannot encode this Filter tree.

UnsupportedSortError

UnsupportedSortError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: ApiError

The configured SortStrategy cannot encode this Sort term list.

UnsupportedPaginationError

UnsupportedPaginationError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: ApiError

The configured PaginationStrategy cannot satisfy this operation.

Raised when a collection method asks the strategy for something the underlying wire protocol does not allow — e.g. count() against a cursor strategy with no count source, or get_page(n) against any strategy that walks pages sequentially (cursor, link header). The capability is fundamentally absent from the protocol, not merely unimplemented; callers should switch strategy or iterate sequentially instead.

UnsupportedFilterKeyError

UnsupportedFilterKeyError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: UnsupportedFilterError

A filter key is not declared on the operation.

UnsupportedSortFieldError

UnsupportedSortFieldError(
    message: str,
    *,
    status_code: int | None = None,
    request: Any = None,
    response: Any = None
)

Bases: UnsupportedSortError

A sort field is not declared on the operation.