WreathBrowse
Reference

Application and HTTP#

These modules own the ASGI request boundary and the application surfaces built on it. The signatures below are imported from the installed code during the docs build.

wreath.app#

The Wreath application: registration, startup compilation, and dispatch.

An application is an ordinary ASGI 3 callable. Routes, middleware, exception handlers, lifespan handlers, and infrastructure (databases, job runners, object stores) are registered on the instance; the first request compiles them once into the route table, the per-handler binders, the middleware tape, and the capability masks that dispatch actually uses. Registering anything afterwards is allowed and marks the application dirty, so the next request recompiles.

Wreath#

An ASGI application: the registration surface and the request dispatcher.

An instance is an ordinary ASGI 3 callable, so it runs on any conforming server as well as on wreath's own. Everything belongs to the instance -- routes, middleware, handlers, and the databases, job runners, message buses, HTTP clients and object stores registered on it. None of it is a module global, so two applications in one process share no state and tear down independently.

Registration only records. The first request compiles the route table, the per-handler binders, the middleware tapes and the capability masks in one pass, and no request afterwards pays for introspection. Registering anything later marks the application dirty and the next request recompiles.

Five attributes are public. state is a namespace for application-owned objects, where each registration helper publishes its result -- postgres("main") puts its database on app.state.postgres_main. router is the compiled route table, replaced wholesale on each recompile.

The remaining count failures that no caller is in a position to see, which makes them the only record that anything went wrong: background_errors for background tasks that raised after their response had already been sent, background_timeouts for tasks cancelled for outrunning background_timeout, exception_handler_errors for a registered error renderer that raised while rendering, and lifespan_teardown_errors for a resource that refused to close while a lifespan failure path was releasing it.

One native policy table performs route selection and capability classification.

Args:

  • debug — Put the exception type and message in the body of an unhandled 500.
  • routing — policy, the sole routing implementation.
  • limits — Body, form and cookie ceilings applied to every request built here.
  • background_timeout — Seconds a response's background tasks may run before
  • they are cancelled, or None for no limit. Default 30.
  • ai_scraping — deny (default) refuses known autonomous AI crawlers at
  • ingress. allow explicitly opts the application into that traffic.
  • require_access_declarations — Refuse route compilation unless every HTTP
  • and WebSocket endpoint explicitly declares public, optional
  • identity, authentication, or authorization. Default False for
  • incremental adoption.
Wreath(*, debug: bool = False, routing: RoutingMode = 'policy', limits: RequestLimits = RequestLimits(max_body_bytes=16777216, max_parts=1024, max_part_header_bytes=16384, max_part_bytes=8388608, max_form_memory_bytes=16777216, spool_max_bytes=1048576, max_cookie_bytes=16384, max_form_fields=1024), background_timeout: float | None = 30.0, http_policy: Any = None, ai_scraping: "Literal['deny', 'allow']" = 'deny', signatures: Any = None, hardening: str = 'warn', require_access_declarations: bool = False) -> None

add_error_reporter#

add_error_reporter(reporter: Any) -> None

Register an async application-wide error reporter.

Reporters see unhandled request failures, broken exception renderers, WebSocket handler failures, and background-task failures. Expected HTTPException and validation responses are not errors and are not reported. A reporter that raises is counted on error_reporter_errors and cannot replace the original response or exception.

add_exception_handler#

add_exception_handler(error_type: type[Exception], handler: ExceptionHandler) -> None

Handle error_type and its subclasses with handler.

handler(request, error) is awaited and may return anything a route handler may return; the result is coerced the same way, and a bare value therefore gets status 200 -- return a Response or ProblemResponse to choose the status. Lookup walks the raised exception's MRO and takes the first registration it finds, so the most derived registered class wins and a handler registered for Exception catches HTTPException too. Registering one for NotFound also covers a routing miss.

Registered handlers take precedence over everything built in. With none registered, an HTTPException becomes an RFC 9457 application/problem+json body carrying its status, title and detail, a ValidationError becomes a 422 whose errors extension holds the field errors, and anything else becomes a 500 whose detail is Internal Server Error -- never a {"detail": ...} body. BaseException is caught nowhere in dispatch, so CancelledError, KeyboardInterrupt and SystemExit still unwind the request.

A handler that itself raises does not end the request without a reply. The failure is counted on exception_handler_errors and logged with its traceback to the wreath logger, and the client is answered with the built-in 500 problem document -- in debug, one naming the handler's own exception rather than the original. Treat a non-zero count as a bug in the handler: it means clients are getting a generic 500 where the application meant to shape something.

add_global_middleware#

add_global_middleware(middleware: Middleware, *, priority: int = 0) -> None

Register hook middleware around routing and all HTTP responses.

Global custom middleware must expose before, before_sync, and/or after hooks. Unlike route middleware, it covers misses, static files, and authorization failures, so it is suitable for ingress checks and response headers.

A PipelineHooks may also carry the stage hooks miss, pre_auth, identity and action, each running at the pipeline boundary it names and each able to end the request by returning a response. Ordering and the after contract are those documented on add_middleware().

A middleware may additionally expose handle_preflight, which answers an OPTIONS request that matched no route. Only one may, because two CORS policies answering the same preflight would silently resolve to whichever was registered first.

Raises:

  • TypeError — middleware exposes none of the three hooks.
  • ValueError — A second handle_preflight middleware was registered.

add_middleware#

add_middleware(middleware: Middleware, *, priority: int = 0) -> None

Register middleware, routed by its own scope to one of two pipelines.

This API is exclusively for application-defined hooks. Standard HTTP behavior is configured through wreath.policy.HttpPolicy and never enters a middleware tape. A custom object carrying a truthy global_scope goes to add_global_middleware(); anything else is route-scoped and runs after matching and authorization.

Route middleware is either the hook form (before, before_sync, after, or a MiddlewareHooks) or the legacy (request, call_next) callable. An object carrying both raises TypeError when the routes compile, rather than silently running one and ignoring the other.

Ordering within a pipeline is by priority ascending, ties broken by registration order, and the first item is the outermost: its before runs first and its after runs last. Middleware passed to route(middleware=...) sits inside everything registered here.

The after guarantee is narrower than it is usually stated, and it differs between the two pipelines. Global: an after hook runs only if its own before completed, where returning a response counts as completing, so it may run for a request that never reached the endpoint and may be handed an error response rather than the handler's; an after that raises becomes an error response and the remaining after hooks keep unwinding with it. Route: a before that returns a response still runs its own after and those outside it, but a before or an after that raises abandons the rest of that tape entirely -- no further after hook on the route runs, and the exception becomes the response through the ordinary error boundary.

Args:

  • priority — Lower runs earlier and further out. Ties keep registration order.

add_security_scheme#

add_security_scheme(name: str, schema: Mapping[str, Any]) -> None

Declare one OpenAPI security scheme used by route metadata.

The schema is copied at registration. Routes opt into it with security={name: scopes}; naming an undeclared scheme is refused by OpenAPI generation rather than emitting a dangling reference.

add_status_handler#

add_status_handler(status: int, handler: ExceptionHandler) -> None

Render every HTTPException of this status with handler.

handler(request, error) receives the HTTPException itself and is awaited; its return value is coerced exactly as a route handler's, so a plain dict or str is sent with status 200 -- the handler owns the status, and should return a Response or ProblemResponse carrying status.

Status handlers are consulted only after the exception handlers, and only for an HTTPException. A ValidationError is not one: shape a 422 with set_validation_formatter(), or with an exception handler registered for ValidationError. An ordinary crash is not one either, so a handler registered for 500 sees a deliberately raised HTTPException of that status and never an unhandled error.

billing#

billing(name: str, *, backend: Any, catalog: Any, merchant: Any, capture: Any, topology: Any = None, customer_for: Any = None, payment_for: Any = None, ledger: Any = None, database: str | None = None) -> Any

Register one named billing control plane.

client_facts#

client_facts(name: str = 'default', *, geoip: "GeoIPProvider | Literal['wreath'] | None" = 'wreath', user_agents: UserAgentDatabase | None = None) -> ClientFactsProvider

Register one application-owned client-facts provider.

geoip="wreath" selects the bundled country database. Pass None to disable geographic classification or a structural GeoIPProvider for richer application-owned data. The provider is directly usable as a dependency and is discovered automatically by metrics mounted on this application.

configure_auth#

configure_auth(backend: AuthenticationBackend, authorizer: AuthorizationProvider | None = None, *, vocabulary: AuthorizationVocabulary | None = None) -> None

Install the application-wide authentication backend and policy authorizer.

backend.authenticate(request) returns an Identity or None and runs at most once per request: before route selection when the compiled table classified the path as protected, otherwise on first need in the authorization stage. A request that produces no identity where one is required becomes a 401 carrying backend.challenge(request) as its WWW-Authenticate value; an identity that fails a role or permission check becomes a 403.

authorizer is consulted only for routes carrying @authorize policies. Such a route with no authorizer configured raises RuntimeError when it is requested, rather than allowing the request through.

vocabulary is the complete typed action set for the application. Route compilation refuses an @authorize action absent from it; the route manifest reports members that no route currently uses.

Calling this replaces any previous backend, authorizer, and vocabulary, and marks the routes for recompilation.

configure_http_policy#

configure_http_policy(policy: Any) -> None

Install the application's first-class HTTP policy before startup.

counters#

counters() -> Any

Application-owned framework and metrics-collection failures.

crud#

crud(model: type, open_session: Any, **options: Any) -> Any

Mount auto-generated CRUD routes for one model (requires opt-in).

Off unless enable_crud() was called (config-level opt-in) and you call this per model (model-level opt-in); without the first this raises RuntimeError. A column whose name looks sensitive is hidden from responses unless named in expose=, and it stays unwritable even then -- no generated route will ever set one. options are crud_router()'s keyword arguments. See wreath.crud.

delete#

