Tooling#
These surfaces inspect, generate or operate on an application outside its ordinary request path. For the complete command map and operator examples, start with the command-line tasks guide.
wreath.admin#
A generated administration surface, built entirely out of what already ships.
Django's admin auto-generates list, detail and edit screens from models, and it is a principal reason teams stay on Django. There is no equivalent in async Python, and the gap is filled by per-seat vendors that connect to your database, re-derive your relationships and permissions by hand, and drift on the next migration.
Wreath's answer adds no new primitive. Every input already existed:
| It needs | It uses |
|---|---|
| The model, its columns, its types | the ORM declaration set |
| Which columns are secrets | wreath.crud.sensitive_fields |
| Which columns are retrieval indexes | wreath.crud.retrieval_fields |
| Per-operation authorization | wreath.crud.Access -- the same five operations |
| Per-field authorization | Cedar, asked the way PrecisionLadder asks |
| Paging, sorting, the column allow-list | wreath.pagination |
| HTML that escapes by default | wreath.templates |
| Every write attributed | wreath.audit_log.actor |
| WCAG 2.1 A/AA conformance | wreath.audit, in the test suite |
from wreath.admin import Admin, FieldAccess
from wreath.crud import Access
admin = Admin(
open_session,
authorize={
"read": Access.roles("staff"),
"write": Access.roles("staff").within(300), # step up before writing
},
csrf=verify_admin_form,
)
admin.register(Photo, list_columns=("taken_at", "species"))
admin.register(User, field_access={"email": FieldAccess(read="read_contact")})
app.include_router(admin.router("/admin"))It is not a customer-facing surface. It concentrates read access to every registered model into one authenticated place, which is exactly what makes it useful to an operator and exactly why it must never be the UI a customer is given. There is no theming beyond the basics, no custom pages, and no workflow builder, and those absences are the feature: every generated admin that grew them became the thing nobody could reason about.
Three properties worth knowing before mounting it:
- The admin is a client of the ordinary stack, not a bypass of it. Writes go through the same ORM session, the same validation and the same
_orm_eventsas a hand-written route, so the audit trail and the response cache see them. - Step-up, field policy and audit are preconditions, not enhancements.
Access.within(...)composes onto any rule;FieldAccessdecides per column; a write to an audited model with no actor bound raises rather than recording an anonymous change. - No JavaScript, so
script-src 'none'. The admin ships no script at all, which is what lets its Content-Security-Policy be the strongest one available rather than the permissive policy an inline-script page would need.
Admin#
A generated administration surface over registered models.
admin = Admin(
open_session,
authorize={"read": Access.roles("staff"),
"write": Access.roles("staff").within(300)},
csrf=verify_admin_form,
)
admin.register(Photo, list_columns=("taken_at", "species"))
admin.register(User, field_access={"email": FieldAccess(read="read_contact")})
app.include_router(admin.router("/admin"))Opt-in three times, because an admin concentrates read access to everything into one authenticated surface and an admin mounted by accident is worse than a CRUD route mounted by accident: an explicit authorize rule (there is no default and Access.public() is refused), an explicit register per model, and an explicit include_router.
Args:
open_session—(request) -> Session, one per request, closed by the viewauthorize— onewreath.crud.Accessrule, or a mapping keyed by- operation (
list/retrieve/create/update/delete), group - (
read/write) or"*". The same vocabulary generated CRUD uses. csrf—(request) -> bool, optionally async, proving a mutating request- came from this admin's own form. Required before any write
- operation is generated -- see
router. title— the heading and<title>of the overview page
Raises:
AdminError—authorizeadmits anonymous callers.
Admin(open_session: Callable[[Any], Any], *, authorize: Access | Mapping[str, Access], csrf: Callable[[Any], Any] | None = None, title: str = 'Administration') -> Nonemodels (property)#
models: tuple[ModelAdmin, ...]Every registered model, in registration order.
register#
register(model: type, *, slug: str | None = None, label: str | None = None, list_columns: Iterable[str] | None = None, field_access: Mapping[str, FieldAccess] | None = None, operations: Iterable[str] = ('list', 'retrieve', 'create', 'update', 'delete'), readonly: Iterable[str] = (), exclude: Iterable[str] = (), expose: Iterable[str] = (), page_size: int = 25) -> ModelAdminExpose model in the admin. Nothing is exposed by existing.
Withholding follows wreath.crud exactly rather than being decided again here: a column whose name looks like a secret (crud.sensitive_fields) and a retrieval index such as a Vector or TsVector (crud.retrieval_fields) are absent from every view and from every form unless named in expose. A generated column is readable and never writable. Two layers deciding this separately is how they drift.
Args:
slug— the URL segment; the lower-cased class name by defaultlabel— the human name; the class name by defaultlist_columns— the columns the list view shows, in order. Defaults to- every readable column, which is right for a narrow model and
- worth narrowing for a wide one.
field_access— per-column Cedar rules -- seeFieldAccessoperations— which of list/retrieve/create/update/delete to generatereadonly— columns a form never sets (server-set ones)exclude— columns no view ever showsexpose— columns withheld by default to show anyway, an explicit act
Raises:
AdminError— the model has a composite primary key, a name collides- with a registration already made, or a column named in
list_columns,field_access,readonlyorexposeis not a- column of
model-- each of which would otherwise read as - protection or presentation that silently does nothing.
router#
router(prefix: str = '/admin') -> AnyBuild the Router for every registered model.
Route paths are literal per model, so /{slug}/new is a static segment that wins over /{slug}/{pk}. The consequence is worth knowing: a model with a text primary key whose value is exactly new is not reachable through the admin. Every generated admin makes this trade; it is written down here rather than discovered.
Raises:
AdminError— nothing is registered, or a write operation is- registered with no
csrfverifier. The generated admin has not - yet adapted
CsrfPolicy(form_field=...)automatically, so an - admin that accepted posts without the explicit adapter would be
- a cross-site write against the most privileged surface.
AdminError#
A misdeclared admin, raised at registration or at router().
FieldAccess#
The Cedar actions that permit reading and writing one column.
admin.register(User, field_access={
"email": FieldAccess(read="read_contact"),
"plan": FieldAccess(read="read_billing", write="change_billing"),
})read=None means the column inherits the model's own read rule -- it is visible to anyone the list and detail views already admit. write=None likewise inherits, so a column may be freely readable and separately gated on write, or the reverse.
A column that is unreadable is also unwritable, whatever write says. The alternative is a form that accepts a value for a field its author cannot see, which is an overwrite nobody can review.
FieldAccess(read: str | None = None, write: str | None = None) -> NoneModelAdmin#
One registered model and how the admin presents it.
ModelAdmin(model: type, slug: str, label: str, table: str, primary_key: str, columns: tuple[str, ...], list_columns: tuple[str, ...], editable: tuple[str, ...], sortable: frozenset[str], python_types: Mapping[str, Any], field_access: Mapping[str, FieldAccess], operations: frozenset[str], page_size: int, resource: str, meta: Mapping[str, Any] = <factory>) -> Nonewreath.infra.model#
The typed plan wreath.infra.infer returns.
Every type here is frozen and holds only data: no method reaches back into the application, opens a connection, or reads the environment. That separation is deliberate -- the plan is meant to be rendered, compared, serialised, and read by a person before anything touches an account, so it must be inert once built.
The vocabulary is small on purpose. Stage 1 does not model cloud resources; it models what an application requires, in the words the application already used. A database is a database, not an aws_db_instance.
ConnectionBudget#
How many backends one process wants from one endpoint, and who holds them.
held counts connections taken at startup and never given back for the life of the process: every LISTEN doorbell acquires from a workload pool and keeps its connection, so a pool sized without allowing for them is a pool that starves. available is what is left for requests.
ConnectionBudget(endpoint: str, workload: str, pool_max: int, held: int, holders: tuple[str, ...]) -> Noneavailable (property)#
available: intDatabaseRequirement#
One PostgreSQL database an application registered with app.postgres.
DatabaseRequirement(name: str, endpoint: str, database: str, user: str | None, pools: tuple[WorkloadPool, ...], budgets: tuple[ConnectionBudget, ...], schemas: tuple[str, ...], extensions: tuple[str, ...], models: int, components: tuple[SchemaComponent, ...]) -> NoneEgressRequirement#
One outbound origin the application pinned a client to.
An egress rule is a firewall's business, and this is the declaration it should be generated from: HTTPClient fixes its origin at construction and DestinationPolicy states which schemes, hosts and ports it will ever resolve to. Both are read here rather than guessed from call sites.
EgressRequirement(name: str, origin: str, base_path: str, max_connections: int, destination: str, declared_by: str) -> NoneGap#
Something the deployment must resolve, named precisely enough to act on.
Gap(kind: GapKind, subject: str, detail: str) -> NoneGapKind#
What kind of hole in the deployment a Gap names.
InfrastructurePlan#
Everything one application requires, derived from its own declarations.
InfrastructurePlan(application: str, databases: tuple[DatabaseRequirement, ...] = (), object_stores: tuple[ObjectStoreRequirement, ...] = (), egress: tuple[EgressRequirement, ...] = (), listeners: tuple[ListenerRequirement, ...] = (), subsystems: tuple[SharedSubsystem, ...] = (), settings: tuple[SettingsContract, ...] = (), gaps: tuple[Gap, ...] = (), notes: tuple[str, ...] = <factory>) -> NoneListenerRequirement#
The application's own inbound surface.
One listener, always, because a wreath application is one ASGI callable. Whether it sits behind a load balancer, and on which port, is a deployment decision the application does not make and this plan does not invent.
ListenerRequirement(protocol: str, routes: int, websocket_routes: int, methods: tuple[str, ...], mounts: tuple[str, ...]) -> NoneObjectStoreRequirement#
One app.objects(...) registration and what it implies.
ObjectStoreRequirement(name: str, backend: str, root: str | None = None, bucket: str | None = None, region: str | None = None, host: str | None = None, path_style: bool = False, credentials: str | None = None, requires: tuple[str, ...] = ()) -> NonePresence#
Whether a subsystem was found, absent, or simply cannot be seen.
The third value is the honest one and the reason this is not a boolean. Some subsystems register themselves on the application (app.jobs(...)), and for those a plan can say ABSENT and mean it. Others are constructed by the application author and handed straight to a handler -- a RoomRegistry, a Workflow -- so the application object never learns they exist. Reporting those as absent would be a false negative dressed as a finding.
SchemaComponent#
One subsystem's tables, and the database they belong in.
SchemaComponent(name: str, schema: str, relations: tuple[str, ...], declared_by: str) -> NoneSettingsContract#
The contract between one settings dataclass and a deployment's environment.
wreath.config.Environment.bind is the app-side half: it turns field names into environment keys by a fixed rule. This records the keys that rule produces, so the other half -- whatever supplies them -- can be checked against it instead of maintained beside it.
SettingsContract(model: str, prefix: str, keys: tuple[SettingsKey, ...], unread: tuple[str, ...] = ()) -> NoneSettingsKey#
One environment key a settings model reads, and whether anything sets it.
SettingsKey(field: str, key: str, annotation: str, required: bool, secret: bool, supplied_by: str | None) -> NoneSharedSubsystem#
A subsystem that would be a separate service in another framework.
This is the row a reader will not believe, so every one is listed whether or not the application uses it -- an omission would read as an oversight rather than as the claim it is. backing is where the state actually lives.
SharedSubsystem(name: str, module: str, instead_of: tuple[str, ...], backing: str, presence: Presence, detail: str) -> NoneWorkloadPool#
One wreath.postgres workload pool, as the application configured it.
WorkloadPool(workload: str, min_size: int, max_size: int, endpoint: str) -> Noneas_dict#
as_dict(plan: InfrastructurePlan) -> dict[str, object]The plan as plain JSON-compatible data, for --format json.
dataclasses.asdict on its own drops ConnectionBudget.available, because a property is not a field; it is re-added here rather than stored so the two renderings cannot disagree about arithmetic.
wreath.infra.inference#
Derive an infrastructure plan from an application's own declarations.
Nothing here guesses. Every value in the returned plan is read off an object the application constructed at import: app.postgres(...) built a wreath.postgres.Database and it still holds its DSN and its pool sizes; app.objects(...) built a store and it still holds its bucket or its root; app.http_client(...) built a client and it still holds the origin it is pinned to. Inference is a walk over those, not an analysis of anything.
Nothing here connects to anything either. No socket is opened, no DSN is resolved, no bucket is listed, and no cloud SDK is imported -- the plan is derived entirely from objects already in memory. tests/test_infra_infer.py asserts both of those directly rather than trusting this paragraph.
infer#
infer(app: Any, *, application: str, settings: Iterable[tuple[type, str, str]] = (), supplied: Mapping[str, str] | None = None, dotenv_keys: Mapping[str, str] | None = None) -> InfrastructurePlanRead one built wreath.app.Wreath and return what it requires.
The application must already be constructed -- inference reads the objects its declarations built, so a factory has to have been called first. Nothing is started: no pool opens, no lifespan runs, no socket is created.
Args:
app— a built application.application— how the application was named on the command line, for the- plan's header.
settings—(dataclass, label, prefix)for each settings model whose- environment contract should be checked. Empty means the contract is
- not checked at all, and a note says so -- an unchecked contract must
- not read as a satisfied one.
supplied— environment key to the label of whatever supplies it.dotenv_keys— the subset ofsuppliedthat came from an authored dotenv- file, which is the only source worth reporting unread keys against.
Returns:
- The plan. Read it, then decide; nothing here decides anything.
Raises:
TypeError—appis not a built wreath application.
wreath.infra.render#
Turn an InfrastructurePlan into something to read, or something to parse.
The plan is the artefact; these are two views of it. render_text is written to be pasted into a review or a ticket -- it names every requirement and, where a requirement is absent, says so out loud rather than leaving a silence for the reader to interpret. render_json is the same content with no opinions, so a pipeline can diff two plans or feed one to something else.
render_json#
render_json(plan: InfrastructurePlan, *, indent: int = 2) -> strThe plan as JSON, keys in declaration order, one trailing newline.
render_text#
render_text(plan: InfrastructurePlan) -> strThe plan as a report to read, ending in one newline.
Absence is always stated. A section with nothing in it prints a sentence saying so, because a plan that silently omits object storage and a plan that found none look identical otherwise, and only one of them is an answer.
wreath.infra.settings#
The environment keys a settings dataclass reads, derived the way bind does.
wreath.config.Environment.bind turns a dataclass into an environment contract by a fixed rule: uppercase the field name, join it to the prefix with _ at the root and __ inside a nested group, and let Annotated[T, Env("EXACT")] override the whole thing. That rule is the app-side half of the seam this module exists to close, so the keys are derived from the same primitives the binder uses rather than from a second reading of the same idea.
Concretely, _unwrap_env is imported from wreath.config instead of being reimplemented. A copy would be a second place for the Annotated/Env rule to live, and the failure mode of two copies is not a crash -- it is a plan that names a key the application never reads, which is exactly the class of defect this whole feature is for.
describe_annotation#
describe_annotation(annotation: Any) -> strA short readable name for a settings annotation.
Used for display only. Secret[str] renders as Secret[str] rather than as the wrapped type, because a reader deciding how to supply a key needs to know it is a credential.
settings_keys#
settings_keys(settings: type, *, prefix: str = '') -> tuple[SettingsKey, ...]Every environment key Environment.bind(settings, prefix=prefix) will read.
Nested dataclass fields are walked exactly as the binder walks them, so database.host under prefix="APP" comes back as APP_DATABASE__HOST with the dotted field path preserved for the report.
supplied_by is left None here; deciding what supplies a key is the caller's job, and keeping the two apart is what lets one derivation be checked against several environments.
Args:
settings— the dataclass type an application binds.prefix— the sameprefixthat would be passed tobind.
Returns:
- One
SettingsKeyper leaf field, in declaration order.
Raises:
TypeError—settingsis not a dataclass type, or an annotation on it- cannot be resolved -- the same two refusals
bindmakes, for the - same reason.
wreath.infra.deploy#
Inspectable deployment artifacts from an inferred infrastructure plan.
This renderer accepts an immutable OCI image and produces an inert Compose bundle. It never contacts a provider and deliberately has no apply operation.
DeploymentArtifact#
One named UTF-8 file in a deployment bundle.
DeploymentArtifact(path: str, content: str) -> NoneDeploymentBundle#
A complete, inert set of deployment files.
DeploymentBundle(artifacts: tuple[DeploymentArtifact, ...]) -> Nonefiles#
files() -> dict[str, str]Return the bundle as {relative path: content}.
write#
write(directory: str | Path, *, force: bool = False) -> tuple[Path, ...]Write each artifact atomically beneath one explicit directory.
Existing bundle files are refused unless force=True. Unrelated files in the directory are neither read nor removed.
deployment_bundle#
deployment_bundle(plan: InfrastructurePlan, *, image: str, service: str = 'wreath-app', port: int = 8000, factory: bool = False) -> DeploymentBundleBuild a provider-neutral Compose deployment bundle.
image must carry a SHA-256 digest. A mutable tag can resolve to a different application between review and rollout, so accepting one would make the checksummed bundle reproducible in name only. Plans with gaps are refused before any file is produced. A local object-store root must be an absolute path naming its location inside the container.
wreath.infra.cli#
Presentation-only bindings for wreath infra.
_cli.py is shared by every subcommand, so the implementation lives here and only the parser registration and one dispatch clause live there -- _migrations.cli and _docs.cli set that precedent. Nothing in this module decides anything: it resolves the arguments into the objects wreath.infra.infer takes, calls it once, and prints the rendering.
add_infra_parser#
add_infra_parser(commands: Any) -> NoneRegister wreath infra on _cli.build_parser's subparsers.
execute#
execute(namespace: argparse.Namespace, load_application: Callable[..., Any]) -> intRun one wreath infra action. Returns the process exit code.
A plan with gaps exits 1. That is the point of the command: a settings key nothing supplies is a deployment that will start and die, and a CI step that runs this should fail on it rather than print it.
wreath.typegen.model#
The canonical typegen intermediate representation.
One semantic model feeds both OpenAPI and every consumer target (TypeScript models, the fetch client, React Query hooks). Python owns all annotation analysis; the records here are frozen, hashable, and contain no live Python objects, so a renderer -- pure or native -- only ever sees normalized data.
ApiModel#
ApiModel(title: 'str', version: 'str', models: 'tuple[Model, ...]' = (), operations: 'tuple[Operation, ...]' = (), permissions: 'tuple[PermissionSet, ...]' = (), series: 'tuple[SeriesShape, ...]' = ())
ApiModel(title: str, version: str, models: tuple[Model, ...] = (), operations: tuple[Operation, ...] = (), permissions: tuple[PermissionSet, ...] = (), series: tuple[SeriesShape, ...] = ()) -> NoneDATE (value)#
DATE: TypeRef = TypeRef(kind='string', name='date', arguments=(), literals=())DATE_TIME (value)#
DATE_TIME: TypeRef = TypeRef(kind='string', name='date-time', arguments=(), literals=())Diagnostic#
A source-anchored problem, kept out of renderer input on purpose.
Diagnostic(message: str, operation_id: str | None = None, method: str | None = None, path: str | None = None, location: str | None = None, annotation: str | None = None) -> Nonerender#
render() -> strField#
Field(wire_name: 'str', type: 'TypeRef', required: 'bool', description: 'str | None' = None, examples: 'tuple[object, ...]' = (), gt: 'int | float | object | None' = None, ge: 'int | float | object | None' = None, lt: 'int | float | object | None' = None, le: 'int | float | object | None' = None, min_length: 'int | None' = None, max_length: 'int | None' = None, pattern: 'str | None' = None, unique_items: 'bool' = False)
Field(wire_name: str, type: TypeRef, required: bool, description: str | None = None, examples: tuple[object, ...] = (), gt: int | float | object | None = None, ge: int | float | object | None = None, lt: int | float | object | None = None, le: int | float | object | None = None, min_length: int | None = None, max_length: int | None = None, pattern: str | None = None, unique_items: bool = False) -> NoneModel#
Model(name: 'str', fields: 'tuple[Field, ...]')
Model(name: str, fields: tuple[Field, ...]) -> NoneOperation#
Operation(id: 'str', method: 'str', path: 'str', parameters: 'tuple[Parameter, ...]', request_body: 'TypeRef | None', request_body_media_type: 'str | None', response_body: 'TypeRef', tags: 'tuple[str, ...]' = (), summary: 'str | None' = None, description: 'str | None' = None, behaviours: 'tuple[str, ...]' = ())
Operation(id: str, method: str, path: str, parameters: tuple[Parameter, ...], request_body: TypeRef | None, request_body_media_type: str | None, response_body: TypeRef, tags: tuple[str, ...] = (), summary: str | None = None, description: str | None = None, behaviours: tuple[str, ...] = ()) -> NoneParameter#
Parameter(python_name: 'str', wire_name: 'str', location: "Literal['path', 'query', 'header', 'cookie']", type: 'TypeRef', required: 'bool')
Parameter(python_name: str, wire_name: str, location: "Literal['path', 'query', 'header', 'cookie']", type: TypeRef, required: bool) -> NonePermissionSet#
The actions one resource type is authorized for, read off the routes.
In the IR because the generated client should be typed on the server's vocabulary: a UI asking about an action the API does not enforce is a bug that ought to be a compile error, not a silent false.
PermissionSet(resource_type: str, actions: tuple[str, ...]) -> NoneSeriesMeasure#
One named quantity on a calculated view.
The name is load-bearing in three places at once: it keys the series in the envelope, it names the field in the generated TypeScript, and it is what a fill= refers to. That is why measures are named rather than positional -- positional ones arrive in a component as value_0.
fills says whether an absent bucket reads as a number or as null, which is the difference between number[] and (number | null)[] on the other side. A count fills with zero; an average of no rows is undefined and stays null, and a component that has to handle the gap should be made to.
SeriesMeasure(name: str, kind: str, unit: str | None = None, fills: bool = True) -> NoneSeriesShape#
A calculated view, typed for the client that will draw it.
In the IR for the same reason PermissionSet is: the shape a chart endpoint returns is decided by a declaration on the server, and a component indexing number[][] by hand is a copy of that declaration which nothing keeps honest.
SeriesShape(name: str, form: str, measures: tuple[SeriesMeasure, ...], bucket: str | None = None, grouped: bool = False, compares: str | None = None, events: bool = False) -> NoneTypeRef#
A structural type. reference names a model in ApiModel.models.
TypeRef(kind: TypeKind, name: str | None = None, arguments: tuple[TypeRef, ...] = (), literals: tuple[str | int | float | bool | None, ...] = ()) -> NoneTypegenError#
Raised when a model cannot be built under the active strictness.
TypegenError(diagnostics: tuple[Diagnostic, ...]) -> NoneUNKNOWN (value)#
UNKNOWN: TypeRef = TypeRef(kind='unknown', name=None, arguments=(), literals=())NULL (value)#
NULL: TypeRef = TypeRef(kind='null', name=None, arguments=(), literals=())BOOLEAN (value)#
BOOLEAN: TypeRef = TypeRef(kind='boolean', name=None, arguments=(), literals=())INTEGER (value)#
INTEGER: TypeRef = TypeRef(kind='integer', name=None, arguments=(), literals=())NUMBER (value)#
NUMBER: TypeRef = TypeRef(kind='number', name=None, arguments=(), literals=())STRING (value)#
STRING: TypeRef = TypeRef(kind='string', name=None, arguments=(), literals=())wreath.typegen.inspect#
Build the canonical typegen model from Wreath's routes and binding specs.
This is the single place Python annotations are interpreted for client generation; OpenAPI consumes the same model. Nothing here emits target syntax -- it produces the frozen records in wreath.typegen.model.
build_api_model#
build_api_model(app: Any, *, title: str = 'Wreath', version: str = '0.1.0', allow_unknown: bool = False) -> ApiModelConstruct the canonical model for app's routes.
Raises TypegenError when strict (allow_unknown=False) and any annotation is unsupported, or on any operation-id collision regardless of strictness.
derive_operation_id#
derive_operation_id(method: str, path: str) -> strA deterministic camelCase id from method and path.
GET /widgets/{widget_id} becomes getWidgetsByWidgetId -- stable across handler renames and reused handlers, unlike handler.__name__.
is_valid_identifier#
is_valid_identifier(name: str) -> boolresolve_operation_ids#
resolve_operation_ids(routes: list[Any]) -> tuple[dict[tuple[int, str], str], tuple[Diagnostic, ...]]Map each (route index, method) to a unique operation id.
Explicit ids are validated and preserved; missing ones are derived. A collision -- explicit or derived -- is a hard error naming both routes.
wreath.typegen.render#
Select the deterministic Python renderer used by type generation.
Client rendering is a cold developer-tool path. typescript_renderer assembles output linearly with a single join, and select_renderers returns it with the backend name consumed by the CLI and generated-file metadata.
select_renderers#
select_renderers() -> tuple[Renderer, Renderer, str]Return (render_models, render_client, backend_name).
wreath.typegen.typescript_renderer#
The TypeScript renderer for typegen.
This module renders already-normalized, prevalidated tuples -- never the semantic dataclasses and never live Python objects. That keeps the boundary to an optional native renderer explicit and makes parity fixtures compact. Output assembly is linear: a list builder with one final join, no output-proportional string concatenation.
This stays Python. The "benchmark decision" render.py defers to has been made: rendering a client is a cold path reached from wreath typegen, run by a developer occasionally rather than by a request, and the assembly above is already linear with a single join. select_renderers keeps its seam because it costs nothing, but a missing _core.typegen_render_* is not a gap.
Normalized tuple contract (version TYPEGEN_CONTRACT):
type_tuple `(kind: str, name: str | None, args: tuple[type_tuple, ...], literals: tuple[str | int | float | bool | None, ...])`
declaration (an interface) (name: str, fields: tuple[(wire_name: str, type_tuple, required: bool), ...])
operation (a client method) (id, method, path, params, request_body, request_media, response) where params is ((client_name, wire_name, location, type_tuple, required), ...), request_body is a type_tuple or None, and response is a type_tuple.
render_client#
render_client(payload: tuple[Any, ...], flags: int = 0) -> bytespayload is (import_names, operations). import_names are the model/parameter interface names to import as types from ./models.
render_models#
render_models(declarations: tuple[Any, ...], flags: int = 0) -> bytesRender the models module: data-model and per-operation parameter interfaces, in the order supplied (Python owns ordering).
ts_type#
ts_type(node: tuple[Any, ...]) -> strwreath.typegen.targets.python#
The Python target: a typed ServiceClient subclass for a sibling service.
Service-to-service calls were stringly typed -- await client.get(f"/llamas/{id}") returning Any, with the caller re-parsing what came back. Everything needed to do better already existed separately: a strict OpenAPI generator, a compatibility comparator, a binding layer that builds validators from ordinary Python types, and a ServiceClient that binds a base path and an auto-refreshing bearer token.
This target joins them. What it emits:
models.py-- one dataclass per model the API returns or accepts.client.py-- aServiceClientsubclass with one typed method per operation, and aSPEC_DIGESTnaming the contract it was generated from.
Two rules shape everything here.
No transport. The generated client subclasses ServiceClient and calls self.request(...). Pooling, retries, rate limiting, origin pinning and token refresh are solved in wreath.http_client, and a generated file must not re-solve them -- a bug fixed there must not need regenerating to reach anyone.
Responses bind through wreath.binding.validate, the same function the server uses on the way in. An extra field on the wire is refused exactly as it would be server-side. That strictness is the feature: a provider that starts sending a field you do not model is a change you want to hear about at the boundary, not three layers in where the shape has already been passed around.
render_python#
render_python(api: ApiModel, *, document: dict[str, Any] | None = None, class_name: str = 'GeneratedServiceClient') -> dict[str, str]Return {filename: contents} for the Python target.
spec.json is the document this was generated from, retained so wreath typegen --check-contract has a previous to compare against. SPEC_DIGEST alone cannot serve: a digest answers "changed or not", and telling a breaking change from a compatible one needs the document itself.
spec_digest#
spec_digest(document: dict[str, Any]) -> strA stable digest over the document, independent of key order.
wreath.typegen.targets.typescript#
Plan an ApiModel into TypeScript output files.
The planner normalizes the semantic model into the renderer's tuple contract, computes deterministic client identifiers (camelCase) while preserving wire names, and assembles the file set. Rendering of the two size-scaling modules (models, client) goes through the pure/native facade; the small structural files (index, manifest, react-query) are assembled here.
permission_flag#
permission_flag(action: str) -> str"Llama::force_sync" -> "canForceSync".
The verb only: a component destructuring { canEdit } off a llama's permissions already knows what it is looking at, and canLlamaEdit reads like a stutter.
render_typescript#
render_typescript(api: ApiModel, *, react_query: bool = False, base_url_env: str | None = None) -> dict[str, str]Return {filename: contents} for the TypeScript target.
wreath.typegen.targets.proto#
The proto target: a .proto schema for the shapes this API exchanges.
A consumer in another language — a mobile client, an embedded device, a service written in Go — gets its schema generated from the same ApiModel that produces the OpenAPI document, the TypeScript client and the Python one. The usual arrangement is the reverse: a hand-maintained .proto beside a hand-maintained REST contract, agreeing by discipline. Here they cannot disagree, because one model produces both.
Messages only, deliberately. Nothing here emits a service. Turning REST operations into RPCs means inventing a request and a response message per operation and a naming scheme for both, and that invented surface would be a second contract with nobody to check it against. wreath.grpc declares its own methods against real @message classes; that is where an RPC contract belongs.
Field numbers come from declaration order, which makes reordering a dataclass a wire-breaking change. That is stated in the generated header rather than papered over: wreath.protobuf makes numbers explicit for exactly this reason, and a generator cannot recover an intent the source never recorded. Pin the schema in review, or declare the message with wreath.protobuf and generate from that instead.
What cannot be expressed is refused by name rather than approximated. A .proto that compiles and describes the wrong bytes is worse than one that was never written, because the failure surfaces in another team's decoder.
ProtoTargetError#
A shape this API exchanges cannot be expressed in proto3.
render_proto#
render_proto(api: ApiModel) -> dict[str, str]Return {filename: contents} for the proto target.
wreath.typegen.cli#
The wreath typegen command: build the model and emit consumer files.
Generation renders every file in memory first, then writes through temporary files replaced atomically, so a failure never leaves a half-written tree. Only paths recorded in wreath-typegen.json are ever removed, and only inside the selected output directory.
TypegenCliError#
TypegenCliError(message: str, *, exit_code: int = 1) -> NoneTypegenOptions#
TypegenOptions(target: 'str', output: 'str', react_query: 'bool' = False, base_url_env: 'str | None' = None, check: 'bool' = False, check_contract: 'bool' = False, allow_unknown: 'bool' = False, factory: 'bool' = False, title: 'str' = 'Wreath', version: 'str' = '0.1.0', class_name: 'str' = 'GeneratedServiceClient')
TypegenOptions(target: str, output: str, react_query: bool = False, base_url_env: str | None = None, check: bool = False, check_contract: bool = False, allow_unknown: bool = False, factory: bool = False, title: str = 'Wreath', version: str = '0.1.0', class_name: str = 'GeneratedServiceClient') -> Nonecheck#
check(files: dict[str, str], output_dir: Path) -> list[str]Return the reasons --check should fail; empty means up to date.
check_contract#
check_contract(current: dict[str, Any], output_dir: Path) -> tuple[Any, ...]Backwards-incompatible changes between the pinned document and current.
Empty means the provider is still compatible; additions are compatible and are not reported. Raises when nothing is pinned, rather than returning empty: a gate that passes because it found no baseline is a gate with nothing to check, and it would read as a green build forever.
run#
run(app: object, options: TypegenOptions) -> intwrite#
write(files: dict[str, str], output_dir: Path) -> Nonewreath.port#
Analyze, emit, and behaviourally verify an application port.
wreath port statically analyzes an existing FastAPI/Pydantic/ormar/SQLModel application and reports what maps 1:1 to Wreath, what needs review, and what has no equivalent, without importing the target. It can emit a sister source tree and then compare the source and candidate ASGI applications against one declared HTTP corpus. Analysis and emission remain static; only explicit verification imports and runs application targets.
from wreath.port import analyze, analyze_all report = analyze_all(["path/to/app"]) print(report.to_markdown())
analyze#
analyze(root) -> ReportAnalyze a single app root (directory or file) and return its Report.
One bad file is recorded and skipped, never fatal. A 3000-file tree reliably contains a broken symlink, a file whose permission bit says no, a file deleted between the walk and the read, a null byte in a ".py" that is really a fixture, and an expression nested past the parser's limit. Each of those takes its own file out of the run and leaves the rest in, and each lands in Report.skipped with a reason — a silently dropped file is indistinguishable from a file with nothing in it, and the coverage number is computed from exactly this population.
KeyboardInterrupt and SystemExit derive from BaseException and are deliberately not caught: a run the user asked to stop must stop.
analyze_all#
analyze_all(roots) -> ReportAnalyze several app roots (a glob of apps, design 07 §5) into one Report.
TreeContext#
What one module needs to know about the rest of the tree to be ported well.
A module on its own cannot see that Llama's primary key is a UUID, that NewLlama is a body model rather than a query parameter, or that a GraphQL type lists exactly the columns of the model it shadows. Every one of those changes the verdict, so port_tree reads the tree once and hands the answers to each file.
TreeContext(pk_types: dict[str, str] = <factory>, index: dict[str, set[str]] = <factory>, orm_columns: dict[str, set[str]] = <factory>, orm_relations: dict[str, dict[str, str]] = <factory>, orm_tables: dict[str, str] = <factory>, orm_unique_constraints: dict[str, tuple[frozenset[str], ...]] = <factory>, positional_model_calls: frozenset[str] = frozenset(), session_functions: frozenset[str] = frozenset(), session_definition_sites: frozenset[tuple[str, int]] = frozenset(), session_call_sites: frozenset[tuple[str, int, int]] = frozenset(), session_sites_resolved: bool = False, class_bases: dict[str, list[str]] = <factory>, class_members: dict[str, set[str]] = <factory>, django: DjangoImage = <factory>) -> Noneof (classmethod)#
of(files: list[Path], on_skip=None, *, opinionated: bool = False, trees: Mapping[Path, ast.Module] | None = None) -> TreeContextemit_module#
emit_module(source, context: TreeContext | None = None, *, opinionated: bool = False) -> strPort one module. source may be a Path/path-string (read) or source text.
Returns the ported source (with a provenance header), preserving every function body verbatim. Raises EmitError if the result is not valid Python.
context carries what the rest of the tree knows — which models exist, what type each primary key is, which columns each one declares — so a foreign key onto a model in another file gets the right type and a body parameter is recognized as a body. port_tree reads it once for the whole tree; a lone module falls back to what it can see in itself.
Two orderings here are load-bearing:
- the walk runs before the imports are rewritten, because whether a name can be dropped from an import depends on whether every use of it was replaced, and only the walk knows that;
- the emitter's own notes are written before the analyzer's, so where both have something to say about a line, the more specific one wins.
port_tree#
port_tree(root: str | Path, output: str | Path | None = None, *, in_place: bool = False, force: bool = False, opinionated: bool = False) -> PortResultPort every .py under root into a sister tree (or in place).
Idempotent: an unchanged source whose output still carries a matching provenance hash is skipped; an output that was hand-edited (its body hash no longer matches the recorded output-sha256) is left untouched unless force.
A source that cannot be read is recorded in failed, not fatal — the same rule analyze follows, for the same reason: one broken symlink in a large tree must not end the run. A destination that cannot be written is fatal, because it condemns every remaining file and a partial output tree is indistinguishable from a complete one.
PortResult#
What one port_tree run wrote, left alone, and could not read.
skipped and failed are deliberately separate. A skip is a success: the output was already current, or it had been hand-edited and refusing to clobber it is the correct answer. A failure is a file that could not be read or translated at all, so nothing about it reached the output tree. A caller that folded the two together could not tell "your tree is already ported" from "a third of your tree never made it".
PortResult(written_files: list[Path] = <factory>, regenerated: list[Path] = <factory>, skipped: list[Path] = <factory>, failed: list[SkippedFile] = <factory>) -> NoneFinding#
One recognized construct, its translation verdict, and where it lives.
Finding(file: str, line: int, construct: str, tag: str, rule_id: str, message: str, category: str) -> Noneas_dict#
as_dict() -> dictReport#
Aggregate of findings with coverage math and JSON/markdown renderings.
Coverage is undefined, not 1.0, when nothing was recognized. coverage and coverage_overall return None for an empty denominator, and every rendering here prints n/a for it. A tree the analyzer understood nothing of is the case where the tool has failed hardest, and "100% auto-translatable" is the single most misleading thing it could say there.
Skipped files sit outside the coverage fraction entirely. They contribute to neither numerator nor denominator — a file that could not be parsed has no constructs to classify, and inventing a verdict for it would be a guess. So coverage answers "of what I could read, how much carries across", and files_analyzed/skipped say how much of the tree that sentence covers.
Report(findings: list[Finding], roots: list[str] | None = None, skipped: list[SkippedFile] | None = None, files_analyzed: int = 0, detection=None) -> Noneas_dict#
as_dict() -> dictcategories#
categories() -> dictcounts#
counts() -> dictcoverage#
coverage(category: str) -> float | Nonetranslated / recognized within category; None if none recognized.
None rather than 1.0: an empty denominator means the analyzer recognized nothing here, which is the absence of an answer and not a perfect score. Callers must render it as "n/a" (see _percent).
coverage_overall#
coverage_overall() -> float | Nonetranslated / recognized across every category; None if none recognized.
merge (classmethod)#
merge(reports: list[Report]) -> Reportrecognized_constructs (property)#
recognized_constructs: intrule_counts#
rule_counts() -> list[tuple[str, str, str, int]](rule_id, category, tag, count) for the non-translated findings.
The report lists findings one per line, in file order. That answers "what does this file need" and hides "what does this codebase need" -- a rule firing forty times across thirty files reads as forty unrelated problems. Clustering by rule is how the ported-app population gets prioritised, and it is the view that kept getting rewritten by hand against --json.
Translated findings are excluded: they are the part that needs no decision, so ranking them ranks work nobody has to do. Heaviest first, then by rule id so equal counts are stable.
to_markdown#
to_markdown() -> strSkippedFile#
A path the analyzer could not read, decode, or parse — and why.
A silently skipped file is how a coverage number becomes a lie: the coverage denominator counts constructs found in files that were analyzed, so every file missing from that population has to be visible next to the number. reason is a stable machine code (see analyzer._SKIP_REASONS); detail is the exception's own message, for a human.
SkippedFile(file: str, reason: str, detail: str) -> Noneas_dict#
as_dict() -> dictMigrationInventory#
One provenance-preserving inventory across several source projects.
MigrationInventory(projects: tuple[ProjectReport, ...], *, target_python: tuple[int, int, int], migration_strategy: str) -> Noneas_dict#
as_dict() -> dict[str, Any]cedar_module#
cedar_module(*, required_condition: str | None = None, default_condition: str | None = None, action_conditions: Mapping[str, str] | None = None, condition_map: Mapping[str, str] | None = None, authentication_factories: Collection[str] = ()) -> strRender fail-closed Cedar and route decorators for complete guards.
A Cedar-shaped principal attribute predicate is preserved as a permit; configured required, condition, and action mappings can express richer generic temporal or hierarchical policy. Every other complete candidate gets a path-scoped forbid, so generated code compiles and protects the route while making the missing grant a review task. Incomplete candidates stay in REVIEW and never become an invented authorization decision.
counts#
counts() -> dict[str, int]to_json#
to_json() -> strto_markdown#
to_markdown() -> strPolicyCandidate#
One dependency guard whose action/resource vocabulary is recoverable.
PolicyCandidate(factory: str, action: str | None, resource_type: str | None, resource: str | None, lookup: str | None, condition: str | None, conditions: tuple[str, ...], complete: bool) -> Noneaction_id (property)#
action_id: str | Noneas_dict#
as_dict() -> dict[str, Any]from_call (classmethod)#
from_call(call: ast.Call, imports: _Imports, defaults: dict[str, ast.AST] | None = None) -> PolicyCandidate | NoneProjectReport#
ProjectReport(name: 'str', root: 'str', report: 'Report', routes: 'tuple[RouteContract, ...]', integrations: 'tuple[str, ...]', dependencies: 'dict[str, Any]')
ProjectReport(name: str, root: str, report: Report, routes: tuple[RouteContract, ...], integrations: tuple[str, ...], dependencies: dict[str, Any]) -> Noneas_dict#
as_dict(*, migration_strategy: str) -> dict[str, Any]effective_counts#
effective_counts(*, migration_strategy: str) -> dict[str, int]Counts after applying an explicitly selected adoption strategy.
RouteContract#
A source-level HTTP or WebSocket route with explicit uncertainty.
RouteContract(file: str, line: int, owner: str, method: str, path: str | None, handler: str, dependencies: tuple[str, ...], access: str, policies: tuple[PolicyCandidate, ...]) -> Noneas_dict#
as_dict() -> dict[str, Any]inventory_projects#
inventory_projects(roots: Sequence[str | Path], *, target_python: str = '3.14', migration_strategy: str = 'preserve') -> MigrationInventoryAnalyze several applications without merging away their ownership.
Difference#
One response field on which the source and candidate disagree.
Difference(case: str, field: str, source: object, candidate: object) -> Noneas_dict#
as_dict() -> dict[str, object]RequestCase#
One byte-exact HTTP request sent to both applications.
RequestCase(name: str, method: str, path: str, headers: tuple[tuple[str, str], ...] = (), body: bytes = b'') -> NoneResponseSnapshot#
The observable HTTP response contract for one application.
ResponseSnapshot(status: int, headers: tuple[tuple[str, str], ...], body: bytes) -> Noneas_dict#
as_dict() -> dict[str, object]VerificationReport#
The complete result of driving one declared request corpus.
VerificationReport(cases: int, differences: tuple[Difference, ...]) -> Noneas_dict#
as_dict() -> dict[str, object]equivalent (property)#
equivalent: boolrender_text#
render_text() -> strload_cases#
load_cases(path: str | Path) -> tuple[RequestCase, ...]Read the documented JSON request-corpus format from path.
verify_apps#
async verify_apps(source_app: Any, candidate_app: Any, cases: tuple[RequestCase, ...], *, ignore_headers: tuple[str, ...] = ('date', 'server')) -> VerificationReportDrive two ASGI applications through lifespan and compare their replies.
Header-name order and casing are not HTTP semantics, so names are normalized and sorted before comparison. Repeated values remain separate and in wire order, because their order can be meaningful. Only Date and Server are ignored by default; callers must opt out of comparing any other header by name.
The two targets must begin from isolated, equivalent external state. This function observes ASGI responses; it does not infer that a database write or an outbound request was equivalent merely because the replies matched.
wreath.mutant#
wreath.mutant -- does your test suite watch the controls you declared?
A passing test proves your code does what you wrote. It does not prove your test would notice if it stopped. Those are different claims, and the gap between them is where authorization bugs live: the rate limit still returns 200, the policy still evaluates, the field is still absent from the response -- and the test that "covers" it would say exactly the same thing if the control were deleted.
wreath mutant removes one declared control at a time and re-runs the tests that reach it. A control whose removal nothing notices is reported. So is a control no test reaches at all, separately, because "the suite would not notice" and "the suite never looks" are different problems.
What makes this different from a general mutation tester is that Wreath owns the declarations. An AuthRequirement, a Cedar policy, an MCP tool's gate, a CRUD router's withheld field set and a rate limit's key are objects, not lines, so the mutations can be phrased the way an incident report is:
src/wreath/_auth/requirements.py :122 clause self.second_factor is not None in a compound or condition predicate.drop-operand in AuthRequirement.access_level
Typical use, from a project root:
wreath mutant # mutate this project, run its tests wreath mutant --operators cedar declaration wreath mutant --path src/myapp/policies.py --format json
It is a report, not a gate. A surviving mutant is a question -- would you want a test to catch this? -- and sometimes the answer is no.
Mutation#
One control, removed one way.
Mutation(identifier: str, operator: str, control: str, site: Site, module: str, patch: Patch | None = None) -> NoneOutcome#
What one mutant did when the tests it could reach were run at it.
Plan#
Everything decided before a single test has been run.
Plan(mutations: list[Mutation] = <factory>, errors: list[tuple[str, str]] = <factory>, watched: dict[str, set[int]] = <factory>, whole_file: set[str] = <factory>, watch: dict[str, tuple[int, ...]] = <factory>, sources: list[str] = <factory>) -> NoneReport#
A whole run. A report, not a gate.
Report(verdicts: list[Verdict] = <factory>, baseline_tests: int = 0, baseline_failures: tuple[str, ...] = (), baseline_seconds: float = 0.0, total_seconds: float = 0.0, sources: tuple[str, ...] = (), live_kills: int = 0, live_probes: int = 0, live_completed: int = 0, live_cancelled_at_seal: int = 0, live_first_started_seconds: float | None = None, selection: dict[str, Any] | None = None, differential_fuzz: dict[str, Any] | None = None) -> Noneas_dict#
as_dict() -> dict[str, Any]by_outcome#
by_outcome(outcome: Outcome) -> list[Verdict]decided (property)#
decided: intMutants with a real answer: killed or survived.
score (property)#
score: float | NoneKilled / decided. None when nothing was decided.
Not a percentage to chase. A suite can reach 100% here and still be blind to the control nobody wrote a mutation for.
Site#
Where in the tree a control is written down.
Site(path: str, line: int, scope: str) -> NoneVerdict#
What happened when one mutation met the tests that reach it.
Verdict(mutation: Mutation, outcome: Outcome, candidates: tuple[str, ...] = (), killers: tuple[str, ...] = (), seconds: float = 0.0, note: str = '', fuzz_evidence: tuple[dict[str, Any], ...] = ()) -> Noneas_dict#
as_dict() -> dict[str, Any]build_plan#
build_plan(roots: Sequence[Path], repo: Path, *, operators: Sequence[str] = (), only: Sequence[str] = (), changed: str | None = None, selected_ids: frozenset[str] | None = None) -> PlanCompile every mutation this run will attempt.
execute#
execute(*, repo: Path, roots: Sequence[Path], tests: Sequence[str], workdir: Path, operators: Sequence[str] = (), only: Sequence[str] = (), extra: Sequence[str] = (), timeout: float = 60.0, max_candidates: int = 4000, limit: int = 0, sample: int = 0, changed: str | None = None, progress: bool = True, maxfail: int = 1, baseline: Baseline | None = None, baseline_wait: Path | None = None, baseline_stream: Path | None = None, budget: float = 0.0, jobs: int = 1, reclaim_workers: bool = False, suite_workers: int = 0, preselected: frozenset[str] | None = None, activity_file: Path | None = None, test_engine: str = 'native', differential_fuzz: DifferentialFuzzConfig | None = None) -> Reportrender#
render(report: Report, *, verbose: bool = False) -> strrender_json#
render_json(report: Report) -> strwreath.docs#
Public static-site generation API.
Site compiles Markdown into a checked static tree. Theme and AssetManifest extend the generated pages without moving content stamping, link assurance, or output ownership into a browser build tool.
AssetManifest#
Named build assets, including hashed outputs from an external renderer.
AssetManifest(*assets: StaticAsset) -> Nonefrom_mapping (classmethod)#
from_mapping(assets: Mapping[str, str], *, source_root: str = '', output_root: str = '') -> AssetManifestCreate a manifest from logical names mapped to renderer output paths.
path#
path(name: str) -> strReturn the public path for name, refusing an undeclared asset.
BuildReport#
BuildReport(pages: 'int', output: 'str', errors: 'tuple[str, ...]' = (), warnings: 'tuple[str, ...]' = ())
BuildReport(pages: int, output: str, errors: tuple[str, ...] = (), warnings: tuple[str, ...] = ()) -> Noneok (property)#
ok: boolLink#
One header link — a project homepage, a package page, a chat room.
icon names one of the built-in marks (ICONS); there is no icon pack and no way to point at a remote SVG, because a docs page that fetches its chrome from a CDN is no longer self-contained.
Link(label: str, url: str, icon: str = 'link') -> NoneNav#
The ordered navigation tree — the single source of page ordering.
Nav(*items: Page | Section) -> Nonepages#
pages() -> tuple[Page, ...]Every Page in the tree, depth-first in nav order.
Page#
One documentation page: a nav title and a source markdown file.
Page(title: str, source: str) -> NonePageContext#
The complete, pre-rendered input passed to a custom page template.
PageContext(site_name: str, page_title: str, content: str, output: str, nav_html: str, toc_html: str, css_href: str, js_href: str, palette: Palette, feel: str, layout: str, search_root: str = '', description: str = '', footer: str = '', home_href: str = 'index.html', map_href: str = '', canonical: str = '', repo_html: str = '', links_html: str = '', tabs_html: str = '', section_title: str = '', section_href: str = '', theme: Theme | None = None) -> Noneasset#
asset(name: str) -> strA page-relative URL for one declared theme asset.
Palette#
A full, coherent colour theme (light + dark surfaces), fonts, and a radius.
Defaults are wreath's pressed-viridian look — an evergreen structural colour on a faintly green-cast paper, with brass reserved for state. Pick a ready-made one from THEMES (palette=THEMES["sepia"]) or tweak any field.
Palette(primary: str = '#1d5b4a', accent: str = '#8a5a12', bg: str = '#f7f8f5', fg: str = '#151a14', muted: str = '#5a6560', border: str = '#dde0d6', surface: str = '#edf0e8', dark_bg: str = '#0e1310', dark_fg: str = '#e4eae2', dark_muted: str = '#93a099', dark_border: str = '#26302a', dark_surface: str = '#161d18', link: str = '', dark_link: str = '', dark_primary: str = '', dark_accent: str = '', font: str = 'system', display: str = 'serif', radius: str = '8px') -> NoneRepo#
The project's source repository, shown in the header.
Args:
url— the repository's web URL, e.g.https://github.com/you/proj.label— header text. Empty derivesowner/namefrom the URL.stats— show star and fork counts. They are read once, at build time- from the host's public API and baked into the HTML — the built page
- never calls out to an API, which is what keeps the site self-contained
- and stops every reader spending your rate limit. No network, a
- timeout, or an error means the link renders without counts and the
- build reports a warning; it never fails the build.
Repo(url: str, label: str = '', stats: bool = False) -> Nonehost#
host() -> str"github", "gitlab", or "" for a repository hosted elsewhere.
slug#
slug() -> strowner/name as it appears in the URL, or "" when there is none.
title#
title() -> strThe text shown in the header.
Section#
A named group of pages (and sub-sections) in the navigation tree.
Section(title: str, *items: Page | Section) -> NoneSite#
A whole documentation site: sources, output, nav, and theme.
Args:
name— site/brand name, shown in the header and<title>suffix.source— directory holding the markdown sources.output— directory the built HTML is written to.nav— the navigation tree (also the page-ordering source of truth).palette— theme colours.strict— fail the build on an orphan page (a.mdnot innav), a- dead internal link, or a broken
#anchor. On by default — a docs - build should not rot.
base_url— canonical site URL (e.g.https://docs.trailhead.example). When set,- a
sitemap.xmland absolute URLs inllms.txtare generated. description— one-line site description (used inllms.txtand page<meta>when a page has none of its own).exclude— glob patterns (matched against each source-relative.mdpath)- for files that live under
sourcebut are deliberately unpublished — - working notes, an agent manifest. Matching files never raise an
- orphan warning. Mirrors mkdocs'
exclude_docs. source_url— base URL each page's "Edit this page" link is built from, e.g.- `
https— //github.com/you/proj/edit/main/docs`. Empty means no edit - links. The page's source-relative path is appended to it.
tabs— promote the top level ofnavinto a row of section tabs in the- header, leaving the sidebar to show only the section you are in. The
- default,
"auto", does this once the nav has at least three - top-level entries — below that a tab row is chrome around nothing.
"never"keeps the whole tree in one sidebar.repo— the source repository, rendered as a header link with optional- build-time star and fork counts. See
Repo. links— extra header links — a homepage, a package page, a chat room —- each drawn with one of the built-in
ICONS. map_page— source path of the site's wayfinding page. When set, a- labelled
Browselink stays in the header on every viewport. The - page must also appear in
nav.
Site(name: str, source: str, output: str, nav: Nav, palette: Palette = <factory>, strict: bool = True, base_url: str = '', description: str = '', feel: str = 'flat', exclude: tuple[str, ...] = (), source_url: str = '', tabs: str = 'auto', repo: Repo | None = None, links: tuple[Link, ...] = (), map_page: str = '', layout: str = 'docs', theme: Theme = <factory>) -> Noneuse_tabs#
use_tabs() -> boolShould the top level of the nav render as header tabs?
StaticAsset#
One build input copied to a stable public path.
StaticAsset(name: str, source: str, output: str) -> NoneTheme#
Optional page template, head content, and named browser assets.
Theme(template: PageTemplate | None = None, assets: AssetManifest = <factory>, stylesheets: tuple[str, ...] = (), scripts: tuple[str, ...] = (), head_html: str = '') -> Nonebuild#
build(site: Site, root: Path | None = None) -> BuildReportRender site to its output directory. Returns a BuildReport.
wreath.cli#
Public command-line entry point for Wreath's server tooling.
main#
main(argv: Sequence[str] | None = None) -> intRun Wreath's command-line interface and return a process exit code.