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 |
required |
raw_spec
|
dict[str, Any] | str | Path
|
original OpenAPI document (path, URL string, or already-loaded dict).
Forwarded to |
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
|
required |
project_name
|
str | None
|
PEP 503 distribution name. Defaults to the last segment of
|
None
|
project_version
|
str
|
initial version string emitted into |
'0.1.0'
|
python_version
|
str
|
pinned Python version for the generated project. |
'3.13'
|
license
|
str
|
SPDX identifier; drives the |
'Proprietary'
|
author
|
str | None
|
copyright holder for the generated |
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 |
None
|
shape
|
Shape
|
response-shape policy for the generated client.
|
'auto'
|
Returns:
| Type | Description |
|---|---|
dict[str, GeneratedFile]
|
A |
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_diskalways overwrites. Everything underbase/pluspy.typedfalls in this bucket.one_shot=True: emitted exactly once on first generation.write_to_diskskips 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.jsonfrom disk; deletes any base file inprevious.base_filesthat 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=Truecomputes the report without touching disk;WriteReport.would_changeisTrueif anything would change. Powersokapipy generate --check.
GeneratedFile
dataclass
¶
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 |
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 |
required |
model_templates_dir
|
Path | None
|
directory of dmcg Jinja templates that override the
built-in ones; passed straight through to |
required |
python_version
|
str
|
target Python version, e.g. |
required |
public_names
¶
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_filesis the set of files the regenerated tree owns. On the next generation,previous.base_files - current.base_filesis the set of stale files (their parser-tree node no longer exists in the spec) thatwrite_to_diskdeletes from disk. User-layer files are never tracked here and never pruned. - Drift detection.
edgesis the set of parent → child wirings the current parser tree implies. On the next generation,current.edges - previous.edgesare 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).
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
¶
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
¶
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
¶
Bases: GenerationError
Raised when Jinja2 fails to render a template (StrictUndefined, syntax, etc.).
FormatError
¶
Bases: GenerationError
Raised when ruff format rejects a rendered Python file.
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
¶
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
¶
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
¶
Equality-only conjunctive filter: ?status=open&customer_id=42.
KeyOpValueFilter
dataclass
¶
Django-style operator-suffix filter: ?created_at__gte=…&status__in=….
SearchFilterStrategy
dataclass
¶
Single free-text param filter: ?q=…. Accepts only one Search leaf.
JsonFilterStrategy
dataclass
¶
Encode the full Filter tree as a JSON expression in one query parameter.
CommaSignedSort
dataclass
¶
?sort=-created_at,id — comma-joined, leading - for desc.
KeyDirectionSort
dataclass
¶
?order_by=created_at&order=desc — separate field/direction params.
Only single-term sort is meaningful for this encoding; multi-term raises.
JsonApiSort
dataclass
¶
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
¶
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
¶
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
¶
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
¶
OrFilter
¶
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
¶
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
¶
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
¶
ClientError
¶
ServerError
¶
ResponseValidationError
¶
ConfigurationError
¶
UnsupportedFilterError
¶
UnsupportedSortError
¶
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.