delete(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a DELETE route. metadata is route()'s keyword arguments.

enable_api_catalog#

enable_api_catalog(*, api_path: str = '/', spec_path: str | None = '/openapi.json', docs_path: str | None = '/docs') -> None

Publish the RFC 9727 API catalog at its well-known location.

Registration is explicit because catalog entries disclose an API's specification and documentation locations. Each configured path must be origin-relative; emitted links become absolute when the request has a Host header.

enable_api_docs#

enable_api_docs(*, path: str = '/docs', spec_path: str = '/openapi.json', environments: Iterable[str] | None = None, env: str | None = None, auth: Any = None, authorize: Callable[[Any], Any] | None = None, authenticated: bool = False, permissions: Iterable[str] = (), try_it_out: bool = False, title: str = 'Wreath', version: str = '0.1.0') -> bool

Serve a self-contained API-docs page + the OpenAPI document, fail-closed.

Both are rendered from the same signature inspection that drives request binding, so the page, the spec, and the typed clients cannot drift. No external/CDN assets are used.

Gating is declarative and fail-closed:

  • environments -- if set, the routes are registered only when the current environment (env arg, else WREATH_ENV, else "production") is listed. Otherwise nothing is registered and both paths 404 -- no existence leak. Forgetting to set an env therefore means production, which means docs OFF.
  • authenticated / permissions / authorize -- require auth on the two routes, enforced (401/403) by a route-scoped guard. authorize is a decorator such as wreath.authorization.authorize.
  • auth -- a backend that authenticates ONLY these two routes, independent of configure_auth() (no global side effect). When it and the other auth args are all omitted, the docs are open within the allowed environments (environment gating is the only guard). A live try-it-out console inherits exactly this gate.

Returns True when the routes were registered, False when the environment gate withheld them.

enable_crud#

enable_crud() -> None

Allow crud() to mount auto-generated CRUD routers.

CRUD is off by default: generating write endpoints from a model is a deliberate, app-wide decision, so it must be turned on explicitly before any model can opt in.

enable_docs#

enable_docs(*, docs_path: str = '/docs', spec_path: str = '/openapi.json', title: str = 'Wreath', version: str = '0.1.0', environments: Iterable[str] | None = ('dev', 'development', 'local', 'test', 'testing')) -> bool

Backwards-compatible alias for enable_api_docs().

Serves the self-contained docs page and OpenAPI document only in the named non-production environments by default. Pass environments=None to register everywhere, or use enable_api_docs() for authorization as well as environment gating.

Returns whether the routes were registered.

entities#

entities(name: str = 'default', *, database: str, bus: str, table: str = 'wreath_entity', lease: Any = 30.0, channel: str = 'wreath_entity', max_pending: int = 1024) -> Any

Configure leased ownership of names, and a question that reaches the holder. See wreath.entity.

Registered rather than merely constructed, so schema_components finds its component() and the table is applied at startup like every other wreath-owned one. An EntityRegistry built by hand is a table nothing creates -- which is the defect schema_components exists to prevent.

exception_handler#

exception_handler(error_type: type[Exception]) -> Callable[[ExceptionHandler], ExceptionHandler]

Register the decorated function with add_exception_handler().

The function is returned unchanged, and the handler contract, precedence and coercion rules are exactly that method's.

flags#

flags(provider: Any = None, **values: str) -> Any

Register a feature-flag provider on app.state.flags, and return it.

Pass a TypedFlagProvider (e.g. FeatureFlags(...)), an original boolean FlagProvider, keyword flag values, or nothing to build one from WREATH_FLAG_<NAME> in the environment.

The provider is returned because that is how it reaches a handler. wreath.flags.flags_dependency captures the provider it is given once, when the dependency is built, and never reads app.state. Wire it as Depends(flags_dependency(app.flags(...))).

frozen#

frozen(path: str, response: PreparedResponse, *, methods: Iterable[str] = ('GET',), **metadata: Any) -> PreparedResponse

Register a response whose complete representation is fixed at startup.

A frozen route has no handler activation to perform. On Wreath's native HTTP server, an application made entirely of frozen routes resolves the route and emits the prepared response without constructing a Request, creating a dispatcher coroutine, or calling Python application code. Generic ASGI servers execute the tiny endpoint below and receive the same ordinary ASGI messages.

Custom middleware, authentication, dynamic host routing, policy and recording deliberately select the ordinary dispatcher: those features need a request lifecycle, so freezing never silently bypasses them.

Args:

  • path — Route path, with the same syntax as route.
  • response — Exact immutable response to replay.
  • methods — HTTP methods answered by this route; defaults to GET.
  • metadata — The ordinary route metadata accepted by route.

Returns:

  • The supplied response, convenient for later inspection.

get#

get(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a GET route. A GET route answers HEAD as well, without a body.

metadata is route()'s keyword arguments -- middleware, tags, summary, dependencies, permissions, operation_id -- and the handler contract, parameter binding and return-value coercion are the same.

health#

health(*, checks: Iterable[Any] = (), liveness_path: str = '/health', readiness_path: str = '/ready', is_live: Any = None) -> Any

Mount liveness and readiness endpoints, /health and /ready by default.

checks are readiness probes: one critical failure answers 503 on the readiness path, and a load balancer takes the instance out of rotation. is_live decides liveness alone -- returning false answers 503 while the process drains -- and reports the process, never its dependencies.

This mounts that pair only. wreath.health.health_router also builds an alerts endpoint, for conditions that need a person rather than a load balancer, and there is no argument for it here: build that router yourself and pass it to include_router().

http_client#

http_client(name: str, *, base_url: str, **options: Any) -> Any

Register one lifespan-managed outbound HTTP client.

include_router#

include_router(router: Router, *, prefix: str = '', tags: Iterable[str] = (), middleware: Iterable[Middleware] = (), dependencies: Iterable[Depends] = (), permissions: Iterable[str] = ()) -> None

Copy router's routes onto this application beneath prefix.

The router is read here, not retained: each of its routes is re-registered on the application with the prefix applied, and this call's tags, middleware and dependencies placed ahead of the route's own. Routes added to router afterwards are not picked up -- include it last.

Access requirements merge rather than replace. A route keeps whatever @authenticated, @roles, @permissions or @authorize it declares, and permissions= here is required on top of it, so including a router can only tighten a route.

Args:

  • prefix — Path prefix. Must begin with /; a trailing / is stripped.
  • permissions — Permission names required on every included route, all of them.

Raises:

  • ValueError — prefix does not begin with /, or a route it adds is a duplicate.

jobs#

jobs(name: str, *, database: str, workload: str = 'write', concurrency: int = 8, lease: Any = 30.0, poll_interval: Any = 5.0, schema: str = 'wreath', batch: int = 1, progress: Any = None) -> Any

Configure a durable job runner on an existing app.postgres() database.

Its workers, sweeper, and scheduler run for the process lifetime, started during lifespan after the databases come up. See wreath.jobs.

Pass a ProgressRegistry to make the queue watchable: JobRunner.launch() hands back a task id, a handler reports through ctx.report(), and the runner sets the terminal state itself. Give the registry the message bus and a job running on any worker is watchable from every worker.

manage_schema#

manage_schema(manage: bool) -> None

Whether wreath creates and upgrades its own tables. Default True.

Pass False when the application's database role cannot CREATE SCHEMA -- common in enterprise deployments, and a supported configuration rather than an error. Wreath then creates nothing, and instead refuses at startup if a registered subsystem's tables are absent, naming the subsystem, the relation, and the command that emits the DDL for a DBA:

wreath schema sql --component jobs

The refusal is at startup rather than at first use deliberately: a subsystem that registers is a subsystem that will be used, so checking lazily reproduces the exact failure this exists to remove.

messaging#

messaging(name: str, *, database: str, workload: str = 'write', schema: str = 'wreath', poll_interval: Any = 5.0, lease: Any = 30.0) -> Any

Configure a pub/sub + durable message bus on an app.postgres() database. Consumers run for the process lifetime. See wreath.messaging.

metrics#

metrics(source: Any, *, path: str = '/metrics', namespace: str = 'wreath', route_labels: Any = None, counter_sources: Iterable[Any] = ()) -> Any

Mount a Prometheus scrape endpoint rendering source.snapshot().

middleware#

middleware(middleware: Middleware | None = None, *, priority: int = 0) -> Middleware | Callable[[Middleware], Middleware]

Register middleware as a decorator or a direct call.

@app.middleware registers the object it decorates and hands it back unchanged; @app.middleware(priority=10) returns the decorator that does the same; app.middleware(obj) registers obj directly. Every form goes through add_middleware(), so a global_scope object still lands in the global pipeline and the ordering rules there apply unchanged.

mount#

mount(prefix: str, application: Any, *, name: str | None = None) -> None

Mount an arbitrary ASGI application below prefix.

The child receives a path with the mount prefix removed and a root_path extended by that prefix. Parent global middleware still runs and its response-header edits are merged into the child's response; the child's own status, streaming, routing and middleware remain independent.

oauth2_login#

oauth2_login(name: str, *, provider: Any, client_id: str, client_secret: str, redirect_uri: str, scopes: tuple[str, ...] = ('openid', 'email'), **options: Any) -> None

Register /auth/login + /auth/callback for provider.

Env/auth gating is the app's business; this only wires the routes. See the design's SSO bridge: the callback verifies the id_token with the provider's own JWKS verifier and writes a principal into the session.

objects#

objects(name: str, *, backend: str = 'local', **options: Any) -> Any

Register an object-storage backend (memory, local, or s3).

Exposed on app.state.objects_<name>. An s3 backend owns a pinned outbound HTTPClient started/stopped with the app; local opens its root at registration and is closed on shutdown. Credentials come from AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN unless given explicitly.

oidc_provider#

oidc_provider(name: str, *, issuer: str, audience: Any = <object object>, http_client: Any, **options: Any) -> Any

Register an OIDC identity provider (Cognito/Auth0/Okta/…).

http_client is the name of a client registered with http_client() and pinned to the issuer origin, or an HTTPClient instance. Discovery and the first JWKS fetch run during lifespan startup (after HTTP clients start), so the first request never pays for them. audience must be explicit; pass None only when another layer validates it.

on_shutdown#

on_shutdown(handler: LifespanHandler) -> LifespanHandler

Run handler(app) during lifespan shutdown, in registration order.

on_startup#

on_startup(handler: LifespanHandler) -> LifespanHandler

Run handler(app) during lifespan startup, in registration order.

orm#

orm(*, database: str, models: Iterable[type], validate_schema: "Literal['off', 'warn', 'error']" = 'error', query_cache_size: int = 512, schema_mode: Any = None) -> Any

Compile models against an existing app.postgres() database.

Models and relationships resolve immediately, so an invalid declaration fails here rather than on a request. Schema validation runs later, during lifespan startup, once the database is up.

patch#

patch(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a PATCH route. metadata is route()'s keyword arguments.

post#

post(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a POST route. metadata is route()'s keyword arguments.

postgres#

postgres(name: str, *, dsn: str, pools: Any = None, workload_dsns: Any = None, shutdown_timeout: float = 10.0) -> Any

Configure one application-owned PostgreSQL database.

put#

put(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a PUT route. metadata is route()'s keyword arguments.

query#

query(path: str, *, accept_query: Iterable[str] = ('application/json',), **metadata: Any) -> Callable[[Handler], Handler]

Register a safe, idempotent QUERY route.

accept_query names the request media types this resource understands. A QUERY without exactly one Content-Type is refused with 400; an unsupported type is refused with 415. Both responses advertise the supported formats in Accept-Query.

route#

route(path: str, *, methods: Iterable[str], middleware: Iterable[Middleware] = (), tags: Iterable[str] = (), summary: str | None = None, dependencies: Iterable[Depends] = (), permissions: Iterable[str] = (), operation_id: str | None = None, response_only: bool = False, status_code: int = 200, response_description: str = 'Successful response', response_media_type: str = 'application/json', responses: Mapping[int, Any] | None = None, deprecated: bool = False, deprecated_at: datetime | None = None, sunset_at: datetime | None = None, deprecation_link: str | None = None, include_in_schema: bool = True, security: Mapping[str, Iterable[str]] | None = None, name: str | None = None, host: str | None = None, cancel_on_disconnect: bool | None = None) -> Callable[[Handler], Handler]

Register the decorated handler for path under each of methods.

The handler is returned unchanged, so it stays directly callable and stacks with @authenticated, @roles, @permissions and @authorize, whose requirements are merged with permissions= rather than replaced by it.

A handler's first parameter is the Request. Every further parameter is bound by name at compile time from the path, query string, headers, cookies or body. Where the source is not obvious, name it with a marker inside Annotated and leave the default an ordinary Python default -- limit: Annotated[int, Query(...)] = 20, never Query(20).

A handler may return a Response, StreamingResponse, FileResponse or PreparedResponse to control the whole reply, or a value that is coerced into one: str becomes text/plain; charset=utf-8, bytes becomes application/octet-stream, and a dict, list, tuple, number, bool or None becomes application/json. Any other type is a TypeError, which the dispatch error boundary turns into a 500 like any other handler error.

Set response_only=True only when the handler always returns a response object. On a route with middleware this removes the coercing wrapper between the handler and the middleware; violating the promise may hand a non-response value to an egress hook.

cancel_on_disconnect decides what a lost client does to a handler that is still working. Left alone, the request method decides: RFC 9110's safe methods (GET, HEAD, OPTIONS, QUERY) are cancelled, because nobody is waiting for the answer and a cancelled query stops scanning; every other method is left to finish, because unwinding it rolls the transaction back but not the mail it already sent. Set it to opt a POST in or a GET out. It is honoured by Wreath's own HTTP/1.1 server; another ASGI server that does not offer the hook leaves every handler running.

Registration only appends to the route table. Signature inspection, binder compilation, middleware fusing and capability-mask assignment all happen once, when the routes are compiled, and cost a request nothing.

Args:

  • methods — HTTP methods, upper-cased here. A GET route also answers HEAD.
  • middleware — Route-scoped middleware, applied inside app-wide middleware.
  • tags — OpenAPI tags for the operation.
  • summary — OpenAPI summary. The handler's docstring becomes its description.
  • dependencies — Depends values resolved per request before the handler.
  • permissions — Permission names the caller must hold, all of them.
  • operation_id — OpenAPI operationId, and the generated clients' method name.
  • response_only — Handler promises to return a response object directly.
  • deprecated — Mark the operation deprecated in OpenAPI.
  • deprecated_at — RFC 9745 effective date, emitted as Deprecation.
  • sunset_at — Expected shutdown date, emitted as Sunset.
  • deprecation_link — URI-reference for rel="deprecation" documentation.
  • cancel_on_disconnect — Cancel this handler when the client goes away;
  • omitted, the request method decides.

Raises:

  • ValueError — This method and path are already registered.

schema_components#

schema_components() -> tuple[Any, ...]

Every registered subsystem's claim on the wreath schema.

Collected by asking, not from a list kept in step with the registries: anything the application holds that offers component() contributes one. A hand-maintained list would be one more place to forget a new subsystem, and forgetting is exactly the defect this mechanism exists to remove -- fourteen subsystems each emitted DDL and nothing ever applied any of it, because emitting and applying were never connected.

Middleware is walked as well as the registries, because the session, rate-limit and idempotency stores reach an application that way rather than through a registry of their own. A middleware does not own tables itself -- it delegates them to a store -- so it answers schema_owners with the store it holds and the claim is collected from there. This includes the PostgreSQL stores behind rate limiting, idempotency and sessions.

Returns:

  • One wreath.schema.Component per registered subsystem that owns
  • tables, in registration order, deduplicated by name.

schema_holders#

schema_holders() -> tuple[Any, ...]

Everything registered that might own tables, in registration order.

The one list. schema_components, _schema_owners and wreath.infra.inference all walk it. The middleware limb is what makes wreath_ratelimit, wreath_idempotency and wreath_session came to be emitted by wreath schema sql and applied by nothing.

series#

series(*, database: str, schema: str = 'wreath') -> Any

Declare that this application reads a sealed Series.

A Series is a declaration built where it is used, so the application never holds one and schema_components() had nothing to ask: the settled-bucket tables were emitted by wreath schema sql and created by nothing. An application therefore had to import wreath._series.settle — past a leading underscore — and run the DDL itself, or find out at the first settle().

Registering here puts the claim where every other wreath-owned table's claim already is, so the lifespan that creates the job ledger and the message bus creates these too, idempotently, and a deployment with manage_schema(False) gets the same startup refusal naming what a DBA must create.

Only sealed views need this. An open Series stores nothing and reads the source table every time.

Args:

  • database — The app.postgres() database the tables belong in.
  • schema — The schema wreath owns. The default is the right answer.

Returns:

  • The store, so a caller can read its component() or schema_sql().

Raises:

  • KeyError — no database is configured under that name.
  • ValueError — that database already has a settled-bucket store.

service#

service(name: str, service: Any) -> Any

Register a generic process-lifetime service with the supervisor.

A service supplies async start(supervisor) and async drain(deadline). It starts after databases and clients, before user startup handlers, and drains before those dependencies close. This is the public lifecycle seam for bounded protocol managers and other infrastructure that does not need a Wreath-specific registry.

The instance is also exposed as app.state.service_<name> and returned unchanged.

set_validation_formatter#

set_validation_formatter(formatter: Any) -> None

Shape 422 bodies with formatter(errors, request) -> ProblemDetail.

errors is the raw list of {"loc", "msg", "type"} dicts from the validator. Pass None to restore the built-in RFC 9457 output. wreath.validation_errors ships a catalogue-backed formatter that translates on type and negotiates Accept-Language.

static#

static(prefix: str, directory: str, *, html_index: bool = True, cache_control: CacheControl | None = None) -> None

Serve files under directory for paths beginning with prefix.

Mounts are consulted only when no route matches, so they cost nothing on the routed hot path.

prefix is normalised to a leading and trailing /. Mounts are scanned in registration order and the first whose prefix matches wins, so precedence is registration order and not longest prefix: mounting /assets/ before /assets/images/ leaves the second unreachable. Register the narrower mount first. A prefix registered twice keeps its first handler.

Args:

  • html_index — Serve index.html for a directory path; when False it 404s.
  • cache_control — Sent as Cache-Control on every file from this mount.

Raises:

  • ValueError — directory does not exist or is not a directory.

url_path_for#

url_path_for(name: str, **parameters: Any) -> str

Build a percent-encoded path for a named route or mount.

users#

users(store: Any, *, secret: str, **options: Any) -> Any

Mount the user-management lifecycle router (register/login/verify/reset).

Mounted under /users unless options says otherwise; options are wreath.users.user_router's keyword arguments. secret signs the verification and password-reset tokens, so it must be stable across restarts. Login writes the session principal the auth stack reads, which requires SessionPolicy: without it /login answers 500 and signs nobody in. options must include the revocable store used by that policy as sessions=; password reset refuses a configuration that cannot delete every session for its subject.

webhooks#

webhooks(name: str) -> Any

Register one named inbound/outbound webhook hub.

websocket#

websocket(path: str) -> Callable[[WebSocketHandler], WebSocketHandler]

Register a WebSocket handler; it receives one WebSocket per connection.

WebSocket routes live in their own table, so a path may carry both an HTTP route and a WebSocket route without either shadowing the other.

A handler carrying @authenticated, @roles, @permissions or @authorize has it enforced before the handshake is accepted, using the same backend configure_auth() installed and reading the headers and cookies the handshake carries. A refused caller is closed with 1008 and never holds an accepted socket; a path with no WebSocket route is closed with 1000. Servers turn a pre-accept close into their own rejection.

A WebSocketDisconnect raised out of the handler is absorbed: the peer has already gone and there is nothing left to send.

walk_claims#

walk_claims(holders: Iterable[Any]) -> Iterator[tuple[Any, Any, Any]]

(holder, candidate, claim) for everything in holders that owns tables.

The one walk. Collected by asking: anything offering component() contributes, and a holder that delegates its tables answers schema_owners with whatever it delegated them to.

Holder and candidate are both carried because they are not always the same object -- a middleware delegates to a store, so the claim comes from the store while the declaration that named a database was made about the middleware. Wreath._schema_database consults each in turn.

Nothing deduplicates here; each caller owns the key by which it combines claims. Keeping the holder traversal here gives schema application and infrastructure inference the same ownership facts.

wreath.router#

Composable route modules flattened when their definitions are consumed.

This is the only public routing surface. A Router collects RouteDefinition records; an application consumes them with app.include_router(router), and the matcher that dispatches them is internal and not addressable from here.

from typing import Annotated from wreath.binding import Query from wreath.router import Router

llamas = Router(prefix="/llamas", tags=["llamas"])

@llamas.get("/") async def list_llamas( limit: Annotated[int, Query(minimum=1, maximum=100)] = 20, ) -> dict: return {"limit": limit}

Handler-parameter markers ride inside Annotated and the default stays an ordinary Python default — limit: Annotated[int, Query(...)] = 20, never limit: int = Query(20).

RouteDefinition#

One route and everything inherited into it, resolved and frozen.

This is what Router.routes yields and what an application consumes: a flat record with the prefixes already folded into path and the inherited middleware, tags, dependencies, and permissions already merged in. Nothing here is looked up again at request time, so reading a definition tells you exactly what the route will do.

Inherited entries come first in each tuple, outermost include first, with the route's own values last — the order they were applied in. A definition is immutable; build a changed copy with dataclasses.replace.

Args:

  • path — The full path, prefixes already applied.
  • methods — Uppercased HTTP methods this route answers.
  • endpoint — The handler function.
  • middleware — Route middleware, inherited first, then the route's own.
  • tags — Grouping tags for OpenAPI and generated clients, inherited first.
  • summary — One-line OpenAPI summary; the handler docstring becomes the description.
  • dependencies — Depends markers resolved before the handler runs.
  • requirement — The merged authentication and authorization requirement.
  • operation_id — Explicit client-facing operation id, or None to derive one.
  • response_only — Handler promises to return a response object directly.
  • cancel_on_disconnect — Cancel the handler when the client goes away, or
  • None to let the request method decide.
RouteDefinition(path: str, methods: tuple[str, ...], endpoint: Handler, middleware: tuple[Middleware, ...] = (), tags: tuple[str, ...] = (), summary: str | None = None, dependencies: tuple[Depends, ...] = (), requirement: AuthRequirement = AuthRequirement(authenticated=False, identify=False, role_checks=(), permission_checks=(), policies=(), second_factor=None, oauth_step_up=None, public=False), operation_id: str | None = None, response_only: bool = False, status_code: int = 200, response_description: str = 'Successful response', response_media_type: str = 'application/json', responses: tuple[tuple[int, Any], ...] = (), deprecated: bool = False, lifecycle_headers: tuple[tuple[bytes, bytes], ...] = (), include_in_schema: bool = True, security: tuple[tuple[str, tuple[str, ...]], ...] = (), name: str | None = None, host: str | None = None, cancel_on_disconnect: bool | None = None) -> None

Router#

A reusable collection of routes and inherited route metadata.

Routers are declarative modules, not request-time sub-applications. Including one takes a snapshot of its definitions and folds prefixes, middleware, dependencies, tags, and permissions into each resulting route.

Everything passed here is inherited by every route the router registers, and by every router it includes:

api = Router(prefix="/api", tags=["api"], permissions=["api::use"]) api.include_router(llamas, prefix="/v1")

A prefix must begin with /; a trailing slash is stripped, so "/api/" and "/api" are the same router and neither produces a doubled slash. The empty prefix means no prefix. permissions requires all of the named permissions and implies authentication; an empty permissions adds no requirement at all. Inheritance only ever adds — an included router cannot drop a permission, a middleware, or a dependency its parent imposed.

Args:

  • prefix — Path prefix for every route, beginning with a slash.
  • tags — Tags added to every route, for OpenAPI grouping and client generation.
  • middleware — Middleware wrapping every route, outermost first.
  • dependencies — Depends markers resolved before every handler in this router.
  • permissions — Permissions every route requires, all of them, implying authentication.
Router(*, prefix: str = '', tags: Iterable[str] = (), middleware: Iterable[Middleware] = (), dependencies: Iterable[Depends] = (), permissions: Iterable[str] = ()) -> None

delete#

delete(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a handler for path on DELETE; keywords are those of route().

get#

get(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a handler for path on GET; keywords are those of route().

include_router#

include_router(router: Router, *, prefix: str = '', tags: Iterable[str] = (), middleware: Iterable[Middleware] = (), dependencies: Iterable[Depends] = (), permissions: Iterable[str] = ()) -> None

Mount another router's routes inside this one.

The routes taken are the ones router holds at this moment: the entry stores a snapshot tuple, so anything registered on router afterwards will not appear here. Build a router fully, then include it.

Paths compose outside in. A route keeps the prefix of the router that declared it, then gains prefix, then this router's own prefix, so Router(prefix="/api").include_router(llamas, prefix="/v1") turns llamas's /{id} — already /llamas/{id} if llamas has that prefix — into /api/v1/llamas/{id}. Nesting composes the same way to any depth.

Tags, middleware, dependencies, and permissions are inherited: this router's own values, then the ones passed here, then whatever the included route already carried. Inheritance only adds — an included router cannot shed a permission or a middleware imposed above it, and permissions here requires all of the names it lists on every route mounted by this call.

Nothing is flattened yet. The composition happens when routes is read, which is why including a router is cheap and why the same router can be included at two prefixes without either copy affecting the other.

Args:

  • router — The router whose current routes are mounted.
  • prefix — Extra prefix between this router's prefix and the route path.
  • tags — Tags added to every mounted route.
  • middleware — Middleware added to every mounted route, outside the route's own.
  • dependencies — Depends markers added to every mounted route.
  • permissions — Permissions required on every mounted route, all of them.

Raises:

  • ValueError — The prefix is non-empty and does not begin with a slash.

patch#

patch(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a handler for path on PATCH; keywords are those of route().

post#

post(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a handler for path on POST; keywords are those of route().

put#

put(path: str, **metadata: Any) -> Callable[[Handler], Handler]

Register a handler for path on PUT; keywords are those of route().

query#

query(path: str, *, accept_query: Iterable[str] = ('application/json',), **metadata: Any) -> Callable[[Handler], Handler]

Register a safe, idempotent QUERY route with accepted content types.

route#

route(path: str, *, methods: Iterable[str], middleware: Iterable[Middleware] = (), tags: Iterable[str] = (), summary: str | None = None, dependencies: Iterable[Depends] = (), permissions: Iterable[str] = (), operation_id: str | None = None, response_only: bool = False, status_code: int = 200, response_description: str = 'Successful response', response_media_type: str = 'application/json', responses: Mapping[int, Any] | None = None, deprecated: bool = False, deprecated_at: datetime | None = None, sunset_at: datetime | None = None, deprecation_link: str | None = None, include_in_schema: bool = True, security: Mapping[str, Iterable[str]] | None = None, name: str | None = None, host: str | None = None, cancel_on_disconnect: bool | None = None) -> Callable[[Handler], Handler]

Register a handler for path under one or more methods.

This is the general form; get(), post(), put(), patch() and delete() are one-line wrappers over it, and any other method — HEAD, OPTIONS, TRACE — is reached by naming it here. Methods are uppercased, so methods=("get",) and methods=("GET",) are the same.

The decorator returns the handler unchanged, so a handler stays an ordinary callable and can be registered on more than one path or called directly from a test.

path must begin with a slash. A path of "/" on a prefixed router registers the prefix itself — Router(prefix="/llamas").get("/") binds /llamas, not /llamas/.

Handler parameters declare where each value is read from with a marker inside Annotated, keeping the default an ordinary Python default:

@router.get("/llamas/{id}") async def show( id: int, limit: Annotated[int, Query(minimum=1, maximum=100)] = 20, ) -> dict: ...

The router's own prefix, tags, middleware, dependencies, and permissions are applied at decoration time, and prefixes from any router that later includes this one are applied on top when routes is read. Everything passed here is added to what the router already imposes; nothing given here can remove it. permissions requires all of the names it lists and implies authentication.

Args:

  • path — Route path beginning with a slash, appended to the router prefix.
  • methods — HTTP methods to bind, uppercased for you.
  • middleware — Middleware for this route, after the router's, outermost first.
  • tags — Tags added after the router's tags.
  • summary — One-line OpenAPI summary; the handler docstring becomes the description.
  • dependencies — Depends markers resolved before this handler runs.
  • permissions — Additional permissions required, all of them.
  • operation_id — Explicit operation id; when omitted one is derived from method and path.
  • response_only — Promise that the handler returns a response object directly.
  • deprecated — Mark the operation deprecated in OpenAPI.
  • deprecated_at — RFC 9745 effective date, emitted as Deprecation.
  • sunset_at — Expected shutdown date, emitted as Sunset.
  • deprecation_link — URI-reference for rel="deprecation" documentation.
  • cancel_on_disconnect — Cancel this handler when the client goes away.
  • Omitted, the request method decides — safe methods are cancelled,
  • unsafe ones are not.

Returns:

  • A decorator that registers the handler and returns it unchanged.

Raises:

  • ValueError — The path does not begin with a slash.

routes (property)#

routes: tuple[RouteDefinition, ...]

Every route this router owns, flattened, with all inheritance applied.

Included routers are folded in here rather than at include_router() time, so this is where a nested prefix becomes a full path and a parent's tags, middleware, dependencies, and permissions land on a child's route. The result is computed on each access and is a fresh tuple; hold it in a local when iterating it more than once.

Routes appear in registration order, with an included router's routes at the position it was included.

websocket#

websocket(path: str, *, permissions: Iterable[str] = ()) -> Callable[[Callable[[Any], Awaitable[None]]], Callable[[Any], Awaitable[None]]]

Register a reusable WebSocket route.

Router-level and route-level permissions are enforced during the handshake when the router is included in an application. Message-loop flow control remains the handler's choice; WebSocketService is the bounded generic manager when it needs framework ownership.

websockets (property)#

websockets: tuple[WebSocketRouteDefinition, ...]

WebSocket routes owned by this router, including mounted snapshots.

WebSocketRouteDefinition#

One reusable WebSocket route with inherited access requirements.

WebSocketRouteDefinition(path: str, endpoint: Callable[[Any], Awaitable[None]], requirement: AuthRequirement = AuthRequirement(authenticated=False, identify=False, role_checks=(), permission_checks=(), policies=(), second_factor=None, oauth_step_up=None, public=False)) -> None

wreath.binding#

Typed handler binding and validation, compiled at startup.

A handler that takes only request runs exactly as before — binding costs nothing unless a signature asks for more. Extra parameters are bound by inspecting the signature once at route-compile time (never per request):

  • a name matching a path placeholder becomes a path parameter, converted to the annotated scalar type,
  • a parameter annotated with a dataclass or a mapped wreath.orm model receives the validated JSON body,
  • every remaining scalar-annotated parameter comes from the query string, falling back to the handler default when absent.

The same compiled validator understands dataclasses, unions, literals, enums, UUIDs, decimals, dates and instants, bytes, mappings, and list/set/tuple containers. Annotated[..., Field(...)] supplies a wire alias, documentation and numeric, length, or pattern constraints. A supported return annotation is also a response contract: undeclared dataclass attributes are filtered and an invalid result is refused before emission.

@dataclass
class NewItem:
    name: str
    price: float
    tags: list[str] = field(default_factory=list)

@app.post("/items/{item_id}")
async def create(request, item_id: int, item: NewItem, dry_run: bool = False):
    ...

Path, Query, Header, Cookie, Body, Form and File override that inference. A marker rides in Annotated; the default stays a plain Python default — write limit: Annotated[int, Query()] = 20, never limit: int = Query(20). Markers are read only from Annotated metadata, so a marker written as a default is not recognised as a marker at all: Query(20) would become the literal default value, every alias and bound on it ignored, and a request that omits the parameter would hand the marker object itself to the handler. It is therefore refused when routes compile — a startup TypeError naming the parameter and the form to write instead, rather than a wrong value at request time. Depends is the one exception; it is written as the default, not in Annotated, and is not refused.

Every binding failure raises ValidationError, and the application's error boundary answers RFC 9457 application/problem+json — status 422, detail "Request validation failed", and an errors member carrying one {"loc", "msg", "type"} object per failure. There is no {"detail": [...]} body; loc names the source first ("path", "query", "header", "cookie", "form", "file" or "body") and then the field path. Wreath.set_validation_formatter replaces that shaping. A request body that is not JSON at all is a 400 instead, because nothing could be validated.

BindingSpec#

The compiled shape of a typed handler signature.

Produced once by inspect_handler at route-compile time and consumed by compile_binder, by the OpenAPI generator, and by typegen — so a handler's signature is read once and three subsystems agree on what it said.

Each scalar entry is (parameter_name, wire_name, annotation) for a path parameter and (parameter_name, wire_name, annotation, default) elsewhere, where default is inspect.Parameter.empty when the parameter is required. An annotation here is the base type with Annotated already stripped.

Args:

  • path_params — Placeholders bound from the matched path.
  • query_params — Parameters bound from the query string.
  • header_params — Parameters bound from request headers.
  • cookie_params — Parameters bound from request cookies.
  • form_params — Scalar parameters bound from individual form fields.
  • file_params — Parameters bound to uploaded multipart file parts.
  • body — The (parameter_name, annotation) bound from the JSON body, or None.
  • returns — The handler's return annotation, or inspect.Parameter.empty.
  • depends — (parameter_name, Depends) for each injected dependency.
  • connections — (parameter_name, FromDatabase) for each injected connection.
  • sessions — (parameter_name, FromORM) for each injected ORM session.
  • query_constraints — (parameter_name, (minimum, maximum, overflow)) ranges.
  • form_model — The (parameter_name, model) bound whole from a form, or None.
BindingSpec(path_params: tuple[tuple[str, str, Any], ...], query_params: tuple[tuple[str, str, Any, Any], ...], header_params: tuple[tuple[str, str, Any, Any], ...], cookie_params: tuple[tuple[str, str, Any, Any], ...], form_params: tuple[tuple[str, str, Any, Any], ...], file_params: tuple[tuple[str, str, Any, Any], ...], body: tuple[str, Any] | None, returns: Any, depends: tuple[tuple[str, Any], ...] = (), connections: tuple[tuple[str, Any], ...] = (), sessions: tuple[tuple[str, Any], ...] = (), query_constraints: tuple[tuple[str, ScalarConstraint], ...] = (), form_model: tuple[str, Any] | None = None) -> None

Body#

Bind a parameter to the whole decoded JSON request body.

Written in Annotated, never as a default — Annotated[list[int], Body()]. A dataclass or mapped ORM model already binds the body without any marker; Body() is what makes a non-dataclass annotation — a list, a dict, a scalar — the body rather than a query parameter. The payload is checked against the annotation by a validator compiled once at route-compile time, so a handler annotated with a dataclass or model receives a constructed instance.

A handler declares at most one body parameter and cannot combine a body with Form() or File() parameters; either is a TypeError when routes compile. A body that is not valid JSON is a 400, not a 422, because nothing could be validated against the annotation.

Args:

  • alias — Accepted for symmetry and ignored — a body has no name to look up.
Body(alias: str | None = None) -> None

Bind a parameter to a request cookie.

Written in Annotated, never as a default — Annotated[str, Cookie()]. Cookie names are matched exactly, case included, and the name is used literally — no underscore-to-hyphen rewriting. An absent cookie falls back to the handler default; with no default the request fails with 422 and a loc of ["cookie", name].

Reading any cookie parses the whole Cookie header, which is refused with a 431 when it exceeds the configured max_cookie_bytes.

Args:

  • alias — Cookie name to read, when it differs from the parameter name.
Cookie(alias: str | None = None) -> None

AppScope#

Values that outlive a request, owned by one application.

Backs Depends(..., scope="app"). Explicitly owned rather than global: the container hangs off the Wreath instance, is passed into binder compilation, and is torn down by that app's lifespan shutdown. Two apps in one process do not share values. A new container is empty and constructs nothing until a request first asks for a value; fn in scope reports whether the dependency fn has resolved yet.

First use resolves; later uses read. A burst of concurrent first requests constructs once, not once per request in flight.

Single-flighting is per key, not per container. A container-wide lock deadlocks the moment one app-scoped dependency resolves another: the outer factory holds the lock and the inner one waits for it forever. Per-key in-flight futures let a nested chain resolve while still collapsing concurrent callers of the same key. (Recursion through the same key would still hang, but that is a dependency cycle, and _compile_dep rejects those at compile time.)

AppScope() -> None

aclose#

async aclose() -> None

Resume every app-scoped generator for cleanup, innermost first.

Every leg runs even when one fails, so a single bad teardown cannot strand the rest. The first failure is re-raised once all have run.

get_or_create#

async get_or_create(fn: Callable[..., Any], factory: Callable[[], Awaitable[Any]]) -> Any

Return the value held for fn, constructing it once if it is absent.

Concurrent callers for the same key collapse onto a single factory call and all receive its result; callers for different keys never block each other, so one app-scoped dependency can resolve another without deadlocking. A factory that raises is not cached and its exception reaches every waiter, so the next request retries construction.

Args:

  • fn — Cache key — the dependency callable itself, not the factory.
  • factory — Zero-argument coroutine function that builds the value.

Returns:

  • The value for fn, built by this call or by an earlier one.

track_cleanup#

track_cleanup(generator: Any) -> None

Register a started app-scoped async generator for shutdown cleanup.

Called by the resolver for a Depends(..., scope="app") async generator once its first yield has produced the value. Cleanup is held on the container rather than on the request that happened to construct the value, because that request ends long before the value does; aclose resumes everything tracked here at lifespan shutdown.

Args:

  • generator — A started async generator awaiting its cleanup resume.

Depends#

Marks a handler parameter as provided by a dependency callable.

Alone among the markers, Depends is written as the parameter's default — session = Depends(get_session) — not inside Annotated.

async def get_session(request):
    session = Session()
    try:
        yield session
    finally:
        await session.close()

@app.get("/items")
async def items(request, session = Depends(get_session)):
    ...

The callable takes request first, plus its own Depends parameters, resolved recursively. Every other parameter of a dependency must be a Depends or carry a default, and a cycle in the graph is a TypeError naming the cycle when routes compile. A plain function, a coroutine function, and an async generator function are all accepted; a synchronous dependency runs inline on the event loop and is never moved to a thread, so a blocking one blocks the request. An async generator yields the value and is resumed once for cleanup after the handler returns — on success, on error, and on cancellation alike — innermost first. A plain, non-async generator gets no cleanup: its generator object is handed to the handler as the value.

Dependencies resolve per request, after path, query, header, cookie and body binding and after any injected connection or ORM session. With use_cache (the default) a callable resolves at most once per request, keyed by callable and scope, so a diamond in the graph costs one call rather than two, and the same factory used at both scopes stays two distinct values.

scope="request", the default, resolves per request and cleans up when the handler returns. scope="app" resolves once, on first use, and shares that value with every later request; an async generator's cleanup then runs at lifespan shutdown instead. Use it for something expensive and stateless — a client, a compiled ruleset, a warmed lookup table. An app-scoped value is held by the application's AppScope whatever use_cache says; that flag governs only the per-request cache. App scope is reachable only through a route on a Wreath application — compiling one without a container is a TypeError.

An app-scoped dependency may not depend on a request-scoped one: the value would outlive the request it was built from. That is a compile-time error, raised when routes compile, not a surprise on request 2.

Args:

  • fn — The dependency callable, taking request and its own dependencies.
  • use_cache — Reuse one resolved value across a single request. Default True.
  • scope — Lifetime of the value, "request" or "app". Default "request".

Raises:

  • ValueError — scope is neither "request" nor "app".
Depends(fn: Callable[..., Any], *, use_cache: bool = True, scope: str = 'request') -> None

Field#

Describe and constrain one value inside Annotated.

The same metadata drives request validation, response filtering, OpenAPI, MCP, and generated clients. alias changes the wire name of a dataclass field while the Python attribute keeps its declared name.

Field(alias: str | None = None, description: str | None = None, examples: tuple[Any, ...] = (), gt: int | float | Decimal | None = None, ge: int | float | Decimal | None = None, lt: int | float | Decimal | None = None, le: int | float | Decimal | None = None, min_length: int | None = None, max_length: int | None = None, pattern: str | None = None) -> None

File#

Bind a parameter to an uploaded multipart file part.

Written in Annotated, never as a default — Annotated[UploadedFile, File()]. The handler always receives a wreath.request.UploadedFile; the annotation documents intent and is never used to convert or validate the part. A multipart part carrying no filename is a form field, not a file, and binds with Form() instead.

A missing upload with no handler default fails with 422 and a loc of ["file", name].

Args:

  • alias — Part name to read, when it differs from the parameter name.
File(alias: str | None = None) -> None

Form#

Bind a parameter to a multipart or urlencoded form field.

Written in Annotated, never as a default — Annotated[str, Form()]. A scalar-annotated parameter takes one form field, converted to the annotated type. A parameter annotated with a dataclass or a mapped ORM model instead binds that whole model from the form fields and runs the same validator a JSON body would use, so a form-posted model accepts exactly the shapes a JSON-posted one does. A whole-model form rejects any field the model does not declare, and ignores alias.

File parts are never model fields — bind an upload with a separate File() parameter. A handler declares at most one form model, cannot mix a form model with individual Form() fields, and cannot combine either with a body parameter; each is a TypeError when routes compile. A missing field with no handler default fails with 422 and a loc of ["form", name].

Args:

  • alias — Form field name to read, when it differs from the parameter name.
Form(alias: str | None = None) -> None

Bind a parameter to a request header.

Written in Annotated, never as a default — Annotated[str, Header()]. The lookup is case-insensitive and refuses repeated values rather than choosing whichever one a proxy or application happens to read first. The name is used literally. Unlike FastAPI, wreath does not rewrite user_agent into user-agent, so any header whose name is not a Python identifier needs an explicit alias.

An absent header falls back to the handler default; with no default the request fails with 422 and a loc of ["header", name].

Args:

  • alias — Header name to read, when it differs from the parameter name.
Header(alias: str | None = None) -> None

HandlerParameter#

One resolved callable parameter shared by non-HTTP protocol adapters.

HandlerParameter(name: str, annotation: Any, default: Any) -> None

Path#

Bind a parameter to a path placeholder.

Written in Annotated, never as a default — Annotated[int, Path()]. A parameter whose name already matches a placeholder binds without this marker; Path("item_id") is what lets the handler call it something else.

A path parameter is always required and its handler default is never consulted, because the router only matches a request that supplied every placeholder. Naming a placeholder the route path does not contain is a TypeError when routes compile, not a 404 at request time. A value that does not convert to the annotated type fails with 422 and a loc of ["path", name].

Args:

  • alias — Placeholder name to read, when it differs from the parameter name.
Path(alias: str | None = None) -> None

Query#

Bind a parameter to a query-string value, optionally with a numeric range.

Written in Annotated, never as a default — Annotated[int, Query()] = 20. Writing limit: int = Query(20), which is FastAPI's spelling, is a TypeError when routes compile: it binds nothing, so accepting it would ignore the bounds below and hand the Query object itself to the handler as the value. Every marker below refuses the same way.

A repeated key is refused rather than choosing whichever value a proxy or application happens to read first. An absent parameter falls back to the handler default before any bound is considered; with no default it fails with 422 and a loc of ["query", name]. Invalid syntax stays an error whatever overflow says, because a range applies only to a value that parsed. minimum and maximum apply only to an int or float parameter; a range on any other annotation is a TypeError when routes compile.

Args:

  • alias — Query key to read, when it differs from the parameter name.
  • minimum — Inclusive lower bound. None leaves the value unbounded below.
  • maximum — Inclusive upper bound. None leaves the value unbounded above.
  • overflow — "error" for a 422 on an out-of-range value, "clamp" to pin it.

Raises:

  • ValueError — overflow is neither "error" nor "clamp", or minimum exceeds maximum.
Query(alias: str | None = None, minimum: int | float | None = None, maximum: int | float | None = None, overflow: str = 'error') -> None

ResponseValidationError#

A handler returned a value that violates its declared public contract.

This is deliberately distinct from ValidationError: bad request data is a caller error and answers 422, while a bad response is an application defect and passes through the ordinary 500 boundary.

ResponseValidationError(errors: list[dict[str, Any]]) -> None

ValidationError#

The field errors collected from one request, converted to a 422.

Raised by validate, by path/query/header/cookie/form scalar conversion, by a numeric Query range, and by the compiled ORM model validators. The application's error boundary catches it and answers RFC 9457 application/problem+json with status 422, detail "Request validation failed", and an errors member holding this list verbatim. Wreath.set_validation_formatter reshapes that body; Wreath.add_exception_handler(ValidationError, ...) replaces the response outright.

The collected list is available as the errors attribute. str(error) is only a count summary — everything a client needs is in errors.

Args:

  • errors — One dict per failure, with keys loc, msg and type.
ValidationError(errors: list[dict[str, Any]]) -> None

compile_binder#

compile_binder(handler: Handler, path: str, *, databases: Mapping[str, Any] | None = None, orm_registries: Mapping[str, Any] | None = None, dependencies: tuple[Depends, ...] = (), binding_spec: BindingSpec | None | object = <object object>, requestless: bool | None = None, app_scope: AppScope | None = None) -> Handler

Wrap handler so its typed parameters are bound on each request.

Signature inspection, validation tapes, dependency graphs, and named resource lookups all finish here. A request-only handler is returned unchanged. A path-only handler uses the native adapter, and handlers without leased resources use a lean wrapper without acquire/release machinery.

Args:

  • handler — The endpoint callable to wrap.
  • path — The route path, including its placeholders.
  • databases — Configured PostgreSQL databases by name.
  • orm_registries — Configured ORM registries by name.
  • dependencies — Route dependencies resolved for side effects.
  • binding_spec — A previously inspected binding specification.
  • requestless — Whether the handler accepts no request argument.
  • app_scope — Container for application-scoped dependencies.

Returns:

  • The narrowest compiled wrapper, or the original unbound handler.

compile_message_negotiation#

compile_message_negotiation(handler: Handler, annotation: Any) -> Handler

Offer protobuf on a route whose return annotation is a @message.

Wreath has read protobuf request bodies since _decode_protobuf_body landed, and could not write one: a handler returning a declared message served JSON whatever the client asked for. That asymmetry was not a decision, it was the half that had not been done.

wreath.negotiation keeps PROTOBUF out of DEFAULT_SERIALIZERS for a reason that stands: JSON and MessagePack encode whatever a handler returns, protobuf can only encode a declared message, and a handler returning a dict is the common case — so a global offer would turn every existing route into a runtime error for any client sending Accept: application/x-protobuf.

The return annotation is the fact that was missing. A route annotated -> Ping has said at startup that its body is encodable, so the offer is made for that route and nowhere else. A route without one is returned unchanged and pays nothing at all — not even a branch.

This is compiled before compile_response_validator, because that validator projects a value onto JSON primitives and the encoder needs the message itself. A Response already passes through the validator untouched, which is how the negotiated result reaches the wire without a second path.

compile_response_validator#

compile_response_validator(handler: Handler, annotation: Any) -> Handler

Filter and validate a handler's annotated return value.

Response objects retain full control of their wire representation. Plain values are projected onto the annotation (dropping undeclared dataclass fields), validated, and converted to dependency-free JSON primitives.

convert_parameter#

convert_parameter(annotation: Any, value: Any, *, loc: tuple[Any, ...]) -> Any

Convert one protocol string with the same scalar rules as HTTP binding.

inspect_handler#

inspect_handler(handler: Handler, path: str, host: str | None = None, *, signature: inspect.Signature | None = None) -> BindingSpec | None

Read a handler signature once and resolve every parameter to a source.

Called at route-compile time, never per request. Markers in Annotated decide the source when present; otherwise a parameter matching a path placeholder is a path parameter, a dataclass or mapped ORM model annotation is the JSON body, and everything else is a query parameter. The first parameter is always the request and is never bound.

Every conflict a signature can express is refused here rather than at request time — *args/**kwargs, two body parameters, a body mixed with form or file parameters, a form model mixed with individual form fields, a numeric range on a non-numeric parameter, a Path alias absent from the route path, and a bare Session that does not say which registry it wants.

Args:

  • handler — The endpoint callable, whose first parameter is the request.
  • path — The route path, including its {placeholder} segments.

Returns:

  • The spec, or None for a request-only handler or an uninspectable object.

Raises:

  • TypeError — The signature is self-contradictory or cannot be bound.

inspect_parameters#

inspect_parameters(handler: Handler) -> tuple[HandlerParameter, ...]

Resolve a callable signature once without assigning HTTP wire sources.

validate#

validate(annotation: Any, value: Any, loc: tuple[Any, ...] = ()) -> Any

Validate a decoded-JSON value against annotation.

Body validation for the shapes the flat plan cannot express -- recursive dataclasses, mostly. Everything else compiles to a plan and is checked in C by _core.run_validation, with identical semantics and identical error ordering.

Understood annotations are the scalars str, int, float and bool, Any, None, list[T], tuple[T, ...] (from list input), dict[str, T], unions including Optional, and dataclasses, recursively. Anything else is reported as an unsupported error rather than passed through. A JSON number binds to float whether or not it was written with a decimal point, but bool never satisfies int or float. A dataclass rejects a field it does not declare and reports each missing required field. Every error in the whole value is collected before raising, not just the first.

Validation is bounded to two million node visits, because a nest of unions re-explores each branch at every level and would otherwise let a small body cost exponential work. Exceeding it aborts with one too_complex error at the root and constructs nothing from the truncated result.

Args:

  • annotation — The type to check against, as it appears on the parameter.
  • value — A decoded JSON value — dicts, lists, strings, numbers, bools, None.
  • loc — Path prefix for reported errors, such as ("body",).

Returns:

  • The validated value, with dataclasses constructed and ints widened to floats.

Raises:

  • ValidationError — The value did not match, or the step budget ran out.

wreath.request#

The incoming HTTP request, read lazily from an ASGI scope.

Request keeps the scope and the receive channel and parses nothing until it is asked. Method, path, scheme, client, query string and the raw header list are reads of what the server already delivered, so they cost nothing to touch. The cookies, the body, the decoded JSON and the parsed form are produced on first access and cached on the request, so a middleware, a dependency and the handler that each read the body share one read of it.

RequestLimits bounds what a single request may buffer in memory before it is refused. An application owns one and hands it to every request it builds.

DEFAULT_LIMITS (value)#

DEFAULT_LIMITS: RequestLimits = RequestLimits(max_body_bytes=16777216, max_parts=1024, max_part_header_bytes=16384, max_part_bytes=8388608, max_form_memory_bytes=16777216, spool_max_bytes=1048576, max_cookie_bytes=16384, max_form_fields=1024)

FormData#

Parsed form fields plus uploaded files, first value wins per name.

The mapping protocol -- form[name], get(), in, len(), iteration -- covers fields only: the text parts, decoded as UTF-8 with undecodable bytes replaced rather than raising. Parts that carried a filename are in files, keyed by field name, and are invisible to the mapping and to getlist(). A name submitted twice keeps its first value in fields, and the rest stay reachable through getlist().

Built by Request.form() and cached on the request, so every reader of a request shares one parse and one set of spooled uploads. Nothing closes those for you; see close().

Args:

  • all_values — every value per name, in order, defaulting to one per field
FormData(fields: dict[str, str], files: dict[str, UploadedFile], all_values: dict[str, list[str]] | None = None) -> None

close#

close() -> None

Release every spooled upload this form holds. Idempotent.

The framework never calls this: a form is cached on the request and the request does not know when the last reader is finished with it. Left unclosed, a spool survives until the UploadedFile is collected, so a handler that is done with its uploads closes the form to give the temporary files back at a moment it chooses. Fields stay readable afterwards; the uploads do not.

get#

get(name: str, default: str | None = None) -> str | None

The first value submitted under name, or default when absent.

Text fields only, like the rest of the mapping protocol -- an uploaded file is not found here even when a part of that name was submitted. Read files for those.

getlist#

getlist(name: str) -> list[str]

Every value submitted under name, in order. Empty when there is none.

fields keeps the first, which is what most handlers want and what the binding layer reads. The rest were being dropped with no way to see them -- and a repeated field is exactly where an upstream proxy or WAF may have read a different value than the application will.

Text fields only. A repeated file field keeps its first upload in files and the others are not retained.

Request#

A deliberately small request object backed directly by an ASGI scope.

Construction parses nothing. method, path, scheme, client, query_string and headers hand back what the server already delivered. cookies, body(), json() and form() each parse on first access and cache the result on the request, so a middleware, a dependency and the handler that all read the body pay for one read between them. state and path_params allocate their container on first read and not before.

Behind wreath's own server there is no ASGI scope dict until something reads scope: the properties come off a native request context, and reading one header does not materialise the scope.

A request belongs to the task handling it. Nothing here takes a lock, so two tasks awaiting body() on the same request at the same time race for the receive channel.

Args:

  • scope — an ASGI HTTP scope dict, or wreath's native request context
  • receive — the ASGI receive callable, awaited only by body()
  • path_params — the router's decoded path parameters
  • limits — what this request may buffer in memory before it is refused
Request(scope: Any, receive: Receive, path_params: dict[str, str] | None = None, limits: RequestLimits = RequestLimits(max_body_bytes=16777216, max_parts=1024, max_part_header_bytes=16384, max_part_bytes=8388608, max_form_memory_bytes=16777216, spool_max_bytes=1048576, max_cookie_bytes=16384, max_form_fields=1024), app: Any = None) -> None

authenticated (property)#

authenticated: bool

Whether an Identity has been established for this request.

Exactly identity is not None, with the same caveat: False means nothing has authenticated this request yet, not that the client is anonymous.

body#

async body() -> bytes

The whole request body as bytes, read once and cached.

The first call drains stream() and caches the result; every call after it returns the same object without touching the channel, so middleware, a dependency and the handler can each ask for the body. This method materialises the body in full; use stream() to process chunks without retaining them. Both paths are bounded by RequestLimits.max_body_bytes, checked as chunks arrive rather than after joining them.

A request that is refused or cut short caches an empty body, so a second call returns b"" instead of raising again. The channel is already spent by then and re-reading it would hand back a truncated payload; the caller that saw the exception is the one that knows.

Raises:

  • PayloadTooLarge — the body exceeds RequestLimits.max_body_bytes
  • ClientDisconnect — the peer went away before the body finished
  • BadRequest — a covered Content-Digest does not match these bytes.
  • See _check_body -- ingress cannot do this check, because the
  • body has not arrived when global middleware runs.

client (property)#

client: tuple[str, int | None] | None

The peer's (host, port), or None when the server did not report one.

The socket peer, which behind a load balancer or a reverse proxy is the proxy and not the caller. X-Forwarded-For is deliberately ignored here -- any client can send it. Add ProxyPolicy with the proxy networks you trust and it rewrites this from the forwarded header, in which case the port is None because no forwarding header carries it. Everything that buckets by caller, rate limiting included, reads this.

client_source (property)#

client_source: str

Why client is trusted: socket or forwarded.

forwarded is set only when ProxyPolicy accepted the immediate socket peer and successfully parsed its forwarding chain. Merely sending an X-Forwarded-For header never changes this value.

cookies (property)#

cookies: dict[str, str]

The Cookie header parsed into a dict. Empty when there is none.

Parsed on first read and cached, so the session, the CSRF check and a bearer-cookie backend share one parse. A cookie sent twice keeps its first value. Split Cookie header lines are combined with ; as HTTP/2 requires. Values are the octets as they arrived, decoded latin-1 -- neither unquoted nor percent-decoded, because a cookie is bytes and only its writer knows how it was encoded.

A Cookie header longer than RequestLimits.max_cookie_bytes is refused rather than parsed, and refused again on every subsequent read -- the failure is not cached, so a later reader sees the same 431 and not an empty dict.

Raises:

  • RequestHeaderFieldsTooLarge — the header exceeds max_cookie_bytes

event (property)#

event: Any

This request's canonical log line, for attaching your own fields.

One structured record per request carries what the recorder already knows -- route, status, timings, trace and span ids -- and this is how application code adds to it:

request.event.set("tenant_id", tenant.id)
request.event.set("cache", "miss", raw=True)

Fields follow the same deny-by-default rule as log arguments: a scalar is written, a string is fingerprinted unless raw=True. promote() publishes this request's buffered TRACE/DEBUG records even though it succeeded, for an anomaly the framework cannot see.

Outside a configured recorder this returns an inert stand-in, so the call is always safe and never needs a guard.

form#

async form() -> FormData

Parse a urlencoded or multipart request body. Parsed once and cached.

A multipart/form-data content type is parsed as multipart, and application/x-www-form-urlencoded is parsed as urlencoded. A missing or different media type is refused before the body is read. Uploaded parts, meaning the ones carrying a filename, land in FormData.files; every other part is a text field.

Multipart input drains stream() incrementally, so the body limit and form limits are checked while data arrives: max_parts, max_part_header_bytes, max_part_bytes, max_form_memory_bytes across the parts kept in memory, and max_form_fields while scanning a urlencoded body. A part over spool_max_bytes is written to a temporary file and does not count against the in-memory total. Every one of those limits refuses with a 413: a limit that exists to refuse hostile input has to report the caller's fault as the caller's, not as a 500.

A body the parser cannot read -- a bad boundary, an unterminated part, a malformed header line -- is a different failure and still raises ValueError.

The parsed form is cached on the request, so its spooled uploads live until FormData.close() is called or the request is collected.

Raises:

  • ValueError — the multipart body is malformed
  • PayloadTooLarge — the body, the parts, or the field count exceed a limit
  • ClientDisconnect — the peer went away before the body finished

header#

header(name: str | bytes, default: str | None = None) -> str | None

One header by name, decoded, or default when it was not sent.

name may be str or bytes and is matched case-insensitively -- it is lowercased here, and ASGI servers deliver names already lowercased. A header sent more than once yields its first value; read headers to see the rest. The value is decoded latin-1, which is what HTTP header octets are, so a UTF-8 header value arrives mojibaked unless you re-encode it.

The first lookup on a request scans the header list; the second builds a name index, and every lookup after that is a dict hit.

Args:

  • name — header name, in any case
  • default — returned when the header is absent

headers (property)#

headers: list[tuple[bytes, bytes]]

The raw ASGI header list -- (name, value) pairs of bytes, in order.

Names arrive lowercase, as ASGI requires of servers, and a header sent more than once appears more than once here. No copy is made: this is the list the request is backed by, so appending to it or rewriting an entry changes what every later reader sees -- except that it does not update the name index header() may already have built, which keeps returning the old value. Use header() to read one by name; it decodes the value and resolves duplicates.

identity (property)#

identity: Identity | None

Who the pipeline authenticated this request as, or None.

A plain read of what the pipeline stored. Reading it never runs the authentication backend, and it is None until the backend has run -- which happens only for a route that requires authentication, so a public route sees None even when the client sent perfectly good credentials. Before the identity stage hook it is None on every request.

json#

async json() -> Any

The body decoded as JSON, decoded once and cached.

Reads body(), so the same limits and the same read-once rule apply, and the decoded value is cached separately -- two handlers asking for the JSON share one decode. The Content-Type is not consulted: a body that parses as JSON is returned whatever the client called it.

A malformed body raises on every call, since nothing is cached until a decode succeeds. Note that this is a bare ValueError, which the pipeline reports as a 500. A handler that declares a body parameter instead of reading this gets the binding layer's 400 with the decoder's message.

Raises:

  • ValueError — the body is not valid JSON
  • PayloadTooLarge — the body exceeds RequestLimits.max_body_bytes
  • ClientDisconnect — the peer went away before the body finished

locale (property)#

locale: str

The caller's preferred language tag, or "en".

Read from Accept-Language, highest q first, with ties going to the tag the client listed earlier. This is what wreath.temporal.relative() takes, so "3 hours ago" can be translated later without finding every call site that renders one.

Deliberately forgiving: the header is attacker-controlled and a malformed one must never fail a request, so anything unparseable falls back to the default rather than raising. Only the tag is returned -- no negotiation against a list of supported languages, because the caller knows which those are and this does not.

Computed on every read from header(), which caches the header index but not this result.

method (property)#

method: str

The HTTP method, uppercase, as the server parsed it.

A field read on either backing. Nothing is parsed or cached, and the value never changes for the life of the request.

path (property)#

path: str

The percent-decoded request path, without the query string.

A field read on either backing -- nothing is parsed or cached. It is the whole path the server delivered, prefixes included; the undecoded form is in scope["raw_path"] when the server supplied one.

path_params (property)#

path_params: dict[str, str]

The path parameters the router captured, as raw strings.

Values are undecoded from the router's point of view -- conversion to the types a handler declared is the binding layer's job, and a handler reading this dict directly gets strings. Empty for a route with no parameters, and for a request that never matched one; the dict is allocated on the first read that finds none and then cached, so mutating it is a way to pass a value along the rest of the request.

preferred_content_digest#

preferred_content_digest(*supported: str) -> str | None

Best supported Want-Content-Digest algorithm, or None for no usable hint.

preferred_repr_digest#

preferred_repr_digest(*supported: str) -> str | None

Best supported Want-Repr-Digest algorithm, or None for no usable hint.

query_string (property)#

query_string: bytes

The raw query string, undecoded, without the ?. Empty when absent.

Bytes, and never parsed here: a field read on either backing, with no cache because there is nothing to cache. Handlers take query parameters through Query markers rather than reading this; it is here for the cases that need the bytes exactly as they arrived.

scheme (property)#

scheme: str

The request scheme, "http" or "https", defaulting to "http".

The scheme of the connection this process accepted. Behind a TLS-terminating proxy that connection is plaintext, so this reads "http" for a request the browser made over HTTPS -- which silently disables HSTS and breaks CSRF's origin check. ProxyPolicy, configured with the proxy networks you trust, restores it from X-Forwarded-Proto; nothing else honours that header.

scope (property)#

scope: dict[str, Any]

The ASGI scope dict for this request.

Behind a third-party server this is the dict the server passed in, so a write is visible to anything else holding it. Behind wreath's own server there is no dict until this is read, and the first read builds one and caches it on both the request and the native context -- which is why the properties above exist: each reads one field without paying for that.

Reach for it for the parts of ASGI wreath does not surface, such as root_path or extensions. To override the peer or the scheme, use ProxyPolicy; writing to this dict after something has read a property does not update the native context behind it.

state (property)#

state: State

Per-request scratch space, shared by middleware, hooks and handlers.

A State namespace scoped to this request and thrown away with it; the application-wide one is app.state. Allocated on first read and cached, so a request whose hooks never touch it never builds one, and the routing outcome the pipeline recorded is copied in when it is built.

stream#

stream() -> AsyncIterator[bytes]

Yield request-body chunks directly from the ASGI receive channel.

Streaming enforces max_body_bytes as bytes arrive but retains no complete body. It is one-shot: after a streamed body completes (or a consumer stops early), body(), json(), form(), and a second stream raise StreamConsumed. If body() ran first, its cached bytes are replayed once without touching the receive channel.

A Content-Digest a signature covered is hashed incrementally here. Those uncommon signed bodies are retained up to max_body_bytes and yielded only after the last chunk matches, so a handler cannot act on bytes whose authenticated digest has not been established. Ordinary unsigned streams retain no complete body.

Raises:

  • BadRequest — a covered Content-Digest does not match these bytes.

url_for#

url_for(name: str, **parameters: Any) -> str

Build an absolute URL for a named route.

A host-specific route renders its declared host placeholders. Other routes use the request's Host header, falling back to the ASGI server address when the header is absent.

url_path_for#

url_path_for(name: str, **parameters: Any) -> str

Build a path for a route named on this request's application.

verify_content_digest#

async verify_content_digest(*, required: bool = False) -> str | None

Verify RFC 9530 Content-Digest against this request's message content.

Returns the active algorithm used, None when the field is absent and optional, and raises 400 for a required, malformed, unsupported, or mismatching field. This reads and caches the body; call it after binding for ordinary buffered APIs rather than before a one-shot stream().

verify_repr_digest#

async verify_repr_digest(representation: bytes | None = None, *, required: bool = False) -> str | None

Verify Repr-Digest against complete selected representation data.

representation defaults to this message's content, which is correct when the request encloses the complete selected representation. Supply the reconstructed representation for partial or otherwise transformed messages.

RequestLimits#

What one request may buffer in memory before it is refused.

These bound the framework side of a request, and so apply behind any conforming ASGI server -- wreath.server's own limits stop a body at the socket, but Uvicorn and friends will hand over whatever arrives. They are enforced while receiving, before an oversized body is joined or a part is copied out of it.

Every one of them refuses with a status a client can act on. All but one raise PayloadTooLarge (413); max_cookie_bytes raises RequestHeaderFieldsTooLarge (431), because the fix is to send fewer cookies rather than a smaller body. The multipart parser limits -- max_parts, max_part_header_bytes, max_part_bytes -- are enforced inside the codec, which raises ValueError; form() converts those three to 413. A body the parser cannot read is a different failure and still raises ValueError.

Every limit must be positive; constructing one with a zero or negative value raises ValueError.

Defaults are conservative but chosen not to break working applications, and are a pre-1.0 decision: they may tighten before 1.0. Raise them on the application (Wreath(limits=...)) rather than working around them.

Args:

  • max_body_bytes — total bytes Request.body() buffers before it refuses
  • max_parts — parts one multipart form may contain
  • max_part_header_bytes — header-block bytes allowed per multipart part
  • max_part_bytes — bytes one multipart part may hold in memory
  • max_form_memory_bytes — aggregate in-memory bytes across all retained parts
  • spool_max_bytes — part size past which the payload spools to a temporary file
  • max_cookie_bytes — bytes the Cookie header may carry before it refuses
  • max_form_fields — fields one urlencoded body may contain
RequestLimits(max_body_bytes: int = 16777216, max_parts: int = 1024, max_part_header_bytes: int = 16384, max_part_bytes: int = 8388608, max_form_memory_bytes: int = 16777216, spool_max_bytes: int = 1048576, max_cookie_bytes: int = 16384, max_form_fields: int = 1024) -> None

StreamConsumed#

The one-shot request body stream has already been consumed.

UploadedFile#

One file field from a multipart form.

Built by Request.form(), one per part that carried a filename. A payload of RequestLimits.spool_max_bytes or less stays in memory and is reachable as data. A larger one is written to an unnamed temporary file, data is left empty, and the payload is reachable only through chunks() (streaming) or read() (materialising, which undoes the point of spooling for anything large). Ask spooled which kind you hold rather than testing data, since a spooled upload and an empty one both report data == b"".

size is the payload length either way. field_name, filename and headers come from the part itself, so filename is whatever the client sent -- it is not sanitised, and joining it onto a path unchecked is how a caller writes outside the directory it meant to.

The spool is released by close() here, by FormData.close(), or when the last reference to this object goes away and CPython closes the temporary file; the file is deleted when it closes and cannot be reopened. Nothing in the framework closes a form for you, so a handler that keeps an upload past its own return must copy the bytes out first.

Args:

  • data — the payload when it is held in memory, empty when it is spooled
  • spool — an open temporary file holding the payload, or None
  • size — payload length, which the caller must pass when it spools
UploadedFile(field_name: str, filename: str, headers: list[tuple[bytes, bytes]], data: bytes | None = None, spool: Any = None, size: int | None = None) -> None

chunks#

chunks(size: int = 65536) -> Iterator[bytes]

Yield the payload in pieces, without materialising it.

The streaming read. For an in-memory payload this yields it in one piece, so a caller does not have to know which kind it holds. For a spooled one it rereads from the start of the temporary file, so two iterations both see the whole payload; size bounds each read.

This is a generator, so a closed spool is reported when iteration begins rather than when chunks is called.

Raises:

  • ValueError — the payload was spooled and the spool has been closed

close#

close() -> None

Release the spool, if there is one. Idempotent.

A no-op for an in-memory payload, which stays readable. For a spooled one the temporary file is deleted, and chunks() and read() raise ValueError from then on.

content_type (property)#

content_type: str

The part's declared Content-Type, or application/octet-stream.

Scanned out of the part's headers on every read; nothing is cached. The value is client-supplied and describes nothing about the bytes -- check the content itself before trusting it.

read#

read() -> bytes

The whole payload as bytes.

Materialises a spooled file, which is exactly what spooling avoided -- fine for a small one, and a way to reintroduce the problem for a large one. Prefer chunks().

Raises:

  • ValueError — the payload was spooled and the spool has been closed

size (property)#

size: int

Payload length in bytes, whether it is spooled or not.

spooled (property)#

spooled: bool

Whether this payload lives in a temporary file rather than in memory.

wreath.response#

ASGI response primitives.

Every response type wreath sends, plus the two seams around them: the coerce_* builders that a handler's return value passes through, and ProblemDetail -- the RFC 9457 document every wreath error becomes.

Response and its subclasses hold a complete body in memory and send it as two ASGI messages. StreamingResponse, SSEResponse, and FileResponse are deliberately not Response subclasses: they own their emission and have no in-memory body, which is also why wreath.response_cache and wreath.policy.idempotency refuse to store them.

FileResponse#

Stream a file from disk with a known content-length.

Not a Response subclass: there is no in-memory body, and the file is read only while the response is being sent. content-length is always emitted -- the size comes from an fstat of the descriptor being served, so it is the length actually sent rather than a promise made from a separate stat. The content type is guessed from the path with mimetypes unless media_type says otherwise, falling back to application/octet-stream. Status is 200, or 206 automatically whenever range is given.

Conditional requests are the caller's job. This class emits no etag, no last-modified, and no accept-ranges, and it never reads the request's Range header. Serving a byte window is a two-step contract: the caller resolves the header with parse_range(), sets content-range itself, and passes the resolved window as range. wreath.staticfiles does all of that and is the thing to reach for when serving a directory of files.

The open and the stat happen lazily, in a worker thread, when the response is sent -- so a missing or unreadable path raises FileNotFoundError or PermissionError from __call__, after the handler has already returned. That is past the application's exception boundary, so it reaches the server as a failed ASGI application rather than a problem+json 404. Route-level code that wants a 404 checks existence first, as wreath.staticfiles does by opening the file itself and using from_descriptor().

Args:

  • path — Filesystem path to send; opened at send time, not here.
  • status — Status for a whole-file response; ignored when range is given.
  • headers — Headers to send before the generated content-type and length.
  • media_type — Overrides the type guessed from the path.
  • filename — Sends content-disposition as an attachment under this name.
  • background — Awaited by the application after the file has been sent.
  • range — An inclusive (first, last) byte window from parse_range().

Raises:

  • ValueError — filename contains a control character.
FileResponse(path: str | os.PathLike[str], status: int = 200, headers: Iterable[tuple[bytes, bytes]] | None = None, media_type: bytes | None = None, filename: str | None = None, background: Background | None = None, range: tuple[int, int] | None = None) -> None

close#

close() -> None

Release an unsent descriptor taken by from_descriptor().

A no-op for a FileResponse built from a path (it holds no descriptor until it is sent) and for one that has already been sent (__call__ hands the descriptor to the reader and clears the reference before it streams, so there is never two owners). Idempotent, which matters because closing twice would close whatever descriptor the number has since been reused for.

from_descriptor (classmethod)#

from_descriptor(fd: int, stat: os.stat_result, name: str, *, status: int = 200, headers: Iterable[tuple[bytes, bytes]] | None = None, range: tuple[int, int] | None = None) -> FileResponse

Build a response over an already-open descriptor.

The descriptor is owned by the response. Sending it hands ownership to the reader, which closes it when the send finishes, on success and on error alike. A response that is never sent -- a handler that raises after building it, a middleware that replaces it, a conditional request answered 304 instead -- closes it from close(), which __del__ calls, so an abandoned response costs a descriptor only until it is collected rather than for the life of the process. Call close() directly to release it at a known point.

The file the opener checked is the file served -- there is no reopen-by-name window between the check and the send, which is what lets wreath.staticfiles guarantee it serves only from beneath its root. name is used only to guess the content type, stat only for the length, and status becomes 206 when range is given. No background callback is attached.

set_cache_control#

set_cache_control(policy: CacheControl) -> None

Replace every cache-control header on this response with policy.

Identical in behaviour to Response.set_cache_control(), and defined again here only because FileResponse is not a Response subclass.

set_cdn_cache_control#

set_cdn_cache_control(policy: CacheControl) -> None

Replace CDN-Cache-Control with an RFC 9213 targeted policy.

set_link_templates(*templates: LinkTemplate) -> None

Replace Link-Template with validated RFC 9652 templated links.

HTMLResponse#

A UTF-8 HTML document, sent as text/html; charset=utf-8.

A str is encoded with UTF-8; bytes are already the document and are sent as they arrive. That second form is the one to reach for behind a template: Template.render_bytes produces exactly the bytes that go on the wire, so handing them here through render() decodes the whole document and this constructor encodes it straight back -- two full passes over every page for no change in what is sent.

content-length comes from the encoded bytes either way. Status defaults to

  1. Nothing is escaped or validated -- the markup is sent exactly as given,

so interpolated user text is escaped by the caller or by a template (wreath.templates escapes by default). background, keyword-only here, is awaited after the response has been sent.

HTMLResponse(body: str | bytes, status: int = 200, *, background: Background | None = None) -> None
delete_cookie(name: str, *, path: str = '/', domain: str | None = None) -> None

Expire a cookie by resending it empty with Max-Age=0 and a 1970 Expires.

A browser replaces a stored cookie only when the name, Path, and Domain all match, so this must be given the same path and domain the cookie was set with -- the defaults clear a cookie that was set with the defaults, and a cookie scoped to /admin is not cleared by a deletion at /. SameSite is omitted, and Secure is carried for a __Secure-/__Host- name because a prefixed cookie can only be cleared by a Set-Cookie that still meets the prefix's attribute rules.

headers (property, inherited from Response)#

headers: list[tuple[bytes, bytes]]

Mutable wire headers, materialized when application code observes them.

set_cache_control (inherited from Response)#

set_cache_control(policy: CacheControl) -> None

Replace every cache-control header on this response with policy.

Existing cache-control headers are dropped first rather than appended to, because a cache reads the first line it finds and a second one added after a permissive first would have no effect.

set_cdn_cache_control (inherited from Response)#

set_cdn_cache_control(policy: CacheControl) -> None

Replace CDN-Cache-Control with an RFC 9213 targeted policy.

set_content_digest (inherited from Response)#

set_content_digest(*algorithms: str, content: bytes | None = None) -> None

Replace Content-Digest with RFC 9530 digests of the message content.

set_cookie(name: str, value: str, *, max_age: int | None = None, expires: str | None = None, path: str = '/', domain: str | None = None, secure: bool = False, httponly: bool = False, samesite: str | None = 'lax') -> None

Append one set-cookie header to this response.

The name and value are sent verbatim; a caller quotes or encodes a value containing a separator. By default the cookie is host-only (no Domain), scoped to Path=/, SameSite=Lax, not Secure, not HttpOnly, and a session cookie -- it expires when the browser does, because neither max_age nor expires is set.

Enforces the browser-mandated attribute rules so a cookie is never emitted in a form every browser silently drops: SameSite must be one of strict/lax/none and SameSite=None requires Secure (RFC 6265bis 5.4.7); the __Secure-/__Host- name prefixes require Secure (and, for __Host-, Path=/ with no Domain) per RFC 6265bis 4.1.3. A control character or an attribute separator in the name, value, path, domain, or expires is a header-injection vector and is refused here rather than trusted as far as the serializer.

Args:

  • max_age — Lifetime in seconds; the attribute is omitted when None.
  • expires — An HTTP-date string, emitted verbatim as Expires.
  • path — Path attribute, / by default; an empty string omits it.
  • domain — Domain attribute; None keeps the cookie host-only.
  • secure — Adds Secure, restricting the cookie to HTTPS.
  • httponly — Adds HttpOnly, hiding the cookie from scripts.
  • samesite — strict, lax (the default), or none; None omits the attribute.

Raises:

  • ValueError — Any of the attribute rules or injection checks above failed.
  • TypeError — max_age is not an integer.
set_link_templates(*templates: LinkTemplate) -> None

Replace Link-Template with validated RFC 9652 templated links.

set_repr_digest (inherited from Response)#

set_repr_digest(*algorithms: str, representation: bytes | None = None) -> None

Replace Repr-Digest with digests of the complete selected representation.

set_want_content_digest (inherited from Response)#

set_want_content_digest(preferences: DigestPreferences) -> None

Ask the peer for Content-Digest on future requests.

set_want_repr_digest (inherited from Response)#

set_want_repr_digest(preferences: DigestPreferences) -> None

Ask the peer for Repr-Digest on future requests.

JSONResponse#

A JSON document, sent as application/json.

Serializes with wreath's own encoder, which is compact (no spaces after separators) and stricter than the stdlib: an object key that is not a str raises TypeError, a non-finite float raises ValueError, and a value no encoder understands raises TypeError. Dates, times, datetimes and durations encode as ISO-8601 strings, and an object defining __jsonable__ is asked how it wants to be encoded. Status defaults to 200. background, keyword-only here, is awaited after the response has been sent.

JSONResponse(data: Any, status: int = 200, *, background: Background | None = None) -> None
delete_cookie(name: str, *, path: str = '/', domain: str | None = None) -> None

Expire a cookie by resending it empty with Max-Age=0 and a 1970 Expires.

A browser replaces a stored cookie only when the name, Path, and Domain all match, so this must be given the same path and domain the cookie was set with -- the defaults clear a cookie that was set with the defaults, and a cookie scoped to /admin is not cleared by a deletion at /. SameSite is omitted, and Secure is carried for a __Secure-/__Host- name because a prefixed cookie can only be cleared by a Set-Cookie that still meets the prefix's attribute rules.

headers (property, inherited from Response)#

headers: list[tuple[bytes, bytes]]

Mutable wire headers, materialized when application code observes them.

set_cache_control (inherited from Response)#

set_cache_control(policy: CacheControl) -> None

Replace every cache-control header on this response with policy.

Existing cache-control headers are dropped first rather than appended to, because a cache reads the first line it finds and a second one added after a permissive first would have no effect.

set_cdn_cache_control (inherited from Response)#

set_cdn_cache_control(policy: CacheControl) -> None

Replace CDN-Cache-Control with an RFC 9213 targeted policy.

set_content_digest (inherited from Response)#

set_content_digest(*algorithms: str, content: bytes | None = None) -> None

Replace Content-Digest with RFC 9530 digests of the message content.

set_cookie(name: str, value: str, *, max_age: int | None = None, expires: str | None = None, path: str = '/', domain: str | None = None, secure: bool = False, httponly: bool = False, samesite: str | None = 'lax') -> None

Append one set-cookie header to this response.

The name and value are sent verbatim; a caller quotes or encodes a value containing a separator. By default the cookie is host-only (no Domain), scoped to Path=/, SameSite=Lax, not Secure, not HttpOnly, and a session cookie -- it expires when the browser does, because neither max_age nor expires is set.

Enforces the browser-mandated attribute rules so a cookie is never emitted in a form every browser silently drops: SameSite must be one of strict/lax/none and SameSite=None requires Secure (RFC 6265bis 5.4.7); the __Secure-/__Host- name prefixes require Secure (and, for __Host-, Path=/ with no Domain) per RFC 6265bis 4.1.3. A control character or an attribute separator in the name, value, path, domain, or expires is a header-injection vector and is refused here rather than trusted as far as the serializer.

Args:

  • max_age — Lifetime in seconds; the attribute is omitted when None.
  • expires — An HTTP-date string, emitted verbatim as Expires.
  • path — Path attribute, / by default; an empty string omits it.
  • domain — Domain attribute; None keeps the cookie host-only.
  • secure — Adds Secure, restricting the cookie to HTTPS.
  • httponly — Adds HttpOnly, hiding the cookie from scripts.
  • samesite — strict, lax (the default), or none; None omits the attribute.

Raises:

  • ValueError — Any of the attribute rules or injection checks above failed.
  • TypeError — max_age is not an integer.
set_link_templates(*templates: LinkTemplate) -> None

Replace Link-Template with validated RFC 9652 templated links.

set_repr_digest (inherited from Response)#

set_repr_digest(*algorithms: str, representation: bytes | None = None) -> None

Replace Repr-Digest with digests of the complete selected representation.

set_want_content_digest (inherited from Response)#

set_want_content_digest(preferences: DigestPreferences) -> None

Ask the peer for Content-Digest on future requests.

set_want_repr_digest (inherited from Response)#

set_want_repr_digest(preferences: DigestPreferences) -> None

Ask the peer for Repr-Digest on future requests.

ProblemDetail#

An RFC 9457 problem document -- the one error shape wreath produces.

Every error a wreath application returns is this document, serialized as application/problem+json by ProblemResponse: a raised HTTPException, a request-validation failure, and an unhandled exception alike. It is emphatically not {"detail": ...}; a 500 renders

{"type":"about:blank","title":"Internal Server Error","status":500,
 "detail":"Internal Server Error"}

Frozen and slotted, so an application can build one per error class at import time and hand the same instance to a ProblemResponse on every request.

Args:

  • status — HTTP status code, repeated inside the document body.
  • title — Short summary of the problem kind; defaults to the status phrase.
  • detail — Explanation specific to this one occurrence.
  • type — URI identifying the problem kind, about:blank when unclassified.
  • instance — URI identifying this occurrence, typically the request path.
  • extensions — Extra top-level members merged into the document last.
ProblemDetail(status: int, title: str | None = None, detail: str | None = None, type: str = 'about:blank', instance: str | None = None, extensions: Mapping[str, Any] = <factory>) -> None

as_dict#

as_dict() -> dict[str, Any]

The document as a JSON-ready mapping, in RFC 9457 member order.

type, title, and status are always present. detail and instance appear only when they are not None, and validated extensions are merged last. title falls back to the HTTPStatus phrase for status, or to HTTP Error when the status is not one the stdlib knows, so a custom status still yields a complete document.

ProblemResponse#

An RFC 9457 error response, sent as application/problem+json.

What every wreath error becomes on the wire. The response status is the problem's status rather than a separate argument, so the code in the status line and the one inside the body cannot disagree. The body is ProblemDetail.as_dict() encoded as JSON.

Give it either a ready ProblemDetail as problem, or the document's members as keyword arguments -- in which case status is required and the rest default exactly as on ProblemDetail. When problem is given, the member keyword arguments are ignored.

Args:

  • problem — A ready problem document, replacing the member keywords.
  • status — Required when problem is None; becomes the response status.
  • headers — Extra headers, such as the www-authenticate an Unauthorized carries.
  • background — Awaited by the application after the response has been sent.

Raises:

  • TypeError — Neither problem nor status was given.
ProblemResponse(problem: ProblemDetail | None = None, *, status: int | None = None, title: str | None = None, detail: str | None = None, type: str = 'about:blank', instance: str | None = None, extensions: dict[str, Any] | None = None, headers: Iterable[tuple[bytes, bytes]] | None = None, background: Background | None = None) -> None
delete_cookie(name: str, *, path: str = '/', domain: str | None = None) -> None

Expire a cookie by resending it empty with Max-Age=0 and a 1970 Expires.

A browser replaces a stored cookie only when the name, Path, and Domain all match, so this must be given the same path and domain the cookie was set with -- the defaults clear a cookie that was set with the defaults, and a cookie scoped to /admin is not cleared by a deletion at /. SameSite is omitted, and Secure is carried for a __Secure-/__Host- name because a prefixed cookie can only be cleared by a Set-Cookie that still meets the prefix's attribute rules.

headers (property, inherited from Response)#

headers: list[tuple[bytes, bytes]]

Mutable wire headers, materialized when application code observes them.

set_cache_control (inherited from Response)#

set_cache_control(policy: CacheControl) -> None

Replace every cache-control header on this response with policy.

Existing cache-control headers are dropped first rather than appended to, because a cache reads the first line it finds and a second one added after a permissive first would have no effect.

set_cdn_cache_control (inherited from Response)#

set_cdn_cache_control(policy: CacheControl) -> None

Replace CDN-Cache-Control with an RFC 9213 targeted policy.

set_content_digest (inherited from Response)#

set_content_digest(*algorithms: str, content: bytes | None = None) -> None

Replace Content-Digest with RFC 9530 digests of the message content.

set_cookie(name: str, value: str, *, max_age: int | None = None, expires: str | None = None, path: str = '/', domain: str | None = None, secure: bool = False, httponly: bool = False, samesite: str | None = 'lax') -> None

Append one set-cookie header to this response.

The name and value are sent verbatim; a caller quotes or encodes a value containing a separator. By default the cookie is host-only (no Domain), scoped to Path=/, SameSite=Lax, not Secure, not HttpOnly, and a session cookie -- it expires when the browser does, because neither max_age nor expires is set.

Enforces the browser-mandated attribute rules so a cookie is never emitted in a form every browser silently drops: SameSite must be one of strict/lax/none and SameSite=None requires Secure (RFC 6265bis 5.4.7); the __Secure-/__Host- name prefixes require Secure (and, for __Host-, Path=/ with no Domain) per RFC 6265bis 4.1.3. A control character or an attribute separator in the name, value, path, domain, or expires is a header-injection vector and is refused here rather than trusted as far as the serializer.

Args:

  • max_age — Lifetime in seconds; the attribute is omitted when None.
  • expires — An HTTP-date string, emitted verbatim as Expires.
  • path — Path attribute, / by default; an empty string omits it.
  • domain — Domain attribute; None keeps the cookie host-only.
  • secure — Adds Secure, restricting the cookie to HTTPS.
  • httponly — Adds HttpOnly, hiding the cookie from scripts.
  • samesite — strict, lax (the default), or none; None omits the attribute.

Raises:

  • ValueError — Any of the attribute rules or injection checks above failed.
  • TypeError — max_age is not an integer.
set_link_templates(*templates: LinkTemplate) -> None

Replace Link-Template with validated RFC 9652 templated links.

set_repr_digest (inherited from Response)#

set_repr_digest(*algorithms: str, representation: bytes | None = None) -> None

Replace Repr-Digest with digests of the complete selected representation.

set_want_content_digest (inherited from Response)#

set_want_content_digest(preferences: DigestPreferences) -> None

Ask the peer for Content-Digest on future requests.

set_want_repr_digest (inherited from Response)#

set_want_repr_digest(preferences: DigestPreferences) -> None

Ask the peer for Repr-Digest on future requests.

RedirectResponse#

A redirect: an empty body and a location header.

Status defaults to 307, which preserves the request method and body; pass 303 to send a browser to a GET after a POST, or 308/301 for a permanent move. No content-type is emitted (there is no representation to describe), and content-length is 0.

The target is percent-encoded on the way into the header, so a URL holding a space or a non-ASCII character is emitted in a form a client accepts. Its scheme is checked against allowed_schemes rather than trusted: a javascript: or data: target in a Location is script execution on the origin that redirected. A relative target, and a protocol-relative //host/path that inherits the current scheme, are always allowed.

Args:

  • url — Redirect target, relative or absolute.
  • status — A 3xx status, 307 by default.
  • background — Awaited by the application after the response has been sent.

Raises:

  • ValueError — url names a scheme outside allowed_schemes.
RedirectResponse(url: str, status: int = 307, *, background: Background | None = None) -> None
delete_cookie(name: str, *, path: str = '/', domain: str | None = None) -> None

Expire a cookie by resending it empty with Max-Age=0 and a 1970 Expires.

A browser replaces a stored cookie only when the name, Path, and Domain all match, so this must be given the same path and domain the cookie was set with -- the defaults clear a cookie that was set with the defaults, and a cookie scoped to /admin is not cleared by a deletion at /. SameSite is omitted, and Secure is carried for a __Secure-/__Host- name because a prefixed cookie can only be cleared by a Set-Cookie that still meets the prefix's attribute rules.

headers (property, inherited from Response)#

headers: list[tuple[bytes, bytes]]

Mutable wire headers, materialized when application code observes them.

set_cache_control (inherited from Response)#

set_cache_control(policy: CacheControl) -> None

Replace every cache-control header on this response with policy.

Existing cache-control headers are dropped first rather than appended to, because a cache reads the first line it finds and a second one added after a permissive first would have no effect.

set_cdn_cache_control (inherited from Response)#

set_cdn_cache_control(policy: CacheControl) -> None

Replace CDN-Cache-Control with an RFC 9213 targeted policy.

set_content_digest (inherited from Response)#

set_content_digest(*algorithms: str, content: bytes | None = None) -> None

Replace Content-Digest with RFC 9530 digests of the message content.

set_cookie(name: str, value: str, *, max_age: int | None = None, expires: str | None = None, path: str = '/', domain: str | None = None, secure: bool = False, httponly: bool = False, samesite: str | None = 'lax') -> None

Append one set-cookie header to this response.

The name and value are sent verbatim; a caller quotes or encodes a value containing a separator. By default the cookie is host-only (no Domain), scoped to Path=/, SameSite=Lax, not Secure, not HttpOnly, and a session cookie -- it expires when the browser does, because neither max_age nor expires is set.

Enforces the browser-mandated attribute rules so a cookie is never emitted in a form every browser silently drops: SameSite must be one of strict/lax/none and SameSite=None requires Secure (RFC 6265bis 5.4.7); the __Secure-/__Host- name prefixes require Secure (and, for __Host-, Path=/ with no Domain) per RFC 6265bis 4.1.3. A control character or an attribute separator in the name, value, path, domain, or expires is a header-injection vector and is refused here rather than trusted as far as the serializer.

Args:

  • max_age — Lifetime in seconds; the attribute is omitted when None.
  • expires — An HTTP-date string, emitted verbatim as Expires.
  • path — Path attribute, / by default; an empty string omits it.
  • domain — Domain attribute; None keeps the cookie host-only.
  • secure — Adds Secure, restricting the cookie to HTTPS.
  • httponly — Adds HttpOnly, hiding the cookie from scripts.
  • samesite — strict, lax (the default), or none; None omits the attribute.

Raises:

  • ValueError — Any of the attribute rules or injection checks above failed.
  • TypeError — max_age is not an integer.
set_link_templates(*templates: LinkTemplate) -> None

Replace Link-Template with validated RFC 9652 templated links.

set_repr_digest (inherited from Response)#

set_repr_digest(*algorithms: str, representation: bytes | None = None) -> None

Replace Repr-Digest with digests of the complete selected representation.

set_want_content_digest (inherited from Response)#

set_want_content_digest(preferences: DigestPreferences) -> None

Ask the peer for Content-Digest on future requests.

set_want_repr_digest (inherited from Response)#

set_want_repr_digest(preferences: DigestPreferences) -> None

Ask the peer for Repr-Digest on future requests.

Response#

A complete HTTP response held in memory: status, headers, and a byte body.

The base of every non-streaming response type, and what a handler gets back from coerce_text, coerce_json, and coerce_bytes. Emission is two ASGI messages -- one http.response.start, one http.response.body.

With no headers, the header list is generated: content-type from the class media_type (application/octet-stream here, or media_type when given) and content-length from the body. With headers, that list is sent as written and content-type/content-length are appended only if the caller did not already supply them, matched case-insensitively. A 204 or a 304 may carry no content (RFC 9110 §6.4.1, §15.4.5), so a body passed with either status is dropped at construction and no content-length is emitted.

The header list is a plain mutable list of (name, value) byte pairs and stays writable after construction; the set_* methods are the supported ways to edit it.

Args:

  • status — HTTP status code, defaulting to 200.
  • headers — Full header list to send, replacing the generated defaults.
  • media_type — Overrides the class media_type; empty bytes emit no content-type.
  • background — Awaited by the application after the response has been sent.
Response(body: bytes = b'', status: int = 200, headers: Iterable[tuple[bytes, bytes]] | None = None, media_type: bytes | None = None, background: Background | None = None) -> None
delete_cookie(name: str, *, path: str = '/', domain: str | None = None) -> None

Expire a cookie by resending it empty with Max-Age=0 and a 1970 Expires.

A browser replaces a stored cookie only when the name, Path, and Domain all match, so this must be given the same path and domain the cookie was set with -- the defaults clear a cookie that was set with the defaults, and a cookie scoped to /admin is not cleared by a deletion at /. SameSite is omitted, and Secure is carried for a __Secure-/__Host- name because a prefixed cookie can only be cleared by a Set-Cookie that still meets the prefix's attribute rules.

headers (property)#

headers: list[tuple[bytes, bytes]]

Mutable wire headers, materialized when application code observes them.

set_cache_control#

set_cache_control(policy: CacheControl) -> None

Replace every cache-control header on this response with policy.

Existing cache-control headers are dropped first rather than appended to, because a cache reads the first line it finds and a second one added after a permissive first would have no effect.

set_cdn_cache_control#

set_cdn_cache_control(policy: CacheControl) -> None

Replace CDN-Cache-Control with an RFC 9213 targeted policy.

set_content_digest#

set_content_digest(*algorithms: str, content: bytes | None = None) -> None

Replace Content-Digest with RFC 9530 digests of the message content.

set_cookie(name: str, value: str, *, max_age: int | None = None, expires: str | None = None, path: str = '/', domain: str | None = None, secure: bool = False, httponly: bool = False, samesite: str | None = 'lax') -> None

Append one set-cookie header to this response.

The name and value are sent verbatim; a caller quotes or encodes a value containing a separator. By default the cookie is host-only (no Domain), scoped to Path=/, SameSite=Lax, not Secure, not HttpOnly, and a session cookie -- it expires when the browser does, because neither max_age nor expires is set.

Enforces the browser-mandated attribute rules so a cookie is never emitted in a form every browser silently drops: SameSite must be one of strict/lax/none and SameSite=None requires Secure (RFC 6265bis 5.4.7); the __Secure-/__Host- name prefixes require Secure (and, for __Host-, Path=/ with no Domain) per RFC 6265bis 4.1.3. A control character or an attribute separator in the name, value, path, domain, or expires is a header-injection vector and is refused here rather than trusted as far as the serializer.

Args:

  • max_age — Lifetime in seconds; the attribute is omitted when None.
  • expires — An HTTP-date string, emitted verbatim as Expires.
  • path — Path attribute, / by default; an empty string omits it.
  • domain — Domain attribute; None keeps the cookie host-only.
  • secure — Adds Secure, restricting the cookie to HTTPS.
  • httponly — Adds HttpOnly, hiding the cookie from scripts.
  • samesite — strict, lax (the default), or none; None omits the attribute.

Raises:

  • ValueError — Any of the attribute rules or injection checks above failed.
  • TypeError — max_age is not an integer.
set_link_templates(*templates: LinkTemplate) -> None

Replace Link-Template with validated RFC 9652 templated links.

set_repr_digest#

set_repr_digest(*algorithms: str, representation: bytes | None = None) -> None

Replace Repr-Digest with digests of the complete selected representation.

set_want_content_digest#

set_want_content_digest(preferences: DigestPreferences) -> None

Ask the peer for Content-Digest on future requests.

set_want_repr_digest#

set_want_repr_digest(preferences: DigestPreferences) -> None

Ask the peer for Repr-Digest on future requests.

SSEResponse#

Server-Sent Events over the streaming-response plumbing.

Frames each item an async iterable yields (a ServerSentEvent, a str/bytes treated as data, or a mapping with the same keys) to the text/event-stream wire format: one data: line per line of payload, optional event:/id:/retry: lines, :-prefixed comments, and a blank line ending each event. Status defaults to 200 and the constructor prepends the headers that keep a stream from being buffered or cached -- content-type: text/event-stream, cache-control: no-cache, x-accel-buffering: no, connection: keep-alive -- with any headers given appended after them. Like every StreamingResponse, it also emits incremental: ?1 by default. There is no content-length; the body ends when the iterable does.

Nothing is emitted on a timer. No keep-alive is sent unless the application yields one (ServerSentEvent(comment="ping")), and no retry: hint is sent unless an event carries retry. An idle stream is therefore silent, and an intermediary free to drop it after its own idle timeout, so an application that must survive one yields a periodic comment itself.

This is the SSE transport only; a progress or status convention on top of it is left to the application (consumer-owned, by design). wreath.progress is one such convention built on this class.

Args:

  • events — Async iterable of events to frame, one message each.
  • headers — Extra headers appended after the four preset above.
  • background — Awaited by the application after the stream has finished.
  • incremental — Whether intermediaries should forward events as they arrive.
SSEResponse(events: AsyncIterable[ServerSentEvent | str | bytes | Mapping[str, Any]], *, status: int = 200, headers: Iterable[tuple[bytes, bytes]] | None = None, background: Background | None = None, incremental: bool = True) -> None

set_cache_control (inherited from StreamingResponse)#

set_cache_control(policy: CacheControl) -> None

Replace every cache-control header on this response with policy.

Identical in behaviour to Response.set_cache_control(), and defined again here only because StreamingResponse is not a Response subclass. On an SSEResponse this is also how the constructor's no-cache is displaced, since dropping first is what keeps the stricter policy from landing behind it.

set_cdn_cache_control (inherited from StreamingResponse)#

set_cdn_cache_control(policy: CacheControl) -> None

Replace CDN-Cache-Control with an RFC 9213 targeted policy.

set_link_templates(*templates: LinkTemplate) -> None

Replace Link-Template with validated RFC 9652 templated links.

ServerSentEvent#

One Server-Sent Event, as SSEResponse frames it.

Every field is optional, and an instance carrying none frames as a bare : comment -- the conventional keep-alive. data may be multi-line and is re-framed as one data: field per line; comment behaves the same way. Fields are emitted in the order comment, event, id, retry, data, and a blank line terminates the event.

A CR or LF in event or id raises ValueError when the event is framed, not when it is constructed: either character ends the field, so passing user text through one would let a caller append arbitrary frames to the stream.

Args:

  • data — The payload; bytes are decoded as UTF-8, anything else is str()-ed.
  • event — Event name the client dispatches on, message when omitted.
  • id — Last-Event-ID the client echoes when it reconnects.
  • retry — Reconnection delay in milliseconds the client should adopt.
  • comment — A :-prefixed line ignored by clients, used as a keep-alive.
ServerSentEvent(data: str | bytes | None = None, *, event: str | None = None, id: str | None = None, retry: int | None = None, comment: str | None = None) -> None

StreamingResponse#

A response whose body is produced chunk by chunk by an async iterable.

Not a Response subclass. It adds Incremental: ?1 by default so an intermediary knows not to buffer the complete message (RFC 10036); pass incremental=False to emit ?0 when buffering is acceptable. Apart from that signal, it sends exactly the headers given. In particular there is no content-type and no content-length, so an HTTP/1.1 server frames the body with chunked transfer encoding (an HTTP/1.0 one has to frame it by closing the connection). A caller who knows the length sets the header itself. Status defaults to 200.

Each bytes the iterable yields becomes one http.response.body message with more_body=True, sent as it is produced, and a terminal empty message closes the body. Because the length is unknown up front the response cannot be cached or replayed -- wreath.response_cache and the idempotency middleware skip it by design.

Args:

  • body — Async iterable of bytes chunks; a str chunk is not encoded for you.
  • headers — The complete header list to send, empty by default.
  • background — Awaited by the application after the response has been sent.
  • incremental — Whether intermediaries should forward chunks as they arrive.
StreamingResponse(body: AsyncIterable[bytes], status: int = 200, headers: Iterable[tuple[bytes, bytes]] | None = None, background: Background | None = None, *, incremental: bool = True) -> None

set_cache_control#

set_cache_control(policy: CacheControl) -> None

Replace every cache-control header on this response with policy.

Identical in behaviour to Response.set_cache_control(), and defined again here only because StreamingResponse is not a Response subclass. On an SSEResponse this is also how the constructor's no-cache is displaced, since dropping first is what keeps the stricter policy from landing behind it.

set_cdn_cache_control#

set_cdn_cache_control(policy: CacheControl) -> None

Replace CDN-Cache-Control with an RFC 9213 targeted policy.

set_link_templates(*templates: LinkTemplate) -> None

Replace Link-Template with validated RFC 9652 templated links.

TextResponse#

UTF-8 text, sent as text/plain; charset=utf-8.

Encodes body with UTF-8 and sets content-length from the encoded bytes. Status defaults to 200. background, keyword-only here, is awaited by the application after the response has been sent.

TextResponse(body: str, status: int = 200, *, background: Background | None = None) -> None
delete_cookie(name: str, *, path: str = '/', domain: str | None = None) -> None

Expire a cookie by resending it empty with Max-Age=0 and a 1970 Expires.

A browser replaces a stored cookie only when the name, Path, and Domain all match, so this must be given the same path and domain the cookie was set with -- the defaults clear a cookie that was set with the defaults, and a cookie scoped to /admin is not cleared by a deletion at /. SameSite is omitted, and Secure is carried for a __Secure-/__Host- name because a prefixed cookie can only be cleared by a Set-Cookie that still meets the prefix's attribute rules.

headers (property, inherited from Response)#

headers: list[tuple[bytes, bytes]]

Mutable wire headers, materialized when application code observes them.

set_cache_control (inherited from Response)#

set_cache_control(policy: CacheControl) -> None

Replace every cache-control header on this response with policy.

Existing cache-control headers are dropped first rather than appended to, because a cache reads the first line it finds and a second one added after a permissive first would have no effect.

set_cdn_cache_control (inherited from Response)#

set_cdn_cache_control(policy: CacheControl) -> None

Replace CDN-Cache-Control with an RFC 9213 targeted policy.

set_content_digest (inherited from Response)#

set_content_digest(*algorithms: str, content: bytes | None = None) -> None

Replace Content-Digest with RFC 9530 digests of the message content.

set_cookie(name: str, value: str, *, max_age: int | None = None, expires: str | None = None, path: str = '/', domain: str | None = None, secure: bool = False, httponly: bool = False, samesite: str | None = 'lax') -> None

Append one set-cookie header to this response.

The name and value are sent verbatim; a caller quotes or encodes a value containing a separator. By default the cookie is host-only (no Domain), scoped to Path=/, SameSite=Lax, not Secure, not HttpOnly, and a session cookie -- it expires when the browser does, because neither max_age nor expires is set.

Enforces the browser-mandated attribute rules so a cookie is never emitted in a form every browser silently drops: SameSite must be one of strict/lax/none and SameSite=None requires Secure (RFC 6265bis 5.4.7); the __Secure-/__Host- name prefixes require Secure (and, for __Host-, Path=/ with no Domain) per RFC 6265bis 4.1.3. A control character or an attribute separator in the name, value, path, domain, or expires is a header-injection vector and is refused here rather than trusted as far as the serializer.

Args:

  • max_age — Lifetime in seconds; the attribute is omitted when None.
  • expires — An HTTP-date string, emitted verbatim as Expires.
  • path — Path attribute, / by default; an empty string omits it.
  • domain — Domain attribute; None keeps the cookie host-only.
  • secure — Adds Secure, restricting the cookie to HTTPS.
  • httponly — Adds HttpOnly, hiding the cookie from scripts.
  • samesite — strict, lax (the default), or none; None omits the attribute.

Raises:

  • ValueError — Any of the attribute rules or injection checks above failed.
  • TypeError — max_age is not an integer.
set_link_templates(*templates: LinkTemplate) -> None

Replace Link-Template with validated RFC 9652 templated links.

set_repr_digest (inherited from Response)#

set_repr_digest(*algorithms: str, representation: bytes | None = None) -> None

Replace Repr-Digest with digests of the complete selected representation.

set_want_content_digest (inherited from Response)#

set_want_content_digest(preferences: DigestPreferences) -> None

Ask the peer for Content-Digest on future requests.

set_want_repr_digest (inherited from Response)#

set_want_repr_digest(preferences: DigestPreferences) -> None

Ask the peer for Repr-Digest on future requests.

UNSATISFIABLE (value)#

UNSATISFIABLE: _Unsatisfiable = UNSATISFIABLE

coerce_bytes#

coerce_bytes(body: bytes) -> Response

Build a 200 application/octet-stream response from body.

Applied to a handler's return value when its type is exactly bytes; the bytes are sent unchanged. The content type is fixed, so a handler returning bytes of a known type returns Response(body, media_type=...) instead.

coerce_json#

coerce_json(data: Any) -> Response

Build a 200 application/json response from data.

Applied to a handler's return value when its type is exactly dict; a list, tuple, scalar, None, or dict subclass reaches JSONResponse instead, with the same encoder and the same result. Accepts anything that encoder understands and refuses the rest, at the same strictness as JSONResponse.

Raises:

  • TypeError — An object key that is not a str, or an unserializable value.
  • ValueError — A non-finite float, which has no JSON spelling.

coerce_text#

coerce_text(body: str) -> Response

Build a 200 text/plain; charset=utf-8 response from body.

One of the three builders the application applies to a handler's return value, chosen when the value's type is exactly str; a str subclass goes through TextResponse instead. The result is byte-for-byte what TextResponse(body) produces, which tests/test_response_fast_path.py asserts. There is no type check and no status argument: a handler that needs another status or another header constructs the response itself.

Raises:

  • UnicodeEncodeError — body holds a lone surrogate and cannot be encoded.

parse_range#

parse_range(header: str | None, size: int) -> tuple[int, int] | _Unsatisfiable | None

Resolve a Range header against a representation of size bytes.

Returns an inclusive (first, last), UNSATISFIABLE, or None for a header to ignore.

That last case is most of the grammar. RFC 9110 §14.2 says a recipient may ignore a Range it does not understand, and must serve the whole representation when it does -- so a multi-range request, a unit that is not bytes, and anything malformed all fall through to a normal 200 rather than an error. Only a syntactically valid range that lies entirely past the end is a 416; getting that distinction backwards either breaks resumable downloads or answers 416 to clients asking politely.

RFC 9652 Link-Template response field values.

LinkTemplate#

One validated RFC 9652 templated link, serialized once at construction.

LinkTemplate(template: str, rel: str | None = None, anchor: str | None = None, var_base: str | None = None, attributes: Mapping[str, str] = <factory>) -> None

to_header#

to_header() -> bytes

Return this link as one Structured Fields list member.

serialize_link_templates(templates: Iterable[LinkTemplate]) -> bytes

Serialize a non-empty sequence as one Link-Template field value.

wreath.exceptions#

Structured HTTP exceptions.

Raise one of these from a handler, a dependency, a middleware hook, or an authentication backend and the application answers with an RFC 9457 application/problem+json document -- never {"detail": ...}. raise NotFound() answers with a 404 whose body is {"type":"about:blank","title":"Not Found","status":404,"detail":"NotFound"}.

The status is a class attribute, not a constructor argument, so every status wreath raises has a type that application code can catch and that Wreath.exception_handler() can register against. Wreath.add_status_handler() registers against the status instead, and either intercepts the exception before the problem document is built.

BadRequest#

400 Bad Request -- the request is malformed and will not be understood.

Takes the same detail and headers as HTTPException, and answers with the same problem+json document under a 400 status.

ClientDisconnect#

400 -- the peer went away before the request body finished arriving.

A 4xx because the request never completed; distinct from BadRequest so an application can tell "malformed" from "never finished" in a handler or a log, and so the response -- which usually has nowhere to go -- is not counted as a server fault. Raised by the request-body readers, not by application code; catch it to abandon work whose result no one will read.

Conflict#

409 Conflict -- the request contradicts the resource's current state.

The status for a failed optimistic-concurrency check, a duplicate unique key, or a state machine refusing a transition. Takes the same detail and headers as HTTPException.

Forbidden#

403 Forbidden -- the caller is known and still not allowed to do this.

Distinct from Unauthorized, which means "authenticate and try again": re-authenticating will not change a 403, so no challenge is sent. Takes the same detail and headers as HTTPException.

HTTPException#

The base of every status-carrying exception, and a 500 on its own.

Carries the two things the error boundary needs: status, a class attribute each subclass overrides, and detail, the per-occurrence explanation that becomes the problem document's detail member. An empty detail falls back to the exception class's own name, so raise NotFound() still produces a document with a detail of NotFound rather than an empty string; the document's title comes from the status phrase either way.

headers is stored as a tuple and copied onto the problem response, which is how a 401 carries its challenge and a 429 its Retry-After. It is used as given: a content-type among them replaces application/problem+json while the body stays a problem document, so do not put one there. Nothing is copied at all when a registered exception or status handler answers in place of the built-in problem response -- that handler owns the whole response.

Args:

  • detail — Explanation of this occurrence; the class name when empty.
  • headers — Header pairs copied onto the response; names lowercase, as ASGI expects.
HTTPException(detail: str = '', *, headers: Iterable[tuple[bytes, bytes]] = ()) -> None

MethodNotAllowed#

405 Method Not Allowed -- the target exists but not for this method.

Sends an allow header listing the methods that are supported, which RFC 9110 §15.5.6 requires of a 405; wreath.audit reports a 405 without one as an error. Passing no allow therefore emits a non-conformant response.

Dispatch raises this for you. When nothing matches a request's method but some other method does answer that path, the application raises it with allow already filled in from the route table -- so a routing miss is a 404 only when no route claims the path at all. A GET route is listed as GET, HEAD, because dispatch answers HEAD from it. Raise it yourself when a handler, rather than the router, is what decides a method does not apply. detail defaults to Method Not Allowed.

Args:

  • allow — Method names for Allow, joined with commas in the order given.
MethodNotAllowed(detail: str = 'Method Not Allowed', *, allow: Iterable[str] = ()) -> None

NotFound#

404 Not Found -- no resource at this target.

A routing miss is answered as if this had been raised, so a handler registered for NotFound -- or for status 404 -- shapes that response too. A request whose path matches under some other method is a MethodNotAllowed (405) instead, so a 404 means no route claims the path at all. Takes the same detail and headers as HTTPException.

PayloadTooLarge#

413 Content Too Large -- the request body exceeds the configured limit.

Raised by the request-body readers against RequestLimits.max_body_bytes and max_form_memory_bytes while the body is still arriving, so the limit bounds memory rather than merely reporting on it after the fact. Takes the same detail and headers as HTTPException.

RequestHeaderFieldsTooLarge#

Too many header fields, or one too large to be worth parsing.

RFC 6585 §5. Distinct from PayloadTooLarge because the limit that was hit is on the request line and headers, which a client fixes differently: a 413 says "send a smaller body", a 431 says "send fewer cookies". Raised against RequestLimits.max_cookie_bytes when the cookie header is read.

TooManyRequests#

429 Too Many Requests -- the caller is over a rate limit.

Sends retry-after as a delta in seconds when retry_after is given (RFC 9110 §10.2.3, RFC 6585 §4), which is the difference between a client that backs off correctly and one that retries immediately. The header is a MAY, so omitting it is conformant -- it is just less useful. Raise this from an application's own quota check; wreath.policy.ratelimit does not go through it, building its 429 problem response with the header directly.

Args:

  • retry_after — Seconds the client should wait; None sends no header.
TooManyRequests(detail: str = 'Too Many Requests', *, retry_after: int | None = None) -> None

Unauthorized#

401 Unauthorized -- no credentials, or credentials that did not verify.

Sends a www-authenticate header alongside the problem document, because RFC 9110 §15.5.2 requires a 401 to carry a challenge. The default is Bearer; pass a scheme-specific string (Basic realm="admin") to refine it, or challenge=None when a later layer supplies the header itself -- in which case nothing here supplies one. detail defaults to Unauthorized.

Args:

  • challenge — The www-authenticate value; None emits no challenge header.
Unauthorized(detail: str = 'Unauthorized', *, challenge: str | None = 'Bearer') -> None

UnprocessableEntity#

422 Unprocessable Content -- well-formed, but semantically rejected.

Request-binding failures do not arrive here: dispatch catches ValidationError itself and answers a 422 problem document carrying every {"loc", "msg", "type"} object in an errors member, shaped by Wreath.set_validation_formatter() when one is installed. Raise this for an application-level rejection, where there is no field list to carry.

wreath.background#

Response-bound background tasks.

A background task is application work that runs after the complete response has been emitted but still inside the ASGI application invocation. Ownership is explicit: a task belongs to a response, is awaited by Wreath._finish_http once the final response message is sent, and is interrupted by process shutdown like any other in-process work. This is not a durable job queue -- for work that must survive process loss, use an external queue and hand this task only a durable identifier or payload.

BackgroundTask#

A single callable bound with its arguments, run after the response.

BackgroundTask(function: Callable[..., Awaitable[None] | None], /, *args: Any, **kwargs: Any) -> None

BackgroundTasks#

An ordered group of tasks run sequentially after the response.

Tasks execute in insertion order. If one raises, the exception propagates and later tasks do not run. Configure the group before returning the response; it is not a concurrent queue and must not be mutated while it is executing.

BackgroundTasks(tasks: list[BackgroundTask] | None = None) -> None

add_task#

add_task(function: Callable[..., Awaitable[None] | None], /, *args: Any, **kwargs: Any) -> None

wreath.state#

Explicit application-owned and request-owned state.

State#

A small attribute-accessible namespace with explicit ownership.

Wreath keeps runtime state on an owner rather than in a module global, so what a value belongs to is visible from where it is read: app.state lives as long as the application, request.state only for one request.

app.state.pool = await open_pool() # at startup request.state.tenant = tenant # per request

Attributes are stored in a private dict, not on the instance, so any name is settable and reading one that was never set raises AttributeError naming it — the same shape as a plain object, which is what makes a typo look like a typo. Deleting an unset name raises AttributeError too.

This is state, not configuration: nothing here is validated, typed, or persisted, and nothing is shared between processes. Configuration belongs in wreath.config.

A request's State is created on first access rather than per request, so a request whose handler never touches it costs nothing. When the pipeline has classified the request it records route_outcome there, which is the one name a handler should not reuse.

State() -> None

get#

get(name: str, default: Any = None) -> Any

Return the value stored under name, or default when it is absent.

The non-raising read, for a value that is genuinely optional. A stored None and an absent name are indistinguishable through this; use require() when absence is a bug.

Returns:

  • The stored value, or default.

require#

require(name: str) -> Any

Return the value stored under name, refusing to continue without it.

This is the read for something a request cannot proceed without — a connection pool, a tenant resolved by middleware. It raises rather than returning None, so a missing dependency fails at the line that needed it, naming it, instead of surfacing later as an AttributeError on None. A stored None is a value and is returned as one.

Returns:

  • The stored value.

Raises:

  • RuntimeError — Nothing is stored under name.

wreath.config#

Deterministic environment configuration with constrained dotenv semantics.

The dotenv dialect is deliberately narrow. A line is KEY=value and the value is the literal rest of the line: quotes stay as characters, $VAR is not expanded, there is no command substitution, and # starts nothing. A comment line, an export KEY=value line, and any line without an = are rejected rather than skipped -- a config file that quietly means something other than what it reads is worse than one that refuses to parse. Blank lines are ignored, a repeated key takes its last value, and a value must be valid UTF-8. Every refusal is a ValueError naming the line number.

The process environment wins over the file, so a deployment overrides a checked-in dotenv without editing it; override=True inverts that. Nothing mutates os.environ unless you pass apply=True.

Env#

Override the environment key used for one settings field.

Write this inside Annotated. An explicit name is absolute and is not prefixed, which is useful when several settings models share one credential.

Env(name: str) -> None

Environment#

An immutable snapshot of merged process and dotenv state.

Read-only, and a copy: the constructor duplicates values, so later mutation of the source mapping -- including os.environ -- is invisible here, and the Mapping surface offers no setter. A snapshot taken at startup therefore still describes startup.

Every stored value remains the exact str the environment or file supplied. bind() performs startup-only coercion into a dataclass and reports every missing or malformed value together, without mutating the snapshot.

repr prints the key names and no values, deliberately: an environment snapshot holds credentials, and the usual way a credential reaches a log is a traceback frame that repr'd the object holding it.

Args:

  • values — the pairs to snapshot, copied rather than referenced
Environment(values: Mapping[str, str], *, sources: Mapping[str, str] | None = None) -> None

bind#

bind(settings: type[SettingsT], *, prefix: str = '') -> SettingsT

Construct a typed dataclass from this environment snapshot.

Field names become uppercase keys. At the root, prefix="APP" maps debug to APP_DEBUG; nested dataclasses use a double underscore, so database.host maps to APP_DATABASE__HOST. An Annotated[T, Env("EXACT_NAME")] marker supplies an absolute alias. Defaults and default factories are used when their key is absent.

Conversion supports strings, booleans, numbers, Decimal, UUID, Path, ISO date/time values, enums, Literal, optional/union values, comma- separated containers, and Secret[T]. Every problem is collected before SettingsError is raised.

load (classmethod)#

load(path: str | os.PathLike[str], *, search: bool = True, start: str | os.PathLike[str] | None = None, override: bool = False, apply: bool = False) -> Environment

Snapshot a dotenv file merged over the process environment.

The arguments and the precedence are load_env's: process values win unless override=True, and os.environ is untouched unless apply=True. The snapshot holds the merged result, not the file alone.

Args:

  • search — walk parent directories for a relative path before giving up
  • start — directory the search begins in, defaulting to the current one
  • override — let the file's values replace process values instead of losing
  • apply — also write the file's values into os.environ

Raises:

  • FileNotFoundError — the file is absent, or the search reached the root
  • ValueError — a line is not a valid record, naming the line number

source#

source(key: str) -> str

Return the winning source label for key.

Directly constructed snapshots use "provided"; process values use "process" and dotenv values use the resolved file path.

Secret#

A configuration value that must be revealed explicitly.

repr and str are always redacted, including inside a dataclass representation or traceback. reveal() is the only way to recover the wrapped value; this makes a secret reaching a log an explicit action rather than an accidental formatting side effect.

Secret(_value: SecretT) -> None

reveal#

reveal() -> SecretT

Return the wrapped value.

SettingsError#

All missing or invalid values found while binding one settings model.

SettingsError(errors: list[dict[str, str]]) -> None

find_dotenv#

find_dotenv(path: str | os.PathLike[str], *, start: str | os.PathLike[str] | None = None) -> Path

Resolve an explicit dotenv path, optionally searching parent directories.

An absolute path is used as given. A relative one is joined against start -- the current working directory when omitted -- and then against each parent in turn, up to the filesystem root, so a test or a subpackage finds the project's file without knowing how deep it sits.

Args:

  • start — directory the upward search begins in, resolved before walking

Returns:

  • the absolute path of the first file that exists

Raises:

  • FileNotFoundError — no file at an absolute path, or the search reached the root

load_env#

load_env(path: str | os.PathLike[str], *, search: bool = True, start: str | os.PathLike[str] | None = None, override: bool = False, apply: bool = False) -> dict[str, str]

Load one explicitly named dotenv file and merge it with process state.

The parser accepts only KEY=value records with literal values, and rejects a comment, an export prefix, or a line without an = outright; the module docstring gives the whole dialect.

The result is the whole process environment merged with the file, not just the file's keys, so it can be handed to something that expects an environment. Existing process values win unless override=True.

apply=True also writes the file's values into os.environ -- with setdefault, or unconditionally when override=True -- for code that reads os.environ directly instead of taking the returned mapping. It is the only argument here that mutates anything.

Args:

  • search — walk parent directories for a relative path before giving up
  • start — directory the search begins in, defaulting to the current one
  • override — let the file's values replace process values instead of losing
  • apply — also write the file's values into os.environ

Returns:

  • every key of the process environment and the file, merged

Raises:

  • FileNotFoundError — the file is absent, or the search reached the root
  • ValueError — a line is not a valid record, naming the line number

parse_dotenv#

parse_dotenv(object, /)

parse_dotenv(data) -> dict[str, str]

read_osenv#

read_osenv()

read_osenv() -> dict[str, str]

wreath.contracts#

Transport-neutral, startup-compiled dataclass contracts.

Contract#

A reusable validator and JSON codec for one dataclass declaration.

Construction compiles the same native validation plan used by request binding. Instances are immutable declarations in practice: all policy is fixed in __init__ and only compiled callables are retained.

Contract(model: type[T], *, name: str | None = None, version: int = 1, max_bytes: int = 1048576) -> None

decode_json#

decode_json(data: str | bytes | bytearray) -> T

Decode and validate JSON, refusing oversized input before parsing.

encode_json#

encode_json(value: T | Mapping[str, Any]) -> bytes

Validate and encode with Wreath's native JSON kernel.

to_data#

to_data(value: T | Mapping[str, Any]) -> dict[str, Any]

Return the validated value as a JSON-ready mapping.

validate#

validate(value: T | Mapping[str, Any]) -> T

Validate and construct the declared dataclass.

compile_contract#

compile_contract(model: type[T], *, name: str | None = None, version: int = 1, max_bytes: int = 1048576) -> Contract[T]

Compile model into a reusable Contract.

wreath.negotiation#

Content negotiation: serialize a response in the format the client asked for.

Most endpoints return JSON, and JSON stays the default. But a client that sends Accept: application/msgpack (a mobile app minimizing bytes, a service-to- service call) can be served MessagePack from the same handler — the handler returns plain data, and the format is chosen from the Accept header.

from wreath.negotiation import serialize

@app.get("/report")
async def report(request):
    return serialize(request, {"rows": rows})   # JSON or msgpack per Accept

Accept is parsed with q-values per RFC 9110 §12.5.1; an unsatisfiable Accept yields 406 Not Acceptable listing what is available. A negotiated response carries Vary: Accept so a shared cache keys on the negotiated type; the 406 does not, because it varies with nothing a cache should reuse.

The msgpack encoder is C, held to the published msgpack specification vectors by tests/test_msgpack_parity.py.

JSON (value)#

JSON: Serializer = Serializer(media_type='application/json', encode=<function _to_bytes at 0x7f0e06101010>)

MSGPACK (value)#

MSGPACK: Serializer = Serializer(media_type='application/msgpack', encode=<built-in function msgpack_dumps>)

PROTOBUF (value)#

PROTOBUF: Serializer = Serializer(media_type='application/x-protobuf', encode=<function _to_protobuf at 0x7f0e06102980>)

Serializer#

A media type and the function that encodes data to its bytes.

encode takes the handler's data and returns the response body. It is called once per response, on the request's task, and whatever it raises propagates to the handler -- there is no fallback to another format.

Args:

  • media_type — Compared case-sensitively against a lowercased Accept range.
  • encode — Any callable from data to bytes; JSON and MSGPACK are the built-ins.
Serializer(media_type: str, encode: Callable[[Any], bytes]) -> None

negotiate#

negotiate(accept: str | None, serializers: Sequence[Serializer] = (Serializer(media_type='application/json', encode=<function _to_bytes>), Serializer(media_type='application/msgpack', encode=<built-in function msgpack_dumps>))) -> Serializer | None

Pick the best serializer for an Accept header, or None if unsatisfiable.

Ranges are tried best-first (see parse_accept) and the first serializers entry matching one wins, so the offer order decides between two types the client ranked equally. A missing or empty Accept yields serializers[0]; so does */*, because it matches everything and the first offer is the first match.

A q=0 range excludes every type it matches, across the whole header, and that exclusion is applied even when a later wildcard would have matched: application/json;q=0, */* will not return JSON.

Args:

  • serializers — Offers in preference order; an empty sequence always yields None.

Returns:

  • The chosen serializer, or None when nothing offered is acceptable.

parse_accept#

parse_accept(header: str | None) -> list[tuple[str, float]]

Parse an Accept header into (media_range, q) pairs, best first.

Ordered by q (desc) then specificity (exact > type/* > */*), with header order breaking a remaining tie. A q=0 range (explicitly not acceptable) is kept rather than dropped, so negotiate can apply it as an exclusion. Media ranges are lowercased; parameters other than q are discarded, so text/html;level=1 and text/html are one range.

This never raises. A malformed q is read as q=0 -- refusing the range rather than promoting it -- and an empty element is skipped.

Args:

  • header — A raw Accept header value, or None/empty for "no preference".

Returns:

  • (media_range, q) best first; empty when the header is absent or empty.

serialize#

serialize(request: Request, data: Any, *, serializers: Sequence[Serializer] = (Serializer(media_type='application/json', encode=<function _to_bytes>), Serializer(media_type='application/msgpack', encode=<built-in function msgpack_dumps>)), status: int = 200) -> Response

Serialize data in the client's preferred format, or return 406.

The successful response carries the negotiated Content-Type and Vary: Accept, so a shared cache keys on the format rather than serving one client's msgpack to another client's JSON request. An unsatisfiable Accept produces a ProblemResponse listing what was offered -- returned, not raised, so it flows through the ordinary response path.

status applies only to the successful case; the refusal is always 406.

Args:

  • data — Anything the chosen serializer's encode accepts.
  • serializers — Offers in preference order; defaults to JSON then MessagePack.
  • status — Status for the encoded response.

Returns:

  • A Response with the negotiated body, or a 406 ProblemResponse.

wreath.pagination#

Pagination, sorting, and filtering for the ORM query builder.

A thin, safe layer over Select: turn ?page=&size=&sort= query parameters into LIMIT/OFFSET/ORDER BY against an allow-list of columns (never an arbitrary column name from the request), and return a Page with the total.

Consumer-wired, no app.py changes needed:

from wreath.pagination import Page, PageParams, page_params, paginate
from wreath.binding import Depends

@app.get("/llamas")
async def list_llamas(request, params: PageParams = Depends(page_params)):
    page = await paginate(session, Llama.select(), params,
                          allow_sort=("name", "created_at"))
    return page.as_dict()

InvalidPagination#

A client-supplied page, sort, filter, or cursor that is not accepted.

FilterField#

One public filter name compiled to a model column and allowed operators.

FilterField(column: str | None = None, operators: tuple[str, ...] = ('eq',)) -> None

FilterSet#

A bounded declarative map from public filter names to ORM predicates.

Scalar values mean equality. Rich operators use a nested mapping, for example {"created": {"gte": start, "lt": end}, "state": {"in": states}}. No lookup suffix or relationship path is interpreted.

FilterSet(model: type, fields: Mapping[str, FilterField | Iterable[str]], *, max_terms: int = 16) -> None

apply#

apply(query: Select, values: Mapping[str, Any]) -> Select

Apply validated values to query in one immutable where copy.

Listing#

One reusable declaration for filtering and sorting a model listing.

Listing(filters: FilterSet, *, sort: Iterable[str] = (), default_sort: Iterable[str] = ()) -> None

apply#

apply(query: Select, *, filters: Mapping[str, Any] | None = None, sort: Iterable[str] | None = None) -> Select

Apply both declarations without issuing a database query.

Page#

One page of results plus the totals needed to render pagination controls.

Page(items: Sequence[T], total: int, page: int, size: int) -> None

as_dict#

as_dict() -> dict[str, Any]

This page as a JSON-ready dict, derived fields included.

The shape a list endpoint returns: items plus total, page, size, pages, has_next and has_prev, so a client renders controls without recomputing any of them. items are passed through as they are -- serialising them is the response encoder's job.

has_next (property)#

has_next: bool

Whether a page after this one exists, according to total.

Derived from the count, not from having looked -- so it inherits the count's staleness. See paginate on the two snapshots.

has_prev (property)#

has_prev: bool

Whether this is past the first page. Pages are numbered from 1.

pages (property)#

pages: int

How many pages of this size total rows fill, rounding up.

0 for a page size of zero, rather than a division error: a page object is often built to be rendered, and a template asking for the page count should not be where an invalid size surfaces.

PageParams#

Normalized paging + sort request. sort items may be -field for DESC.

PageParams(page: int = 1, size: int = 20, sort: tuple[str, ...] = ()) -> None

CursorPage#

One keyset page and the opaque position for the next page.

CursorPage(items: Sequence[T], size: int, next: str | None) -> None

as_dict#

as_dict() -> dict[str, Any]

has_next (property)#

has_next: bool

CursorParams#

A bounded keyset request: an opaque position, size, and stable order.

CursorParams(after: str | None = None, size: int = 20, sort: tuple[str, ...] = ()) -> None

apply_filters#

apply_filters(query: Select, filters: Mapping[str, Any], *, allow: Iterable[str] | None = None) -> Select

Apply equality filters {field: value} against an allow-list.

Values are handed to the ORM, which coerces/validates them against the column's type -- an out-of-range or wrong-typed value fails at bind time. Richer operators (ranges, in, ilike) are a deliberate follow-up.

The allow-list defaults to sortable_fields(model), so a retrieval column is not filterable by default either: an equality test against a tsvector or a 1536-dimension embedding is a sequential scan that matches nothing.

apply_sort#

apply_sort(query: Select, sort: Iterable[str], *, allow: Iterable[str] | None = None) -> Select

Apply sort tokens (field / -field) against an allow-list.

All tokens are folded into a single order_by call. Select is immutable, so a per-token order_by would recopy a growing tuple on every step -- O(k^2) in the request-controlled token count. Building the orderings once and applying them together keeps this O(k).

The default allow-list is sortable_fields(model), which is every column except the retrieval ones -- see there for why sorting by an embedding is a scan a caller can ask for. Passing allow= explicitly overrides that in both directions.

page_params#

page_params(request: Any) -> PageParams

Bind ?page=&size=&sort= into PageParams. Written as a Depends.

from wreath.binding import Depends
from wreath.pagination import PageParams, page_params, paginate

@app.get("/llamas")
async def list_llamas(request, params: PageParams = Depends(page_params)):
    page = await paginate(session, Llama.select(), params,
                          allow_sort=("name", "created_at"))
    return page.as_dict()

It takes the request and reads the query string itself, because a dependency's own parameters are never bound from the request -- wreath calls a dependency as fn(request, **nested_depends) and nothing else. A dependency that declares request-bound Query() parameters is refused when routes compile.

page and size are clamped into [1, MAX_PAGE] and [1, MAX_SIZE]; anything unparseable falls back to the default rather than raising, so a hand-edited URL degrades to the first page instead of a 422.

Args:

  • request — The request; only its query string is read.

Returns:

  • The normalized page, size, and sort tuple.

cursor_params#

cursor_params(request: Any) -> CursorParams

Bind ?after=&size=&sort= for keyset pagination.

The existing native query kernel owns size and sort parsing. Only the opaque after value is selected here; cursor decoding happens once in paginate_cursor, where the expected columns are known.

paginate#

async paginate(session: Any, query: Select, params: PageParams, *, allow_sort: Iterable[str] | None = None, total: int | Awaitable[int] | None = None) -> Page[Any]

Fetch one page of query and its total.

Pass total (an int or awaitable) when you already have an efficient count; otherwise a correctness-first fallback counts primary keys (see _count).

The count and the page are two statements. Outside an explicit transaction they see two snapshots, so a concurrent insert or delete can make total disagree with items -- the page is right and the total is a moment older, or the reverse. That is usually what a pager wants (a stale total draws a stale last-page number and nothing more); wrap the call in async with session.begin(): when it is not.

paginate_cursor#

async paginate_cursor(session: Any, query: Select, params: CursorParams, *, allow_sort: Iterable[str] | None = None) -> CursorPage[Any]

Fetch a stable keyset page without OFFSET or a count query.

Caller-selected sorts are followed by every primary-key column as a stable tie-breaker. The cursor carries all order values and is bound to that exact order, preventing a token from one listing being silently reused for another.

parse_sort#

parse_sort(raw: str) -> tuple[str, ...]

"name,-created_at" -> ("name", "-created_at"); blank -> ().

sortable_fields#

sortable_fields(model: type) -> tuple[str, ...]

Every column of model a caller may sort or filter by, in declared order.

The default allow-list for apply_sort and apply_filters, and every column except the retrieval ones -- a Vector embedding and a TsVector.

Those are excluded because ?sort=embedding is the same request-triggered cost MAX_PAGE exists to bound, twenty lines above. pgvector gives vector a btree opclass, so ORDER BY embedding is valid SQL that runs: a full sort of the table on values that are kilobytes each, with no index that can serve it, from a query string an anonymous caller controls. Ordering by one is also meaningless -- a vector's btree order is a tie-break, not a ranking, and ranking by similarity is what Vector.cosine_distance and TsVector.rank are for.

This is the default, not a prohibition: apply_sort(..., allow=("embedding",)) still orders by it, for a caller who meant it.

wreath.versioning#

API versioning by URL prefix (default) or Accept-Version negotiation.

Group per-version routes and mount them under /v1, /v2, ... composing with wreath's Router:

from wreath.versioning import VersionedRouter

api = VersionedRouter()
v1 = api.version("1"); v1.get("/llamas")(list_llamas_v1)
v2 = api.version("2"); v2.get("/llamas")(list_llamas_v2)
app.include_router(api.router())          # -> /v1/llamas and /v2/llamas

Prefix versioning is the routed form and the default: the version is a path segment, so an unknown one matches no route and the app answers its ordinary 404, which -- like every framework error -- is RFC 9457 application/problem+json, not {"detail": ...}.

Header negotiation (Accept-Version: 2) is opt-in via negotiate_version, and it resolves a version string for a handler to branch on. It does not dispatch, and it never refuses: an absent, blank, or unsupported header resolves to the caller's default. Full header-routed dispatch would need a middleware and is a deliberate follow-up.

VersionedRouter#

Collects per-version Routers and mounts each under a version prefix.

There is no app.versioned(...) convenience method, and that is the current limitation: build the per-version routers here, then mount them yourself with app.include_router(api.router()).

Mounting copies routes into the parent under a prefix, so it is a one-time startup act -- adding a route to a version router after mounting does not reach the app.

Args:

  • prefix_template — format string for each mount point, given version
VersionedRouter(*, prefix_template: str = '/v{version}') -> None

add#

add(tag: str, router: Router) -> None

Register an already-built Router under version tag.

Replaces, without warning, any router already registered for that tag.

mount#

mount(parent: Router) -> Router

Include every version router into parent under its prefix.

Mounts in sorted tag order and returns parent, so the call chains onto a router that already carries a prefix of its own.

router#

router() -> Router

A fresh Router with all versions mounted, ready to include.

Call this after every version's routes are registered; a route added to a version afterwards does not reach the router this returned.

version#

version(tag: str) -> Router

Return (creating if needed) the Router for version tag.

Repeated calls with the same tag return the same router, so several modules can add routes to one version without coordinating who created it.

versions (property)#

versions: tuple[str, ...]

The registered tags, sorted as strings -- so "10" precedes "2".

negotiate_version#

negotiate_version(request: Any, *, default: str, supported: Iterable[str]) -> str

Resolve the requested version from Accept-Version, else default.

This never refuses. An absent header, a blank one, a version outside supported, and an object with no header accessor all resolve to default; there is no 406 and no problem response. A handler that must reject an unknown version compares the raw header itself and raises. Nor is default validated -- it is returned stringified whether or not it appears in supported.

Reads through Request.header(). Request.headers is the raw list[tuple[bytes, bytes]] and is not a mapping. An object without the accessor resolves to default.

Args:

  • supported — the version tags this endpoint serves, compared as strings

Returns:

  • the negotiated tag, or default when the header names none of them

version#

version(tag: str) -> Callable[[_T], _T]

Tag a handler or Router with an API version (readable metadata).

The tag is stored under VERSION_ATTR and stringified. It is metadata only: nothing in routing or dispatch reads it, so tagging a handler does not mount it anywhere. Use it to inspect or group what you already mounted.

wreath.validation_errors#

Shaping and translating validation errors.

Wreath already answers a failed body/query/path validation with RFC 9457, which is the right envelope. What applications add on top is presentation: their own field names, their own wording, and their own language:

from wreath.validation_errors import MessageCatalogue, catalogue_formatter

catalogue = MessageCatalogue({
    "en": {"missing": "This field is required.", "int": "Enter a whole number."},
    "fr": {"missing": "Ce champ est obligatoire.", "int": "Entrez un entier."},
})
app.set_validation_formatter(catalogue_formatter(catalogue))

The default output is unchanged from what Wreath has always produced, so installing nothing keeps today's behaviour byte for byte.

An error carries a machine-readable type ("missing", "int", "too_complex", ...) produced by both the Python validator and _native/validate.c. That is the catalogue key -- translate on type, never on the English msg, which is a developer-facing default and may change.

Formatting remains Python because it happens only on a 422. Language negotiation is the reusable exception: one native selector owns the q-value scan and returns the chosen offered object, so Python is materialized only at the header and catalogue boundaries.

MessageCatalogue#

Per-language messages keyed by an error's type.

The first language given is the default and the negotiation fallback. A language missing a key falls back to the default language's message, and a key missing everywhere leaves the validator's own msg in place -- a partial translation degrades to English rather than to a blank.

MessageCatalogue(messages: Mapping[str, Mapping[str, str]]) -> None

default_language (property)#

default_language: str

for_request#

for_request(accept_language: str | bytes | None) -> str

languages (property)#

languages: list[str]

message#

message(language: str, kind: str, fallback: str) -> str

catalogue_formatter#

catalogue_formatter(catalogue: MessageCatalogue, *, aliases: Mapping[str, str] | None = None, detail: str = 'Request validation failed') -> ValidationFormatter

A formatter that translates each error's msg via catalogue.

aliases renames the last path segment of a location, so an external API can expose userName for a Python user_name without the handler signature changing.

The emitted problem carries the negotiated language in a language extension, so a client can tell what it was served.

default_validation_problem#

default_validation_problem(errors: list[dict[str, Any]], request: Request) -> ProblemDetail

Exactly what Wreath produced before formatters existed.

select_language#

select_language(accept_language: str | bytes | None, offered: Sequence[str]) -> str

Pick the best of offered for an Accept-Language header.

offered is in preference order and must not be empty; its first entry is the fallback. Matching is case-insensitive and honours prefixes, so a client asking for en-GB is served en when only en is offered. q=0 explicitly refuses a language, and * accepts anything still on offer.

Returns the fallback for a missing, empty, or unparseable header -- a bad Accept-Language is never a client error.

wreath.staticfiles#

Static file serving.

app.static("/assets", "public/") registers a route that serves files from the directory, with path-traversal and symlink containment, conditional requests (ETag / If-None-Match), and streamed bodies via FileResponse:

app = Wreath()
app.static("/assets", "public")

Directory listings are intentionally not provided. index.html is served for directory paths when present.

Files are opened beneath a trusted root directory descriptor (see wreath._fsguard): the file that is checked is the file that is served, so there is no revalidate-then-reopen-by-name window, and no symlinked component can redirect the request outside the root.

StaticFiles#

A handler serving files under one directory subtree.

Register it with app.static(prefix, directory) rather than constructing it directly; the route it needs captures the rest of the path as path_params["path"]. Calling the instance serves one request.

What one call guarantees:

  • The file is opened beneath a root directory descriptor held since construction, so no symlinked path component and no .. can leave the subtree, and there is no window between the check and the open.
  • A missing, unreadable, or escaping path raises NotFound -- the same 404 either way, so probing cannot distinguish them.
  • Every response advertises Accept-Ranges: bytes and carries a strong ETag derived from mtime and size, plus Last-Modified.
  • Conditional requests are answered before ranges. A matching If-None-Match returns 304 with the validators and no body.
  • A Range is honoured only if If-Range (when present) still matches; a stale If-Range sends the whole file, and an unsatisfiable range returns 416 carrying Content-Range with the current size.
  • With html_index, a directory reached without a trailing slash returns a 308 to the canonical path, so relative links in the index resolve correctly rather than one level up.

Directory listings are deliberately not implemented; a directory either serves its index.html or 404s. Lookups run on the instance's own bounded thread pool, so a burst of static requests cannot exhaust the loop's default executor and stall unrelated work.

Args:

  • directory — Must exist at construction; resolved to a real path and pinned by fd.
  • html_index — Serve index.html for a directory path; when False a directory 404s.
  • cache_control — Emitted as Cache-Control on every response. None sends none.
  • max_workers — Lookup threads. The work is one openat, so more buys queueing.

Raises:

  • ValueError — directory does not exist or is not a directory.
StaticFiles(directory: str | os.PathLike[str], *, html_index: bool = True, cache_control: CacheControl | None = None, max_workers: int = 8) -> None

close#

close() -> None

Shut the lookup pool down and release the root descriptor. Idempotent.

Waits for lookups already in the pool, because releasing the root descriptor requires it: every lookup calls openat against that descriptor, and closing it under a running lookup either fails the request or -- once the kernel hands the number to the next open in the process -- resolves the path beneath something else entirely. Not waiting is what made the descriptor unreleasable, so the two halves are one change rather than two. Lookup admission is bounded by max_workers before submission, so executor shutdown never inherits an unbounded backlog.

Wreath.static() mounts are closed for you on lifespan shutdown, and on a failed startup, since the instance it builds is reachable only as a route handler. Call this yourself when you constructed the StaticFiles directly, after the server has stopped accepting. A lookup submitted after it raises RuntimeError from the pool, as it did before.

wreath.templates#

Wreath's small, safe server-side template system.

Templates compile once (at startup) into a flat opcode tape and render escaped HTML by default:

templates = TemplateDirectory("templates")
table = templates.compile("table.html")
return HTMLResponse(table.render(rows=rows))

The language escapes {{ value }} unless the value is Markup, and supports {% if %}/{% else %}/{% endif %}, {% for x in xs %}/ {% endfor %}, and compile-time {% include "other.html" %}. It evaluates no arbitrary Python.

Rendering is the request-time hot path and runs in C: _native/templates.c executes the tape. Parsing stays in Python -- the compiler, the escaping and the two error types live in src/wreath/_template_tape.py, and the engine is handed the error types so a caller catches one class either way.

Markup#

A string already known to be safe HTML; rendered without escaping.

Template#

A compiled template. Immutable and safe to render concurrently.

Template(tape: tuple[tuple[Any, ...], ...], name: str = '<string>') -> None

from_string (classmethod)#

from_string(source: str, name: str = '<string>') -> Template

Compile source directly. Includes are not resolvable here.

render#

render(max_output: int = 16777216, **context: Any) -> str

Render to a str. Keyword arguments form the template context.

render_bytes#

render_bytes(context: dict[str, Any], max_output: int = 16777216) -> bytes

Render to UTF-8 bytes from an explicit context mapping.

TemplateDirectory#

Compiles templates from a filesystem directory, resolving includes.

Sources are read at compile time; compile returns a ready Template. Nothing touches disk on the render path.

TemplateDirectory(root: str | os.PathLike[str], *, encoding: str = 'utf-8') -> None

close#

close() -> None

Release the trusted root descriptor. Safe to call more than once.

compile#

compile(name: str) -> Template

TemplateRenderError#

Raised at render time (undefined name, bad lookup, overflow).

TemplateRenderError(message: str, *, line: int = 0) -> None

TemplateSyntaxError#

Raised at compile time for malformed template source.

TemplateSyntaxError(message: str, *, line: int = 0) -> None

escape#

escape(value: str) -> str

Escape the five HTML-significant characters.

wreath.openapi#

OpenAPI 3.1 document generation from typed route signatures.

The same signature inspection that drives request binding (wreath.binding) produces the schema, so the docs can never drift from the validation:

app = Wreath()
app.enable_docs()          # /openapi.json and /docs

Dataclass bodies become component schemas; typed path/query parameters become parameter objects; and supported return annotations become response schemas at the route's declared status. Field constraints, additional responses, security, deprecation and schema visibility share the route metadata used at runtime. An unsupported annotation is refused instead of silently becoming {}.

generate_openapi#

generate_openapi(app: Any, *, title: str = 'Wreath', version: str = '0.1.0') -> dict[str, Any]

render_docs_body#

render_docs_body(app: Any, *, title: str = 'Wreath', version: str = '0.1.0', try_it_out: bool = False) -> str

Build the (nonce-free) inner docs HTML for app. Expensive part -- caches well; the per-request shell wraps it with a CSP nonce.

render_docs_shell#

render_docs_shell(*, title: str, version: str, spec_path: str, nonce: str, body: str, try_it_out: bool = False) -> str

Wrap a cached docs body in the outer shell, injecting the per-response CSP nonce on the single inline <style>/<script>.

render_models#

render_models(model: Any) -> str

Render the component schemas of an ApiModel as escaped HTML.

render_operations#

render_operations(model: Any, try_it_out: bool = False) -> str

Render the operation cards of an ApiModel as escaped HTML (a str of already-safe markup). Grouped by first tag.

wreath.testing#

In-memory test client: drive a Wreath (or any ASGI) app without a server.

The client is asynchronous. Enter it with async with and await every request; there is no synchronous mode.

async with TestClient(app) as client: # runs lifespan response = await client.get("/items", params={"q": "bolt"}) assert response.status == 200 assert response.json() == {...}

async with client.websocket("/ws") as ws: # performs connect/accept await ws.send_text("hi") assert await ws.receive_text() == "hi"

Requests execute the application directly — no sockets, no threads — so tests observe exactly the ASGI messages a server would.

Two differences from httpx and requests catch people out. The status is response.status, never response.status_code; and an error body is RFC 9457 application/problem+json (type/title/status/detail), never {"detail": ...}.

ChatTranscript#

ChatTranscript(chat: Any, *, provider: str, installation: str, tenant: str, actor: str, conversation: str, external_identity: Any, native: Any) -> None

action#

async action(name: str, **arguments: Any) -> ChatTurn

command#

async command(name: str, **arguments: Any) -> ChatTurn

event#

async event(name: str, **arguments: Any) -> ChatTurn

turns (property)#

turns: tuple[ChatTurn, ...]

ChatTurn#

ChatTurn(kind: 'str', name: 'str', arguments: 'Mapping[str, Any]', reply: 'Any', events: 'tuple[Any, ...]', context: 'Any')

ChatTurn(kind: str, name: str, arguments: Mapping[str, Any], reply: Any, events: tuple[Any, ...], context: Any) -> None

TestClient#

Drive an ASGI application in-process, with no server and no socket.

The client is asynchronous. Enter it with async with and await every request — there is no synchronous mode, and calling client.get("/") without awaiting it returns a coroutine, not a response:

async with TestClient(app) as client: response = await client.get("/items", params={"q": "bolt"}) assert response.status == 200

Entering the context manager runs the application's lifespan startup and exiting runs its shutdown, so anything opened at startup — a database pool, a background worker, a warmed route table — is live for the requests in between. A startup or shutdown that fails, or an application that raises instead of replying, is turned into a RuntimeError naming the phase rather than a hang. A client used without async with still serves requests, but the lifespan never runs and startup state is missing.

Requests call the application object directly. Nothing is serialized onto a socket, so a test sees the exact ASGI messages the app produced, and a handler's exception is converted by the application's own error handling into a response — an RFC 9457 problem document — not re-raised at the call site. Responses are TestResponse, whose status is .status.

app may be any ASGI 3 application; nothing here is Wreath-specific except acting_as(), which needs an application that exposes configure_auth.

identity is the mechanism behind acting_as() and does nothing on its own — the identity is placed on the request scope, and only the backend that acting_as() installs ever reads it. Call acting_as() instead.

Args:

  • app — The ASGI application to drive.
  • headers — Sent on every request; a per-request headers= entry wins on collision.
  • identity — The identity acting_as puts on the scope; setting it alone does nothing.
TestClient(app: Any, *, headers: dict[str, str] | None = None, identity: Any = None) -> None

acting_as#

acting_as(principal: Any, *, roles: Iterable[str] = (), permissions: Iterable[str] = (), type: str = 'User') -> TestClient

A client whose every HTTP request arrives authenticated as principal.

The point is to test authorization without re-deriving a token for every case:

async with TestClient(app) as client: admin = client.acting_as("root", roles=["admin"]) rider = client.acting_as("bo", roles=["rider"])

assert (await admin.delete("/llamas/7")).status == 204 assert (await rider.delete("/llamas/7")).status == 403

principal is a wreath.auth.Identity, or an id to build one from with roles/permissions. Passing both a complete Identity and roles or permissions raises TypeError, because two sources for one fact is how a test ends up lying about itself.

Derived clients share the application, the lifespan, and the default headers of the client they came from, so make as many as there are roles. Do not enter one with async with — it is already live, and entering it would start a second lifespan against the same app.

The identity rides the request scope, not the backend, so requests from two acting-as clients may be in flight at once without seeing each other's identity. It reaches HTTP requests only: websocket() builds its scope from scratch and carries neither the identity nor the client's default headers.

Authentication runs only for routes that declare a requirement, so a deliberately open route still sees request.identity is None under an acting-as client. That is the application's rule, not the client's.

This bypasses authentication. While any acting-as client exists the application's authentication backend is replaced with one that trusts the scope, and the original is put back when the client exits — unless the application had no backend configured, in which case the scope backend stays installed on that application object for its lifetime. Bypassing is the right trade for an authorization test and the wrong one for a test of authentication; use a real token there.

Args:

  • principal — A wreath.auth.Identity, or an id string to build one from.
  • roles — Roles for the built identity; rejected when principal is an Identity.
  • permissions — Permissions for the built identity; rejected the same way.
  • type — Principal type recorded on a built identity, matching the policy vocabulary.

Returns:

  • A derived client sharing this one's application and lifespan.

Raises:

  • TypeError — A complete Identity was passed together with roles or permissions.

chat#

chat(name: str, *, provider: str, installation: str, tenant: str, actor: str, conversation: str, external_identity: Any = None, native: Any = None) -> ChatTranscript

Open a provider-neutral ChatOps transcript against this application.

delete#

async delete(path: str, **kwargs: Any) -> TestResponse

Send a DELETE request; keywords are those of request().

get#

async get(path: str, **kwargs: Any) -> TestResponse

Send a GET request; keywords are those of request().

async head(path: str, **kwargs: Any) -> TestResponse

Send a HEAD request; keywords are those of request().

options#

async options(path: str, **kwargs: Any) -> TestResponse

Send an OPTIONS request; keywords are those of request().

patch#

async patch(path: str, **kwargs: Any) -> TestResponse

Send a PATCH request; keywords are those of request().

post#

async post(path: str, **kwargs: Any) -> TestResponse

Send a POST request; keywords are those of request().

put#

async put(path: str, **kwargs: Any) -> TestResponse

Send a PUT request; keywords are those of request().

query#

async query(path: str, **kwargs: Any) -> TestResponse

Send a QUERY request; keywords are those of request().

request#

async request(method: str, path: str, *, headers: RequestHeaders | None = None, params: dict[str, Any] | None = None, content: bytes = b'', json: Any = None) -> TestResponse

Send one request through the application and collect the response.

The verb helpers — get(), query(), post(), put(), patch(), delete(), options(), head() — forward here, so every keyword documented below works on all of them.

A query string already present in path is kept and params is appended to it, so get("/items?page=2", params={"q": "bolt"}) sends both. Passing json= encodes the value, sets Content-Type to application/json, and overwrites content; a json=None is indistinguishable from passing nothing, so a literal JSON null body must go through content=b"null". Content-Length is set only when there is a body.

The body is delivered as a single http.request message with more_body false, which is what a streaming handler sees.

Args:

  • method — HTTP method, uppercased for you.
  • path — Request path, optionally already carrying a query string.
  • headers — Merged over the client's default headers for this request only.
  • params — Query parameters, urlencoded and appended to any existing query.
  • content — Raw request body, ignored when json is given.
  • json — Value to encode as a JSON body, setting the content type.

Returns:

  • The completed response.

Raises:

  • RuntimeError — The application returned without sending any response message.

websocket#

websocket(path: str, *, headers: dict[str, str] | None = None, subprotocols: list[str] | None = None) -> WebSocketTestSession

Build an unconnected WebSocketTestSession for path.

Nothing runs until the returned session is entered with async with; that is what performs the connect/accept handshake. A query string in path is passed through to the scope.

The scope is built from scratch: unlike the HTTP verbs, this carries neither the client's default headers nor an acting_as() identity, so a WebSocket endpoint that authenticates must be given its credentials through headers here.

Args:

  • path — Connection path, optionally carrying a query string.
  • headers — Handshake headers; the client's default headers are not added.
  • subprotocols — Offered subprotocols, placed on the scope for the endpoint to pick.

Returns:

  • A session to enter with async with.

with_headers#

with_headers(**headers: str) -> TestClient

A client that sends headers on every request, plus this client's own.

Header names arrive as keyword arguments and are sent verbatim, so an underscore is not rewritten to a hyphen: with_headers(authorization=...) is fine, and a hyphenated name such as x-trace-id cannot be spelled here at all — pass those in a request's headers= argument instead. On a collision the new value wins.

The derived client shares this one's application, lifespan, and acting-as identity; do not enter it with async with. Like acting_as(), the headers apply to HTTP requests only, not to websocket().

Returns:

  • A derived client sharing this one's application and lifespan.

TestResponse#

One completed response, assembled from the ASGI messages the app sent.

The status code is status — not status_code. A TestResponse is not an httpx or requests response, so response.status_code raises AttributeError, which is the single most common surprise when porting a test suite.

Three attributes carry the whole response, and nothing is lazily fetched:

  • status — the integer status code.
  • headers — the raw ASGI header list, lowercase-name byte pairs, in the

order the application emitted them.

  • body — the complete body as bytes, with every http.response.body

chunk already joined.

An error response carries an RFC 9457 problem document under application/problem+json, not {"detail": ...}. A 500 reads {"type": "about:blank", "title": "Internal Server Error", "status": 500, "detail": "Internal Server Error"}, so assert on body["title"] or body["detail"] rather than on a detail string alone.

TestResponse(status: int, headers: list[tuple[bytes, bytes]], body: bytes) -> None

header#

header(name: str, default: str | None = None) -> str | None

Return one response header as text, or default when it is absent.

The name is matched case-insensitively and the first match wins, so this cannot read the second of a repeated header such as Set-Cookie; read headers directly for those. Values are decoded as latin-1, the HTTP wire encoding.

json#

json() -> Any

Decode the body as JSON.

The response's Content-Type is not consulted, so this also decodes a problem+json error body.

Returns:

  • The decoded JSON value.

Raises:

  • ValueError — The body is not valid JSON; an empty body is a ValueError too.

text (property)#

text: str

The body decoded as UTF-8, raising UnicodeDecodeError if it is not.

WebSocketTestSession#

Client side of an in-memory WebSocket connection.

TestClient.websocket() returns one of these, already carrying the scope but not yet connected: the endpoint does not run until the session is entered. Entering it starts the application as a task, sends websocket.connect, and waits for the reply, so the handshake has completed by the time the body of the async with runs.

async with client.websocket("/ws") as ws: await ws.send_text("hi") assert await ws.receive_text() == "hi"

Entering raises ConnectionError when the application closes instead of accepting — a rejected handshake, an authorization failure — with the close code in the message, and RuntimeError when the first message is neither an accept nor a close. The accepted handshake message itself is kept on accepted, which is how a test reads back a negotiated subprotocol or the headers the endpoint accepted with; it is None before the session is entered.

Exiting sends websocket.disconnect with code 1000 and then awaits the application task, so an exception raised inside the endpoint surfaces from the async with rather than being swallowed. An endpoint that never returns after a disconnect makes exit raise TimeoutError after five seconds instead of hanging the test session.

WebSocketTestSession(app: Any, scope: dict[str, Any]) -> None

receive#

async receive() -> Message

Wait for the next raw ASGI message the endpoint sends.

This is the unfiltered form: the message may be a websocket.send or a websocket.close, and reading a close is how a test observes an endpoint hanging up. It waits indefinitely, so a test that expects a message the endpoint never sends hangs rather than failing — bound it with asyncio.timeout when that is a real possibility.

Returns:

  • The ASGI message dict, exactly as the endpoint sent it.

receive_bytes#

async receive_bytes() -> bytes

Wait for the next binary frame from the endpoint.

Returns:

  • The frame's binary payload.

Raises:

  • RuntimeError — The next message was a close or carried text rather than bytes.

receive_text#

async receive_text() -> str

Wait for the next text frame from the endpoint.

Returns:

  • The frame's text payload.

Raises:

  • RuntimeError — The next message was a close or carried bytes rather than text.

send_bytes#

async send_bytes(data: bytes) -> None

Deliver data to the endpoint as a binary frame.

send_text#

async send_text(data: str) -> None

Deliver data to the endpoint as a text frame.

wreath.aws_lambda#

AWS Lambda Web Adapter for API Gateway payloads and Function URLs.

One adapter instance owns one event loop and one ASGI lifespan across warm invocations. No server socket, thread, global loop, boto3, or vendor runtime dependency is involved.

LambdaAdapter#

Turn API Gateway REST/HTTP API and Function URL events into ASGI.

Construct this object at module scope as the Lambda handler. Startup runs on the first invocation and remains live across warm invocations. Call close in local hosts/tests; AWS may freeze or terminate an execution environment without offering a shutdown callback.

LambdaAdapter(app: Any) -> None

close#

close() -> None

Run ASGI shutdown and close the adapter-owned loop exactly once.

LambdaEventError#

An event is not an API Gateway v1/v2 or Lambda Function URL request.

wreath.serverless#

Serverless deployment adapters without an AWS-only application shape.

GoogleFunctionAdapter#

Adapt a Google Functions Framework HTTP request to one warm ASGI app.

The request is consumed structurally, so importing Wreath does not require Flask or Functions Framework. The returned (body, status, headers) is the response form that framework accepts. One underlying adapter owns the event loop and ASGI lifespan across warm calls.

GoogleFunctionAdapter(app: Any) -> None

close#

close() -> None

azure_function_app#

azure_function_app(app: Any, *, http_auth_level: Any = 'FUNCTION', function_name: str = 'http_app_func') -> Any

Return Azure Functions' native ASGI host around app.

Azure already speaks ASGI through AsgiFunctionApp; this helper keeps the platform import optional and refuses at configuration time when its SDK is absent instead of maintaining a second request translator.

esc
↑↓ navigate↵ openesc close