Data and analysis#
Wreath owns its PostgreSQL driver and model stack. It also owns the bounded analytical and storage primitives that sit beside transactional data.
wreath.postgres#
PostgreSQL driver facade and application-owned workload pools.
AdvisoryLock#
A blocking, session-scoped advisory lock held for an async with block.
The lock pins a single connection from workload (default "write", i.e. the primary) for the lifetime of the block and releases it -- and the lock -- on exit. Because a session-scoped lock is bound to its backend connection, the connection is withheld from the pool while held; hold enough of these concurrently and ordinary queries can starve on acquire. Prefer the xact-scoped Session.lock for request-path exclusion and reserve this for a handful of long-lived fleet locks.
AdvisoryLock(database: Database, key: str, *, namespace: str | None = None, mode: str = 'exclusive', workload: Workload = 'write') -> NoneAdvisoryTryLock#
A non-blocking (or timeout-bounded) session-scoped advisory lock.
Used as async with db.try_lock(key) as held: -- held is the lock handle when acquired, or None when it could not be taken. With timeout set, acquisition blocks up to that many seconds using PostgreSQL's lock_timeout (which inherits the server's fair-ish lock queue), not a client-side spin.
AdvisoryTryLock(database: Database, key: str, *, timeout: float | None = None, namespace: str | None = None, mode: str = 'exclusive', workload: Workload = 'write') -> NoneConnection#
Bounded automatically-pipelined asynchronous PostgreSQL connection.
close (inherited from Connection)#
async close() -> Noneclosed (property, inherited from Connection)#
closed: booldropped_notifications (property, inherited from Connection)#
dropped_notifications: intAsync notifications discarded because the ring overflowed.
execute (inherited from Connection)#
async execute(sql: str, *args: object) -> strfetch (inherited from Connection)#
async fetch(sql: str, *args: object) -> list[Record]fetch_batch (inherited from Connection)#
async fetch_batch(sql: str, *args: object) -> RecordBatchfetchrow (inherited from Connection)#
async fetchrow(sql: str, *args: object) -> Record | Nonefetchval (inherited from Connection)#
async fetchval(sql: str, *args: object) -> objectlisten (inherited from Connection)#
async listen(channel: str) -> NoneSubscribe this connection to asynchronous NOTIFY on channel.
Issues LISTEN and registers interest so the reader task stays alive while idle and routes incoming notifications into the ring. Notifications are delivered through notifications(). A dedicated connection should be held for listening: leasing it back to a pool between notifications would stop draining them (that lifecycle is the caller's concern).
map (inherited from Connection)#
async map(method: ResultMode, statement: object, argument_sets: Any, *, max_in_flight: int = 32) -> list[Any]Run one method operation per argument set, preserving input order.
Every input produces a distinct extended-protocol operation with its own Sync; duplicate inputs run twice and are never coalesced into an IN (...) or deduplicated. Results come back in input order. At most max_in_flight operations are queued at once, so a generator input keeps pipeline depth and retained results bounded. Inside an explicit transaction the driver already forbids concurrent operations, so the window collapses to one and the operations run strictly in order.
statement may be a SQL string or any object exposing .sql.
notifications (inherited from Connection)#
notifications() -> AsyncIterator[Notification]Yield captured NOTIFY messages, awaiting new ones when drained.
The iterator ends when the connection closes. It never blocks while the ring is non-empty, so a burst is delivered without interleaved awaits.
prepared_plan_count (property, inherited from Connection)#
prepared_plan_count: intAutomatic prepared plans currently cached on this connection.
transaction (inherited from Connection)#
transaction() -> _TransactionAn explicit transaction scope over this connection.
async with connection.transaction() as tx: issues BEGIN on entry and COMMIT on clean exit, or ROLLBACK if the body raises. Reads and writes issued through tx run in order, so a read group completes before a dependent write group begins. If synchronization cannot be proven on rollback, the connection is discarded rather than reused.
unlisten (inherited from Connection)#
async unlisten(channel: str) -> NoneStop listening on channel (issues UNLISTEN).
The channel is deregistered immediately; if it was the last one the idle reader may stay parked on the socket until the next message or close() before it observes the empty channel set and exits.
Database#
One logical database with independently bounded workload pools.
Database(name: str, dsn: str, *, pools: Mapping[Workload, PoolConfig | Mapping[str, Any]] | None = None, workload_dsns: Mapping[Workload, str] | None = None, connector: Connector | None = None, shutdown_timeout: float = 10.0) -> Noneacquire#
async acquire(workload: Workload = 'read') -> AnyLease a connection from workload's pool. Pair it with release.
The one acquisition seam in this module: Statement, Statement.map and direct callers all come through here, which is why the Flight Recorder's pool-wait phase can be attributed from this single point.
Raises whatever Pool.acquire raises, plus KeyError when the workload is not configured.
acquire_shared#
async acquire_shared(workload: Workload = 'read') -> AnyLease a connection concurrent statements may share. See PoolConfig.
Additive and internal on purpose. Folding shared= into acquire() instead looked tidier -- one seam, one keyword -- and broke twenty-four tests across jobs, passes and workflow recording, because every Database-shaped double in the tree is written against acquire()'s current signature. A new method they do not have is invisible to them; a new keyword on one they do have is not.
Statement reaches this through _acquire_for_statement, which falls back to acquire() for any database object that does not implement it -- so a double keeps working and simply gets exclusive leases, which is the conservative answer rather than a failure.
counters#
counters() -> AnyThis database's pools, summed, for wreath.metrics.collect.
One reading per database rather than per workload pool: the instance label is the database, and a caller who wants the split reads pool(workload).snapshot(). Summing is right for the four fields that are counts of connections; queue_high_water is a maximum, so it is taken as one rather than added.
Only pools that exist are read. Asking for one that has not been built would create it, and a metrics scrape must not open connections.
lock#
lock(key: str, *, namespace: str | None = None, mode: str = 'exclusive', workload: Workload = 'write') -> AdvisoryLockA blocking, session-scoped advisory lock held across an async with.
The lock pins one connection from workload (the primary by default) for the block's duration. Prefer Session.lock(scope="xact") for request-path exclusion; use this for long-lived fleet locks.
name (property)#
name: strThe logical name this database was registered under.
The handle an application knows it by — the name given to Wreath.postgres, which is also what Wreath.orm(database=...) and the other database-taking helpers name — rather than anything PostgreSQL itself knows. Required, non-empty, and fixed at construction.
pool#
pool(workload: Workload) -> PoolThe pool serving workload.
Available only once the database has started. Before that, the call exists to answer one question — is this workload configured at all? — and answers it by raising one of two different errors, which are worth telling apart.
Raises:
KeyError— this workload is not configured on this database, whether- or not it has started.
InterfaceError— the workload is configured, but the database has not- started, so there is no pool to hand back yet. Inspecting a pool
- before startup is deliberately unsupported.
release#
async release(workload: Workload, connection: Any) -> NoneReturn a connection to the pool it was leased from.
workload must be the one it was acquired with — the connection is handed to that pool, and a pool refuses a connection it did not lease with InterfaceError.
release_shared#
async release_shared(workload: Workload, connection: Any) -> NoneReturn a shared lease to the pool it came from.
run_singleton#
run_singleton(key: str, work: Callable[[], Awaitable[Any]], *, namespace: str | None = None, workload: Workload = 'write', poll_interval: float = 5.0) -> SingletonRunnerRun work once across the fleet, guarded by an advisory lock.
work is a zero-argument callable returning a fresh awaitable. Returns a handle with await handle.stop(); wire it through on_startup / on_shutdown. The guarded critical section must be idempotent.
start#
async start() -> NoneBuild a Pool per configured workload and start them all.
Each pool gets the workload's own DSN when one was supplied and the database's otherwise, opens read-only for read and security_read, and prepares that workload's registered statements on every connection.
All-or-nothing across pools as well as within them: if one fails to start, the pools already up are stopped, the set is cleared, and the exception propagates with started still false. Calling it twice does nothing the second time.
A workload first named by statement() once this call has begun is not started here, and pool() raises KeyError for it.
statement#
statement(name: str, sql: str, *, workload: Workload = 'read') -> StatementRegister one prepared statement, refusing a name already claimed.
The duplicate check and the assignment run under a lock because separately they are not a check at all: two threads registering the same name both passed name in self._statements before either assigned, so both succeeded and the loser was left holding a Statement that no pool ever prepares -- _for_workload only ever sees the survivor. A guard that exists to catch two subsystems claiming one name silently became a lost write instead of the refusal it was written to be.
The window spans a Statement construction, so it does not need a free-threaded interpreter to be real; free-threading merely removes the GIL that makes it rare. Registration happens at startup and on a store's first use, never per request, so a plain lock costs nothing worth measuring. store.py's _prepare_lock guards the tier above this one.
stop#
async stop() -> NoneStop every pool, in reverse start order, within one shared deadline.
shutdown_timeout is the budget for the whole shutdown, not for each pool: whatever an earlier pool spends draining is taken off what the next one gets, and a pool reached after the deadline has passed is stopped with no grace at all. Calling it on a database that never started does nothing.
try_acquire_shared#
try_acquire_shared(workload: Workload = 'read') -> Any | NoneThe uncontended shared lease, taken without a coroutine.
None means it has to be awaited: nothing idle, or the pool is shutting down -- and deliberately also whenever a Flight Recorder phase marker is armed, because acquire_shared is the one seam pool waiting is attributed from and an armed request must keep going through it.
See Pool.try_acquire_shared for why the synchronous path exists.
try_lock#
try_lock(key: str, *, timeout: float | None = None, namespace: str | None = None, mode: str = 'exclusive', workload: Workload = 'write') -> AdvisoryTryLockA non-blocking advisory lock: async with db.try_lock(k) as held:.
held is the handle when acquired or None otherwise. With timeout set, acquisition blocks up to that many seconds via lock_timeout.
try_release_shared#
try_release_shared(workload: Workload, connection: Any) -> boolReturn a shared lease without a coroutine; False if it has to await.
FromDatabase#
FromDatabase(name: 'str | None' = None, workload: 'Workload' = 'read')
FromDatabase(name: str | None = None, workload: Workload = 'read') -> NoneInterfaceError#
Invalid driver use or connection state.
OperationalError#
Transport, authentication, or protocol operation failure.
PipelineFullError#
The connection's bounded pipeline cannot accept another operation.
Pool#
A bounded lease pool for one database workload.
A lease is exclusive by default. PoolConfig.pipeline_depth above 1 lets concurrent statements share a connection so their operations batch into one flight -- see acquire, and the note on that field for the measurement that chose the depth. Anything holding an explicit lease still has the connection to itself, because a shared one cannot carry a transaction.
Pool(dsn: str, config: PoolConfig, *, connector: Connector, read_only: bool, statements: Callable[[], tuple[Statement, ...]]) -> Noneacquire#
async acquire(*, shared: bool = False) -> AnyLease one connection. Always pair it with release.
shared=True allows up to pipeline_depth concurrent holders, so their operations batch into one flight; the default is an exclusive lease.
Three outcomes, tried in this order: an idle connection is handed over immediately; otherwise a new one is opened if the pool is below max_size; otherwise the caller queues until somebody releases one.
Every way of failing is an exception rather than a None, and each says which limit was hit:
Raises:
InterfaceError— the pool has not started, or is shutting down, or- the wait queue already holds
max_queuecallers. A full queue - refuses immediately rather than growing without bound.
TimeoutError—acquire_timeoutseconds elapsed while queued. The- deadline covers the wait only — a connection being opened for
- this caller is not interrupted by it.
borrowed (property)#
borrowed: intHow many connections are leased out right now.
A lease is exclusive, so this is also how many are unavailable to the next acquire. snapshot() reports it alongside the idle count, the queue depth and the high-water mark, and is the one to reach for when more than one of them is wanted at a consistent moment.
counters#
counters() -> AnyThis pool's counters, for wreath.metrics.collect.
queue_high_water is the one worth scraping: it is the only field that is not instantaneous, so it answers "did anything ever wait?" — which a sampler polling waiters will usually miss entirely.
release#
async release(connection: Any, *, shared: bool = False) -> NoneReturn a leased connection, and wake one waiter.
shared must match the acquisition: a shared lease is one of several on that connection, and only the last one out returns it to the pool.
The connection goes back to the idle set and is reused — unless the pool is shutting down or the connection reports itself closed, in which case it is dropped from the pool and closed here instead. Either way a waiter is notified, so a connection that turned out to be dead still frees the capacity it was holding.
Raises:
InterfaceError— this connection was not leased from this pool.- Releasing twice is the usual way to see it, and it is refused
- rather than tolerated because a double release would put one
- connection in the idle set twice and hand it to two callers.
snapshot#
snapshot() -> PoolSnapshotMirrors HTTPClient.snapshot(), so both pools read the same way.
start#
async start() -> NoneOpen min_size connections and begin accepting acquisitions.
Each connection is opened, set read-only when this pool serves a read workload, and has every statement registered for that workload prepared on it before it joins the idle set — so a Statement never pays a prepare on the request path.
All-or-nothing: if any of them fails, the ones already opened are closed and the exception propagates with the pool still unstarted, rather than leaving a half-sized pool that works until it doesn't. Calling it on an already-started pool does nothing.
started (property)#
started: boolWhether start() has completed and acquire() will be answered.
False before the first start(), and false again after stop() returns. It does not go false while a stop is draining — use it to ask "has this pool been brought up", not "is it still healthy".
stop#
async stop(grace_period: float) -> NoneStop accepting acquisitions, drain for grace_period, then close everything.
Draining is best-effort: the pool waits up to grace_period seconds for leased connections to come back, and then closes what is still out anyway. A caller still holding one at that point has it closed underneath it — the grace period is the promise, not the outcome.
Waiting callers are woken immediately rather than left on the queue, and acquire refuses from the first moment of the stop. Calling this on a pool that never started does nothing.
Args:
grace_period— Seconds to wait for leases to be returned. Zero closes- everything at once.
try_acquire_shared#
try_acquire_shared() -> Any | NoneTake an idle connection into the shared set, or return None.
The uncontended shared lease, done in the caller's frame. Returning None means "this one needs the coroutine" -- nothing idle, not started, or shutting down -- and the caller falls through to _acquire_shared, which owns every decision this does not make.
It is a separate synchronous method rather than a branch inside _acquire_shared because the cost being removed is the coroutine: a lease and its release created and stepped seven of them to move one connection between a deque and a dict, which measured 18,732 instructions with no query attached to it. wreath-decomp --suite calibrate prices a non-suspending await at 95.7ns against 49.8ns for a guarded synchronous call, and seven of the first is most of that lease.
The policy stays in one place: this is the same branch _acquire_shared opens with, in the same order, and every other case is still that method's.
try_release_shared#
try_release_shared(connection: Any) -> boolGive back a shared lease without suspending, or return False.
True means the lease is fully returned. False means the connection has to go back through release, which is every case that can await: the pool is shutting down, or the connection reports itself closed and has to be closed here.
Raises:
InterfaceError— this connection holds no shared lease from this- pool. Refused here rather than deferred to the slow path so a
- double release is caught in the frame that made it.
PoolConfig#
PoolConfig(min_size: 'int' = 1, max_size: 'int' = 10, max_queue: 'int' = 256, acquire_timeout: 'float' = 5.0, command_timeout: 'float' = 30.0, statement_cache_size: 'int' = 100, statement_cache_bytes: 'int' = 4194304, pipeline_depth: 'int' = 4)
PoolConfig(min_size: int = 1, max_size: int = 10, max_queue: int = 256, acquire_timeout: float = 5.0, command_timeout: float = 30.0, statement_cache_size: int = 100, statement_cache_bytes: int = 4194304, pipeline_depth: int = 4) -> NonePoolSnapshot#
What the pool looks like right now, for a status page or a pacer.
queue_high_water is the only field that is not instantaneous: it is the deepest the wait queue has ever been, because the interesting question -- "did anything ever wait?" -- has no answer a sampler can be relied on to catch. A pass that paces against pool pressure needs to know the queue was ten deep for fifty milliseconds, and polling waiters will usually miss it.
PoolSnapshot(borrowed: int, available: int, waiters: int, max_size: int, queue_high_water: int) -> NonePostgresError#
An error reported by PostgreSQL.
A subclass that stands for exactly one SQLSTATE declares it as a class attribute; anything raised from a server ErrorResponse passes the code it actually carried. Both spellings answer to .sqlstate.
PostgresError(message: str, *, sqlstate: str | None = None) -> NoneProtocolError#
Malformed or unexpected PostgreSQL wire data.
Record#
RecordBatch#
SingletonRunner#
Run one coroutine at a time across the whole fleet, via an advisory lock.
work is a zero-argument callable returning a fresh awaitable each time it is invoked (a coroutine can only be awaited once, and leadership may be re-established after a failover). The winner holds a dedicated connection and the advisory lock for as long as work() runs; if the process dies, its backend connection drops, PostgreSQL releases the lock, and a follower is promoted within one poll_interval. The guarded work must be idempotent -- failover can hand leadership over mid-flight.
SingletonRunner(database: Database, key: str, work: Callable[[], Awaitable[Any]], *, namespace: str | None = None, workload: Workload = 'write', poll_interval: float = 5.0, jitter: Callable[[float], float] | None = None) -> Nonelead_errors (property)#
lead_errors: intHow many leadership attempts ended in a failure rather than a return.
A work() that fails every time is otherwise invisible: leadership is acquired, dropped, and re-contended on a timer, so the fleet looks busy while nothing is being done. This is the only signal that distinguishes "nobody has needed to lead yet" from "leading has never once worked".
release_errors (property)#
release_errors: intHow many rounds ended with the connection's release itself failing.
Distinct from lead_errors, which counts leadership attempts that failed to do their work. This counts rounds whose work may well have succeeded and whose cleanup did not, so a runner that is quietly failing to hand connections back is visible before the pool runs out of them.
stop#
async stop() -> NoneRelinquish leadership (if held) and stop contending for it.
SparseVector#
A pgvector sparsevec value: a dimension, and the positions that are not zero.
Construct one from a mapping of 1-based index to value, or from anything dict() accepts (an iterable of pairs, another SparseVector's to_dict()):
SparseVector(5, {1: 1.5, 3: 3.5}) # '{1:1.5,3:3.5}/5' to pgvector
SparseVector.from_dense([1.5, 0, 3.5, 0, 0]) # the same valueExplicit zeros are dropped rather than stored. That is what the server does to them, so keeping them would mean a value did not survive its own round trip -- write {2: 0.0}, read back {} -- and a difference that appears only after a write is the worst kind to debug.
Immutable in practice: indices and values are tuples and there is no setter. Two values are equal when their dimension and their non-zero elements are, so SparseVector(5, {1: 1.0}) equals itself across a round trip through the database.
Attributes: dim: The declared dimension -- how many positions exist, not how many are stored. indices: The stored positions, 1-based and ascending. values: The stored values, positionally paired with indices.
SparseVector(dim: Any, elements: Any = ()) -> Nonedim (property)#
dim: intThe declared dimension.
from_dense (classmethod)#
from_dense(values: Any) -> SparseVectorThe sparse form of a dense sequence, dropping its zeros.
The dimension is the sequence's length, so a 30,000-word bag whose vocabulary is fixed keeps its dimension even when a document uses nine of the words.
indices (property)#
indices: tuple[int, ...]The stored 1-based positions, materialized at the Python boundary.
to_dict#
to_dict() -> dict[int, float]The non-zero elements, 1-based index to value.
values (property)#
values: tuple[float, ...]The stored values, materialized at the Python boundary.
Statement#
One named, prepared SQL statement bound to a database and a workload.
Register with Database.statement(name, sql, workload=...) rather than constructing directly; that is what makes the pools prepare it on every connection they open, so running it never pays a parse.
A statement carries its workload, and that is the whole of its routing: each call acquires a connection from that pool, runs, and releases it before returning. Nothing is held between calls, so two statements never share a transaction — for that, use a session.
The four run methods differ only in what they ask the connection for, and each takes the query parameters as positional arguments, bound by position ($1, $2, ...). map runs the same statement over many argument sets on one connection.
Attributes: database: The Database this was registered on. name: The registered name, unique within that database. sql: The statement text. workload: Which pool its connections come from — "read", "security_read" or "write". The two read workloads open their connections with default_transaction_read_only.
Statement(database: Database, name: str, sql: str, workload: Workload) -> Noneexecute#
execute(*args: object) -> Awaitable[str]Run the statement for its effect and return the command tag.
The tag is PostgreSQL's own summary of what happened — "INSERT 0 1", "UPDATE 3" — so it is where a row count comes from when there are no rows to fetch. Rows the statement does produce are discarded.
fetch#
fetch(*args: object) -> Awaitable[list[Any]]Run the statement and return every row, as a list of Record.
The whole result is materialized; there is no cursor here. A statement that can match an unbounded number of rows should carry its own LIMIT.
fetch_batch#
fetch_batch(*args: object) -> Awaitable[Any]Run the statement into a compact row collection.
The batch keeps the familiar sequence and append surface while exposing sort_by(column) so sorting does not allocate a Python key callable or dispatch it once per row. Individual rows remain ordinary Record values when Python observes them.
fetchrow#
fetchrow(*args: object) -> Awaitable[Any]Run the statement and return its first row, or None for no rows.
None means the query matched nothing. It is not an error, and a statement whose first column can itself be null is better read with this than with fetchval, which cannot tell the two apart.
fetchval#
fetchval(*args: object) -> Awaitable[Any]Run the statement and return the first column of the first row.
None for no rows and for a first column that is null — the two are indistinguishable here, which is what fetchrow is for.
map#
async map(method: str, argument_sets: Any, *, max_in_flight: int = 32) -> list[Any]Run this prepared statement once per argument set, in input order.
Acquires one connection for the whole fan-out; each input becomes a distinct Sync-delimited operation (duplicates are not coalesced or deduplicated), and results are returned in input order.
connect#
Open a native PostgreSQL connection.
wreath.orm.model#
Model declaration, object state, and fixed model storage.
ModelMeta compiles a class body into an ordered column layout once, at class creation. Registries later resolve relationships against that layout; no part of the request path inspects annotations or class bodies again.
Every concrete model gets a generated C storage type whose tp_basicsize is fixed, with unboxed inline scalar cells, compact bitmaps, and C descriptors. Assignment validates through the column's PgType.coerce, so declaration and request validation share one type rule.
Model#
Base class for mapped models.
Subclass with table= to map a class to a table:
class User(Model, table="users"):
id: Mapped[int] = column(Int64, primary_key=True)
email: Mapped[str] = column(Text, unique=True)Subclassing without table= declares a reusable mixin whose columns are inherited, in declaration order, ahead of the subclass's own.
Model(**values: Any) -> NoneModelMeta#
Collects ordered column and relationship declarations.
ModelMeta(name: str, bases: tuple[type, ...], namespace: dict[str, Any], **kwargs: Any) -> Noneselect#
select(*fields: Any) -> AnyBegin a SELECT for this model; no arguments selects every column.
enforce_rules#
enforce_rules(instance: Any) -> NoneRaise if any whole-object rule refuses instance.
A CheckViolation is a ValueError, the same thing a refused assignment raises, so a caller that already handles a bad value handles a broken rule without a new except clause.
validate_identifier#
validate_identifier(value: str, kind: str) -> strValidate ORM's unquoted form through the PostgreSQL name authority.
wreath.orm.fields#
Column declarations and the descriptor that backs field access.
A column() in a class body is a prototype. ModelMeta clones one descriptor per concrete model so a column declared on a shared mixin can hold a different storage index in each model that inherits it.
MISSING (value)#
MISSING: _Missing = MISSINGColumn#
A declared column, and the descriptor that reads and writes it.
Column(pg_type: PgType, *, primary_key: bool, nullable: bool, unique: bool, indexed: bool, default: Any, server_default: str | None, references: Any, on_delete: str | None = None, on_update: str | None = None, deferrable: bool = False, checks: tuple[Check, ...] = (), index_method: str | None = None, index_ops: str | None = None, index_with: tuple[tuple[str, str], ...] = ()) -> Nonedatabase_name (property)#
database_name: strexpression (property)#
expression: ColumnExprgenerated (property)#
generated: boolWhether PostgreSQL computes this column rather than the application.
A property rather than a stored flag: it is read on the rare branch of the constructor (a column with no supplied value and no default) and by the migration descriptor, never per assignment -- assignment is refused by the type's own coerce, which is already on that path.
Mapped#
Typing-only marker for a mapped column.
Mapped[int] documents that class access yields a SQL expression and instance access yields an int. It carries no runtime behavior: the registry compiles declarations from column() objects, never from annotations.
column#
column(pg_type: PgType, *, primary_key: bool = False, nullable: bool = False, unique: bool = False, index: bool | str = False, index_ops: str | None = None, index_with: Mapping[str, Any] | None = None, default: Any = MISSING, server_default: str | None = None, references: Any = None, on_delete: str | None = None, on_update: str | None = None, deferrable: bool = False, check: Any = None) -> AnyDeclare a mapped column.
default is a Python value or a zero-argument callable applied when a constructor omits the field. index=True declares one ordinary btree index on this column; index="gin" declares a GIN index instead (the right choice for Jsonb and Array columns queried with the containment and key operators), and index="hnsw" or index="ivfflat" declares a pgvector index on a Vector column.
index_ops names the operator class the index is built with, which is what decides which distance an approximate index can answer:
embedding: Mapped[list[float]] = column(
Vector(1536),
index="hnsw",
index_ops="vector_cosine_ops",
index_with={"m": 16, "ef_construction": 64},
)index_with passes index-method options through to WITH (m = 16, ef_construction = 64). Both require an index=, and both are rendered into DDL rather than bound, so their values are restricted to plain identifiers and numbers. server_default names a database-side default, which makes the column optional on insert and returned by RETURNING. references takes another model's column expression (references=User.id) and records a foreign key.
check takes one constraint from wreath.orm.constraints, or a sequence of them, applied in order after the type has accepted the value:
salary: Mapped[int] = column(Int64, check=Ge(0))
name: Mapped[str] = column(Text, check=[Length(1, 200), Pattern(r"\S")])Checks run on every write -- the constructor, assignment, and a request body -- and cost one comparison each, not one call each. A subclass adds to them with narrow(); nothing removes them.
encode_default#
encode_default(value: Any) -> bytesA deterministic tagged encoding of a column default.
resolve_default#
resolve_default(spec: Any) -> AnyProduce a fresh default value for spec, calling factory defaults.
wreath.orm.constraints#
Business rules, layered onto the column types inside the same single pass.
A column type answers what a value is: an int8 is a 64-bit integer, and nothing else gets in. It cannot answer what a value is allowed to be here: an intern's salary is an int8, but an intern's salary above 50,000 is a business mistake, not a type error.
This module adds that second question without adding a second engine. A check is fused into the column's own coercion, so a column with rules is still one call on the write path -- the same one call a column without rules costs today. compile_column_validator generates that fused function; the loop in validation.py never learns that constraints exist.
Three layers, in the order they run:
- the column's
PgType.coerce-- the type, unchanged and still the single source of the type rules; - the column's
Checkchain -- per-field business rules; - the model's
Ruleset -- whole-object rules over several fields.
Layers 1 and 2 are fused together and run on every write: the constructor, attribute assignment, and the request body validator. Layer 3 needs an object that is finished, so it runs where a whole object is proven at once.
Rules only ever accumulate. narrow appends checks to a column inherited from a base class and cannot remove one, so every Intern that validates is by construction a valid Employee. Widening is not expressible.
Check#
One business rule over a single value that has already passed its type.
A check never sees a value of the wrong type: coercion runs first, so Le(50_000) on an Int64 column compares two integers and can assume it. That is why a check can compile down to a bare comparison.
Subclasses implement source, which returns a Python expression that is true when the value is acceptable. Returning source rather than a predicate is what lets the whole chain fuse into one function with no call per rule.
check_type#
check_type(pg_type: Any, where: str) -> NoneReject a check applied to a type it cannot mean anything for.
source#
source(var: str, ns: dict[str, Any]) -> strPython source for an expression that is true when var is valid.
ns is the namespace the generated function closes over; a check that needs a value it cannot write as a literal binds it there with _bind and refers to it by the returned name.
CheckViolation#
A value of the right type that breaks a business rule.
This is a ValueError on purpose. Every seam that already handles a rejected assignment -- the body validator's per-field except, a constructor call, a plain obj.field = value -- handles a broken business rule the same way, with no new except clause anywhere.
CheckViolation(message: str, kind: str) -> NoneGe#
value >= bound.
source (inherited from _Comparison)#
source(var: str, ns: dict[str, Any]) -> strPython source for an expression that is true when var is valid.
ns is the namespace the generated function closes over; a check that needs a value it cannot write as a literal binds it there with _bind and refers to it by the returned name.
check_type (inherited from Check)#
check_type(pg_type: Any, where: str) -> NoneReject a check applied to a type it cannot mean anything for.
Gt#
value > bound.
source (inherited from _Comparison)#
source(var: str, ns: dict[str, Any]) -> strPython source for an expression that is true when var is valid.
ns is the namespace the generated function closes over; a check that needs a value it cannot write as a literal binds it there with _bind and refers to it by the returned name.
check_type (inherited from Check)#
check_type(pg_type: Any, where: str) -> NoneReject a check applied to a type it cannot mean anything for.
Le#
value <= bound.
source (inherited from _Comparison)#
source(var: str, ns: dict[str, Any]) -> strPython source for an expression that is true when var is valid.
ns is the namespace the generated function closes over; a check that needs a value it cannot write as a literal binds it there with _bind and refers to it by the returned name.
check_type (inherited from Check)#
check_type(pg_type: Any, where: str) -> NoneReject a check applied to a type it cannot mean anything for.
Length#
Bound the length of a string or bytes value.
Length(minimum: int | None = None, maximum: int | None = None) -> Nonesource#
source(var: str, ns: dict[str, Any]) -> strPython source for an expression that is true when var is valid.
ns is the namespace the generated function closes over; a check that needs a value it cannot write as a literal binds it there with _bind and refers to it by the returned name.
check_type (inherited from Check)#
check_type(pg_type: Any, where: str) -> NoneReject a check applied to a type it cannot mean anything for.
Lt#
value < bound.
source (inherited from _Comparison)#
source(var: str, ns: dict[str, Any]) -> strPython source for an expression that is true when var is valid.
ns is the namespace the generated function closes over; a check that needs a value it cannot write as a literal binds it there with _bind and refers to it by the returned name.
check_type (inherited from Check)#
check_type(pg_type: Any, where: str) -> NoneReject a check applied to a type it cannot mean anything for.
Narrow#
A subclass tightening a rule on a column it inherited.
Declared in a class body with narrow. The base's checks still run, first; these are appended. There is deliberately no way to drop an inherited check, so a narrowed model always satisfies the model it narrows.
Narrow(field: str, checks: tuple[Check, ...]) -> NoneOneOf#
Restrict a column to a fixed set of values.
This is how an enum stays in Python. The database keeps enforcing the type; which of its values are meaningful is an application rule, so the schema stays portable.
OneOf(*values: Any) -> Nonecheck_type#
check_type(pg_type: Any, where: str) -> NoneReject a check applied to a type it cannot mean anything for.
source#
source(var: str, ns: dict[str, Any]) -> strPython source for an expression that is true when var is valid.
ns is the namespace the generated function closes over; a check that needs a value it cannot write as a literal binds it there with _bind and refers to it by the returned name.
Pattern#
Require a string to contain a match for a regular expression.
The pattern is searched, not anchored: anchor it yourself with ^ and $ when that is what you mean.
Pattern(pattern: str | re.Pattern[str]) -> Nonesource#
source(var: str, ns: dict[str, Any]) -> strPython source for an expression that is true when var is valid.
ns is the namespace the generated function closes over; a check that needs a value it cannot write as a literal binds it there with _bind and refers to it by the returned name.
check_type (inherited from Check)#
check_type(pg_type: Any, where: str) -> NoneReject a check applied to a type it cannot mean anything for.
Predicate#
An arbitrary rule over one value: the escape hatch.
The function is called with the coerced value and returns true when it is acceptable. Prefer the specific checks where they fit -- they compile to a comparison, while this compiles to a call.
Predicate(function: Callable[[Any], bool], message: str, *, kind: str = 'predicate') -> Nonesource#
source(var: str, ns: dict[str, Any]) -> strPython source for an expression that is true when var is valid.
ns is the namespace the generated function closes over; a check that needs a value it cannot write as a literal binds it there with _bind and refers to it by the returned name.
check_type (inherited from Check)#
check_type(pg_type: Any, where: str) -> NoneReject a check applied to a type it cannot mean anything for.
Rule#
A business rule spanning more than one column.
Declared in a class body with rule. Unlike a check, a rule needs an object that is finished, so it runs once the fields are all in -- not on assignment, where the other fields may not have values yet.
Rule(fields: tuple[str, ...], test: Callable[..., bool], message: str, kind: str, at: str | None) -> Nonecheck_rules#
check_rules(instance: Any) -> list[tuple[str, str, str | None]]Run a model's rules against a finished object.
Returns one (message, kind, at) per broken rule, empty when the object is acceptable. A rule whose columns are not all loaded is skipped rather than guessed at: a field that is absent has already been reported by whoever was proving the fields, and a rule cannot say anything useful about a value that is not there.
compile_column_validator#
compile_column_validator(column: Any, owner: str) -> Callable[[Any], Any]Fuse a column's type and its checks into one callable.
Returns pg_type.coerce itself when the column has no checks, so a plain column keeps costing exactly one call and this whole module stays off its path. With checks, the generated function is still one call: the checks become comparisons inside it rather than calls of their own.
The result is the only thing that proves a value for this column. Both the native and the Python storage assign through it, and the body validator runs it, so the type rules and the business rules cannot drift apart -- there is one function and three callers.
compile_rules#
compile_rules(model: Any) -> tuple[CompiledRule, ...]Resolve each rule's column names to storage indexes, once.
Names are looked up here, at class-creation time, so a rule over a column that does not exist is a declaration error rather than a surprise on the first request that happens to hit it.
narrow#
narrow(field: str, *checks: Check) -> NarrowAdd checks to an inherited column, for this model only:
class Intern(Employee, table="interns"):
salary_cap = narrow("salary", Le(50_000))The attribute name is documentation; the metaclass finds these by type. The base's own checks are unaffected and still run first.
rule#
rule(*fields: str, message: str | None = None, at: str | None = None, name: str | None = None) -> Callable[[Callable[..., bool]], Rule]Declare a whole-object rule over several columns:
class Intern(Employee, table="interns"):
@rule("salary", "tenure_months")
def pay_band(salary: int, tenure_months: int) -> bool:
"an intern past six months cannot be paid more than 40k"
return not (tenure_months > 6 and salary > 40_000)The function takes the named columns' validated values, in the order named, and returns true when the object is acceptable. It is a plain function, not a method: it receives values rather than an instance, so it never touches a descriptor and cannot read a column it did not declare.
The docstring is the error message unless message is given. By default the error is reported against the object; at="salary" reports it against one field instead, which is usually what a form wants.
wreath.orm.relations#
Relationship declarations and explicit load options.
LoadOption#
An explicit request to load one relationship with one strategy.
LoadOption(relationship: Relationship, strategy: str, nested: tuple[LoadOption, ...]) -> NoneRelationship#
A declared relationship, and the descriptor that reads it.
Relationship(target: Any, *, foreign_key: Any, back_populates: str | None, load: LoadStrategy) -> Noneexpression (property)#
expression: RelationshipExprRelationshipExpr#
Class-level access to a relationship; builds load options and predicates.
RelationshipExpr(relationship: Relationship, path: tuple[Relationship, ...] = ()) -> Nonejoined#
joined(*nested: LoadOption) -> LoadOptionLoad this relationship in the same statement with a LEFT JOIN.
selectin#
selectin(*nested: LoadOption) -> LoadOptionLoad this relationship with a second batched statement.
relationship#
relationship(target: Any, *, foreign_key: Any, back_populates: str | None = None, load: LoadStrategy = 'raise') -> AnyDeclare a relationship to target.
target is a model class or its name, resolved only inside the registry that owns both models. foreign_key names the column holding the key: a column on this model makes the relationship to-one, and a column on the target makes it to-many.
load defaults to "raise", so reading the attribute without loading it raises instead of emitting a query. "selectin" and "joined" load it with every query for this model.
wreath.orm.expressions#
Immutable SQL expression nodes.
Operator overloads only build nodes. Nothing here inspects a database, holds a connection, or renders SQL text; the compiler owns rendering and the session owns execution.
Nodes compare by identity, because == is overloaded to build an equality predicate rather than answer one.
BinaryExpr#
An expression that renders where a boolean is expected.
BinaryExpr(operator: str, left: Expression, right: Expression) -> Noneasc#
asc() -> OrderExprOrder by this distance or rank, smallest first.
desc#
desc() -> OrderExprOrder by this distance or rank, largest first.
The one to reach for with .rank(): a relevance score is higher when the row is a better match, which is the opposite of a distance.
is_distance (property)#
is_distance: boolWhether this node evaluates to a distance rather than a boolean.
is_rank (property)#
is_rank: boolWhether this node evaluates to a text-search relevance score.
BooleanExpr#
An expression that renders where a boolean is expected.
BooleanExpr(operator: str, operands: tuple[Predicate, ...]) -> NoneColumnExpr#
A reference to one model column.
ColumnExpr(column: Any) -> Noneall_eq#
all_eq(value: Any) -> BinaryExprvalue = ALL(self) -- every array element equals value.
any_eq#
any_eq(value: Any) -> BinaryExprvalue = ANY(self) -- value is an element of the array.
asc#
asc() -> OrderExprcontained_by#
contained_by(other: Any) -> BinaryExprself <@ other -- reverse jsonb/array containment.
contains#
contains(other: Any) -> BinaryExprself @> other -- jsonb/array containment.
cosine_distance#
cosine_distance(other: Any) -> BinaryExprself <=> other -- cosine distance, the common default. Orderable.
covered_by#
covered_by(region: Any) -> BinaryExprST_Covers(ST_GeogFromText($1), self) — rows inside a region.
The question tier 1 structurally cannot answer: a catchment, a council boundary, an exclusion zone. region is a wreath.geospatial.Polygon, which is built from Coordinates and therefore cannot arrive with its longitude and latitude the wrong way round.
ST_Covers rather than ST_Contains, and not as a preference: ST_Contains(geography, geography) does not exist, so a query reaching for it fails at the database. ST_Covers also answers "yes" for a point exactly on the boundary, which is the reading a catchment wants -- an address on the line is in the district.
The polygon travels out as text and PostGIS lifts it, so there is no bind-only column type and no OID to resolve. Storing polygons is a different feature; this one only queries against them.
Args:
region— ThePolygonto test containment in.
Returns:
- The predicate, ready for
.where().
Raises:
DeclarationError— This is not aGeographycolumn.TypeError—regionis not aPolygon.
desc#
desc() -> OrderExprhamming_distance#
hamming_distance(other: Any) -> BinaryExprself <~> other -- how many bits differ. Orderable.
The query half of binary quantization: over a Bit column, this counts the positions where two signatures disagree, which is the cheap stand-in for distance that lets a 32x smaller index shortlist candidates before the real vectors re-score them.
has_all#
has_all(keys: Any) -> BinaryExprself ?& keys -- the jsonb object has all of these keys.
has_any#
has_any(keys: Any) -> BinaryExprself ?| keys -- the jsonb object has any of these keys.
has_key#
has_key(key: str) -> BinaryExprself ? key -- the jsonb object has this top-level key.
ilike#
ilike(pattern: str) -> BinaryExprin_#
in_(values: Any) -> InExpr | InSubqueryExprself IN (...) against a list of values or a one-column Select.
Args:
values— An iterable of values, rendered asIN ($1, $2, ...); or aSelectprojecting exactly one column, rendered asIN (SELECT ...)so the filter stays in the database.
Returns:
- The membership predicate, ready to pass to
.where().
Raises:
TypeError—valuesis neither an iterable nor aSelect.ValueError—valuesis an empty list. A subquery may legitimately- return no rows, so this applies to the list form only.
DeclarationError— The subquery is not a shapeINaccepts -- see_check_subqueryfor the four refusals and why each exists.
inner_product#
inner_product(other: Any) -> BinaryExprself <#> other -- negative inner product, as pgvector defines it.
Negative so that ordering ascending still puts the most similar row first, which is the whole reason pgvector spells it that way. Read a result of -0.9 as an inner product of 0.9.
is_not_null#
is_not_null() -> UnaryExpris_null#
is_null() -> UnaryExprjaccard_distance#
jaccard_distance(other: Any) -> BinaryExprself <%> other -- one minus the Jaccard similarity. Orderable.
Set overlap rather than positional agreement: it counts the bits set in both against the bits set in either, so two sparse signatures that share their few set bits are close even though most positions agree trivially by being zero in both. That is the difference from hamming_distance, which those shared zeros dominate.
l1_distance#
l1_distance(other: Any) -> BinaryExprself <+> other -- taxicab (L1) distance. Orderable.
l2_distance#
l2_distance(other: Any) -> BinaryExprself <-> other -- Euclidean distance. Orderable.
like#
like(pattern: str) -> BinaryExprmatches#
matches(terms: Any, *, parser: str = 'websearch_to_tsquery') -> BinaryExprself @@ websearch_to_tsquery('<config>', terms) -- a text-search predicate.
The configuration comes from the column's own TsVector declaration, so the query is analysed exactly as the stored vector was; a query analysed under a different configuration matches nothing and looks like missing data.
Args:
terms— The search text, bound as a parameter. Awreath.queries.Param- works here as it does for the distance operators.
parser—"websearch_to_tsquery"(the default) or"to_tsquery".- The default never raises on user input --
",&,!and a lone—are just characters to it.to_tsqueryunderstands&/|/!/<->and raises a syntax error on malformed input,- so choose it only where the query is the application's and the
- error is handled.
Returns:
- The predicate, ready for
.where().
Raises:
DeclarationError— This is not aTsVectorcolumn, orparseris- not one of the two.
model (property)#
model: typenearest#
nearest(centre: Any) -> BinaryExprDistance to centre in metres -- an order key, not a predicate.
order_by(Model.at.nearest(here)) with a limit is the shape an index answers. A number rather than a boolean, exactly as the pgvector distances and ts_rank are, so where() refuses it on its own.
On a Geography column this is PostGIS's <->, which the GiST index answers directly as an Index Scan ... Order By: -- a true nearest-neighbour search rather than tier 1's bounding-box approximation, and measured on the spheroid rather than on a sphere. Both answer metres, so the two tiers are comparable; they differ by the ~0.5% the guide states.
On a Point column it renders the great-circle haversine, unchanged.
not_in#
not_in(values: Any) -> InExpr | InSubqueryExprself NOT IN (...) against a list of values or a one-column Select.
Takes the same operands as in_, with one extra refusal: a subquery projecting a nullable column. SQL's three-valued logic makes NOT IN (SELECT ...) return no rows at all the moment the subquery yields a single NULL, because x <> NULL is unknown rather than true. That passes every test written against data without NULLs and empties a page in production, so it is refused at the call site instead.
Args:
values— An iterable of values, or aSelectprojecting exactly one- NOT NULL column.
Returns:
- The negated membership predicate.
Raises:
DeclarationError— The subquery projects a nullable column, or any of- the refusals
in_also makes.
overlaps#
overlaps(other: Any) -> BinaryExprself && other -- the arrays share at least one element.
path#
path(elements: Any, *, as_json: bool = False) -> _JsonPathA jsonb path extraction completed by a comparison.
Model.data.path(["a", "b"]) == "x" renders (data #>> $1) = $2. as_json=True extracts a sub-document with #> instead, usable with the jsonb operators.
rank#
rank(terms: Any, *, parser: str = 'websearch_to_tsquery') -> BinaryExprts_rank(self, websearch_to_tsquery('<config>', terms)) -- a relevance score.
A number, not a predicate: order by it (.rank(t).desc()) or compare it against a threshold. where() refuses one on its own, because PostgreSQL would refuse it later with a message about the argument of WHERE rather than about the line that wrote it.
Takes the same arguments as matches, and should be given the same terms and parser -- ranking one query while filtering by another is a bug that looks like bad relevance.
within#
within(centre: Any, metres: Any) -> PredicateRows whose point is within metres of centre, great-circle.
On a Geography column this is ST_DWithin(col, $1, $2), which PostGIS plans as an && index condition with the exact spheroidal test filtered over what survives -- literally the shape the tier-1 rendering below hand-builds, which is why both are spelled within().
On a Point column it renders as two things ANDed, and both halves are load-bearing:
self <@ box(point(...), point(...))-- the degree-aligned bounding box, which is the only form a GiSTpoint_opsindex can answer, and therefore the only reason this does not read the whole table.- the exact haversine distance, compared against
metres.
The box is a superset of the circle -- its corners reach about 40% further than its edges -- so dropping the second half would return rows that are not within the radius. Dropping the first half would be correct and would scan everything. Neither is optional.
A circle crossing the antimeridian produces two boxes, ORed, because <@ has no notion of a wrapped edge; one reaching a pole produces a box spanning every longitude. wreath.geospatial.bounding_boxes makes that decision and this method only renders it.
Expression#
Base class for everything that renders into a SQL fragment.
InExpr#
Membership against an explicit value list.
Rendered as IN ($1, $2, ...) because the driver codecs exchange scalars rather than arrays; the operand count is therefore part of the query shape.
InExpr(operator: str, left: Expression, values: tuple[ValueExpr, ...]) -> NoneInSubqueryExpr#
Membership against a one-column subquery: left IN (SELECT ...).
Distinct from InExpr rather than a mode of it, because the two differ in every respect the compiler cares about: the operand count is not part of the query shape (the subquery's own shape is), the bound values live inside a nested Select rather than beside the operator, and the rendered text is a statement rather than a parenthesised list. Sharing one class would mean a branch at each of those points and an attribute that is meaningless in one of the two states.
select is validated at construction by _check_subquery, so a node that exists is one the compiler can render.
InSubqueryExpr(operator: str, left: Expression, select: Any) -> NoneOrderExpr#
One ORDER BY key: a column, or an expression that evaluates to one.
expression is a bare ColumnExpr for an ordinary sort, and a BinaryExpr for the two kinds of ordering that are computed rather than stored -- a vector distance and a text-search rank. The compiler branches on which, because a computed key carries a bound value and so has to render through the operand renderer rather than as a quoted column name.
OrderExpr(expression: Expression, direction: str) -> NonePredicate#
An expression that renders where a boolean is expected.
UnaryExpr#
An expression that renders where a boolean is expected.
UnaryExpr(operator: str, operand: Expression) -> NoneValueExpr#
One bound parameter. The value never reaches SQL text or a cache key.
ValueExpr(value: Any, pg_type: Any) -> Noneand_#
and_(*predicates: Predicate) -> PredicateCombine predicates with AND; a single predicate passes through.
not_#
not_(predicate: Predicate) -> UnaryExpror_#
or_(*predicates: Predicate) -> PredicateCombine predicates with OR; a single predicate passes through.
wreath.orm.query#
Immutable query objects.
Every builder method returns a new Select; nothing mutates in place, so a query can be built once at import time and reused per request without a defensive copy.
Select#
A compiled-on-demand SELECT for one model.
Select(model: type, projection: tuple[ColumnExpr, ...], predicates: tuple[Predicate, ...], includes: tuple[LoadOption, ...], orderings: tuple[OrderExpr, ...], limit_: int | None, offset_: int | None, for_update_: bool) -> Nonebuild (classmethod)#
build(model: type, fields: tuple[Any, ...]) -> Selectfor_update#
for_update() -> SelectLock matched rows; requires a write session inside a transaction.
include#
include(*load_options: LoadOption) -> SelectLoad relationships with this query.
limit#
limit(value: int) -> Selectoffset#
offset(value: int) -> Selectorder_by#
order_by(*expressions: Any) -> SelectOrder rows by columns, .asc()/.desc(), or a vector distance.
A distance orders by similarity, which is the shape a vector search actually has:
Document.select().order_by(Document.embedding.cosine_distance(query))Ordering by a distance is what an HNSW or IVFFlat index answers; a distance in a WHERE is not, and the index will not be used for it.
paginate#
paginate(page: int, size: int) -> SelectShape this query for one page: LIMIT size OFFSET (page - 1) * size.
Pure query-shaping; execution and the total count live in wreath.pagination.
rebound#
rebound(predicates: tuple[Predicate, ...]) -> SelectThis query with its predicates replaced rather than extended.
Every other builder method adds to a query; this one swaps one set of predicates for another, which is what a declared query in wreath.queries needs — it fixes a shape once and substitutes each call's values into it. The caller owes the invariant that the replacements have the same tree structure and column types as the originals; anything else changes the plan-cache key, and a declared query compiling once per shape is the whole point of declaring it.
rebound_orderings#
rebound_orderings(orderings: tuple[OrderExpr, ...]) -> SelectThis query with its ORDER BY keys replaced rather than extended.
The ordering counterpart of rebound, and it exists for the same caller: a declared vector search fixes ORDER BY embedding <=> $n at class-definition time and substitutes the query vector per call. Same invariant -- the replacements must have the same tree structure and column types, because anything else is a different plan-cache key.
where#
where(*predicates: Predicate) -> SelectAdd predicates; repeated calls and multiple arguments combine with AND.
wreath.orm.session#
Request-scoped sessions.
A session owns one leased connection, one identity map, and the pending write sets. It acquires the connection on first use and returns it exactly once, on success, error, or cancellation.
Nothing here loads data implicitly: every statement this module issues is the direct result of an awaited call.
FromORM#
Marks a handler parameter as a request-scoped ORM session.
database names an app.orm() registry; it may be omitted when the application has exactly one.
tenant says where the tenant binding comes from for an isolated registry, and wreath.tenancy.FromTenant() is the usual answer -- the tenant this request resolved to. It exists because without it the only route to a tenant-bound session was constructing Session(registry, workload, tenant=...) by hand in the handler body, which made the framework's own convenience the unsafe spelling and hand-rolling the safe one. An isolated registry with no tenant here is refused at route-compile time.
FromORM(database: str | None = None, workload: Workload = 'read', tenant: Any = None) -> NoneRawQuery#
Unmodified SQL, executed on this session's leased connection.
Wreath does not parse, rewrite, or cache the SQL; results come back as the driver's own Record objects unless models() is used.
RawQuery(session: Session, sql: str, args: tuple[Any, ...]) -> Noneexecute#
async execute() -> strfetch#
async fetch() -> list[Any]fetchrow#
async fetchrow() -> Anyfetchval#
async fetchval() -> Anymodels#
async models(model: type) -> list[Any]Hydrate this result into model instances.
The result must contain every column of the model exactly once, named as in the database. Extra or missing columns are rejected rather than silently dropped, so a drifting query fails loudly.
Session#
One unit of work over one leased connection.
The identity map holds every object this session hydrated, for as long as the session is open. That is what makes the map an identity map -- two reads of one row give one object, and a pending change is not lost to a refetch -- and it means a fetch of a million rows pins a million objects until close(). There is deliberately no ceiling: evicting an entry would detach an object the caller may still be holding, which changes what the ORM means rather than bounding it. Past identity_map_warn_at the session emits one ResourceWarning, so the situation is visible before it is a memory incident. Page large reads, or open a session per page.
Session(registry: Any, workload: Workload, *, tenant: TenantContext | None = None, statement_timeout: float | None = None, identity_map_warn_at: int | None = None, audit: Any = None) -> Noneadd#
add(instance: Any) -> NoneSchedule instance for insertion on the next flush.
begin#
begin() -> AnyOpen a transaction, or a savepoint when one is already open.
check_identity_map#
check_identity_map() -> NoneWarn, once, if this session is holding an unusual number of objects.
Nothing calls this for you. Every automatic placement costs a boundary crossing on a path the whole application pays for -- per fetch, or per session close -- and wreath-request-trace prices each at +1 on the realistic scenario. That is a poor trade for a diagnostic that fires for almost nobody, so the check is a thing you call while chasing a memory problem:
rows = await session.fetch(Big.select()) session.check_identity_map()
Set identity_map_warn_at on the session or the registry to pick the threshold; without one this does nothing.
close#
async close() -> NoneRoll back any open transaction and return the connection exactly once.
closed (property)#
closed: boolconnection (property)#
connection: AnyThe leased connection, or None while the session is still lazy.
count#
async count(query: Select) -> intCount the rows query matches, without fetching or hydrating them.
Emits SELECT COUNT(*) with the query's filters (and any to-one filter joins), ignoring its projection, ordering, and paging. This is what wreath.pagination.paginate uses for the page total, so a large result set costs one aggregate round trip rather than transferring and materializing every matching row.
create#
async create(model: type, **values: Any) -> AnyConstruct, insert, and return one mapped model.
The model constructor remains the single validation path. This is a session convenience, not a second model or repository abstraction.
declared#
async declared(sql: str, args: tuple[Any, ...]) -> list[Any]Run one statement rendered from a declaration, and return its rows.
The seam wreath.series executes through. It exists rather than going via raw because a calculated view is compiled from model metadata the same way a Select is, so it owes the same two checks a Select gets: that the session is open, and that a tenant session is inside the transaction its search_path is bound by. Statement text comes from the compiler, never from a caller.
delete#
delete(instance: Any) -> NoneSchedule instance for deletion on the next flush.
delete_where#
async delete_where(query: Select) -> intDelete rows selected by an explicit predicate and return its count.
fetch#
async fetch(query: Select) -> list[Any]Run query and hydrate every row.
fetch_one#
async fetch_one(query: Select) -> AnyRun query and return one object, or None.
Raises MultipleResultsError when the query matches more than one row; a stricter limit set by the caller is left alone.
flush#
async flush() -> NoneWrite pending inserts, updates, and deletes in a deterministic order.
Outside an explicit transaction this opens one for the flush and commits or rolls it back atomically.
That is also what makes this safe for an isolated-tenant session without the _check_tenant_bound guard the read paths carry: the begin() below binds search_path before any statement runs, so there is no window where tenant-template SQL sees the pooled connection's previous namespace. It is load-bearing rather than incidental -- do not "optimise" the transaction away for a single- statement flush.
get#
async get(model: type, primary_key: Any, *, load: Iterable[Any] = ()) -> AnyFetch one object by primary key, or None.
in_transaction (property)#
in_transaction: boolload#
async load(target: Any, relationship: Any) -> NoneLoad relationship on one object or a sequence, in one batch.
lock#
async lock(key: str, *, scope: str = 'xact', mode: str = 'exclusive', namespace: str | None = None) -> NoneTake a transaction-scoped PostgreSQL advisory lock on this session.
scope="xact" (the default and recommended form) takes a lock that PostgreSQL releases automatically at COMMIT/ROLLBACK; it must run inside async with session.begin(): and rides the connection the transaction already pins, so there is no explicit unlock and no connection-affinity bookkeeping.
Advisory locks ignore search_path, so for an isolated-tenant session the tenant schema is folded into the lock namespace automatically -- two tenants never collide on the same key. Pass namespace to override (for example, to take a deliberately fleet-global lock).
Session-scoped locks (held beyond the transaction) are intentionally not offered here: releasing them correctly is bound to returning the pooled connection, which database.lock(...) / database.try_lock(...) own.
raw#
raw(sql: str | Template, *args: Any) -> RawQueryRun SQL exactly as written on this session's connection.
A t-string is the spelling to reach for when any part of the statement is a value. wreath.sql compiles it to $1-style placeholders and binds what was interpolated, so the value cannot become syntax:
await db.raw(t"SELECT id FROM shipments WHERE reference = {ref}").fetch()A plain str is still accepted, and still means what it always meant: SQL wreath does not parse, rewrite, or cache, with $1 placeholders bound from args. That is the right tool for a statement written in full at the call site. It is the wrong one for a statement assembled from a caller's text, which is what wreath.hardening's SQL101 exists to say -- one character at the quote turns the wrong one into the right one.
registry (property)#
registry: Anyrequire#
async require(model: type, primary_key: Any, *, load: Iterable[Any] = ()) -> AnyFetch one object by primary key, raising when it does not exist.
require_one#
async require_one(query: Select) -> AnyRun query and require exactly one result.
update_where#
async update_where(query: Select, **values: Any) -> intUpdate rows selected by an explicit predicate and return its count.
workload (property)#
workload: WorkloadTenantContext#
A validated, request-scoped tenant binding for an isolated registry.
schema is the tenant's physical PostgreSQL schema; role is an optional database role selected transaction-locally when the deployment relies on PostgreSQL to enforce hostile-tenant isolation. Both are validated as unquoted identifiers at construction, so the transaction-local SET LOCAL statements the session issues can never carry untrusted text.
A context must be resolved from an application-owned tenant directory, never from a request host, path, header, or token. It is transaction-local by construction: the session binds it after BEGIN and PostgreSQL discards it at transaction end, so a pooled connection never leaks one tenant's binding into the next lease.
TenantContext(schema: str, role: str | None = None) -> Nonecompile_session_binding#
compile_session_binding(registries: Any, marker: FromORM) -> tuple[str, Any]Resolve marker to a registry at route-compile time.
wreath.orm.table#
Table-level schema declarations found in a model body by type.
These sit alongside narrow and rule: you declare them as plain class attributes and the metaclass collects them by type, so the attribute name is only documentation:
class Membership(Model, table="memberships", schema=TENANT_SCHEMA):
org_id: Mapped[int] = column(Int64)
user_id: Mapped[int] = column(Int64)
_identity = unique("org_id", "user_id")
_lookup = index("user_id", "org_id")Constraints and indexes declared this way are named deterministically by the migration engine (never by you), so a downgrade drops exactly what an upgrade created. Columns are named by their database name (which is the attribute name).
AllOf#
Two or more predicates joined by AND.
AllOf(operands: tuple[object, ...]) -> NoneEq#
column = literal.
Eq(column: str, value: object) -> NoneFacet#
A declaration another subsystem attaches to a model, found by type.
Columns are the ORM's. What an audit trail wants to redact, what a privacy traversal considers personal, what a sync shape keys on -- those are facts about a model that the ORM does not own and must not have to know about. A facet is how a subsystem outside the ORM declares one on the model itself, next to the columns it talks about, rather than in a registry that drifts from the schema on the next migration.
class Photo(Model, table="photos"):
owner_id: Mapped[UUID] = column(Uuid)
exif_gps: Mapped[str | None] = column(Text, null=True)
_audit = audited(redact={"exif_gps"})Subclass it, give it a namespace, and list in columns every column name the facet mentions. The metaclass does two things with that, and both are the things a subsystem would otherwise get wrong on its own:
- it validates the column names when the class is created, so a facet naming a column the model does not declare is a
DeclarationErrorpointing at the class body, not aKeyErroron the first write; - it refuses two facets sharing a namespace on one model, so "which of the two audit declarations wins" is never a question.
One mechanism rather than one per subsystem: audited(...) and a privacy classification are the same shape with different payloads, and the class kwarg each would otherwise need (Model(audited=True, redact=...)) does not compose -- two subsystems eventually want the same keyword, and the metaclass grows a parameter per feature.
Attribute names are documentation, exactly as for unique and index: the metaclass collects by type.
Facet(columns: tuple[str, ...] = ()) -> NoneIndex#
A multi-column btree index, optionally UNIQUE and optionally partial.
where carries a predicate built from eq, is_null, is_not_null, one_of and all_of. Columns are named by string for the same reason index names them by string -- a model cannot refer to its own attributes from inside its own class body, so the query language (Job.state == "ready") is not available here.
where_sql is the rendered predicate in PostgreSQL's own normal form, set by the registry once column types are known. It is None until then.
Index(columns: tuple[str, ...], unique: bool, where: object = None, where_sql: str | None = None) -> NoneIsNull#
column IS NULL, or IS NOT NULL when negated.
IsNull(column: str, negated: bool) -> NoneInValues#
column IN (...) over two or more literals.
InValues(column: str, values: tuple[object, ...]) -> NoneUnique#
A composite UNIQUE constraint over two or more columns.
Unique(columns: tuple[str, ...]) -> Noneall_of#
all_of(*predicates: object) -> AllOfTwo or more predicates joined by AND.
eq#
eq(column: str, value: object) -> Eqcolumn = value, for a text, integer, or boolean column.
facet#
facet(model: object, namespace: str) -> Facet | NoneThe declaration namespace attached to model, or None.
The read side of Facet. None rather than a raise, because "this model is not audited" is the ordinary answer for most models and making every caller guard would put a try around every read.
index#
index(*columns: str, unique: bool = False, where: object = None) -> IndexDeclare a multi-column btree index by column name.
unique=True makes it a unique index; where= makes it partial:
_claim = index("queue", "run_at", where=eq("state", "ready"))
_dedup = index("queue", "dedup_key", unique=True, where=is_not_null("dedup_key"))is_not_null#
is_not_null(column: str) -> IsNullcolumn IS NOT NULL -- the shape a unique partial index usually wants.
is_null#
is_null(column: str) -> IsNullcolumn IS NULL.
one_of#
one_of(column: str, values: object) -> InValuescolumn IN (...), over two or more literals of one type.
A single-element list is refused: PostgreSQL rewrites IN ('a') to = 'a', so the catalog would never match what was declared. Use eq, which is what that means anyway.
unique#
unique(*columns: str) -> UniqueDeclare a table-level composite unique constraint by column name.
wreath.orm.types#
Explicit PostgreSQL column types.
Every type names one PostgreSQL OID that both driver backends encode and decode. There is no inference and no implicit widening: a column declares the type it has in the database, and values that do not fit it are rejected before any SQL runs.
coerce validates and normalizes a Python value on assignment. to_wire and from_wire convert between that Python value and the representation the driver codec exchanges, and are the identity for everything except JSON.
Geography#
Geography(*, srid: int = 4326) -> ExtensionTypeDeclare a PostGIS geography(Point,srid) column.
The Python value is a wreath.geospatial.Coordinate, the same value type a tier-1 Point column holds, so a model can move between the tiers without its handlers changing.
class Station(Model, table="stations"):
at: Mapped[Coordinate] = column(Geography(), index="gist")Only the point form is declarable. geography also spells polygons and line strings, and wreath has no value type for either -- a geography(Polygon,4326) column would have nothing to hold and nothing to validate, so it is left to a hand-written migration rather than declared with a hole in it.
Requires CREATE EXTENSION postgis in the column's database. A registry that declares one against a database without it fails at startup, naming the extension and the schema, rather than at the first query with an OID error -- the same contract Vector has, because it is the same mechanism.
Tier 1 needs none of this. Point stores a coordinate in core PostgreSQL and answers "within" and "nearest" through a core GiST index. Reach for Geography when you need what tier 1 does not do: a true KNN ordering, containment against a polygon, or a projection.
Halfvec#
Halfvec(dim: int) -> ExtensionTypeDeclare a pgvector halfvec(dim) column -- half-precision, half the storage.
The same Python value as Vector: a list[float] of exactly dim finite floats. What differs is what the database keeps. Each element is stored as an IEEE-754 binary16 rather than a binary32, which halves both the column and any HNSW index over it -- the reason to reach for this at 1536 dimensions and up, where the index is usually the thing that stopped fitting in memory.
class Document(Model, table="documents"):
embedding: Mapped[list[float]] = column(
Halfvec(1536), index="hnsw", index_ops="halfvec_cosine_ops"
)Be deliberate about the precision. binary16 carries about three decimal digits, so a value written and read back is not the value you wrote: 0.1 returns 0.0999755859375. For embedding similarity that is normally irrelevant -- the ranking is what matters and it is robust to the third digit -- but a halfvec is the wrong type for anything you intend to compare for equality or accumulate.
Two refusals, both at coercion time rather than at the server:
- A magnitude above
MAX_HALF_MAGNITUDE(65504) would round to an infinity, which pgvector rejects. Refused here so the error names the element rather than arriving as anINSERTfailure naming neither it nor the column. - NaN and infinity, exactly as for
Vector.
Note the operator classes are the type's own -- halfvec_cosine_ops, not vector_cosine_ops. Naming vector's opclass on a halfvec column is an error pgvector reports at index creation.
Requires CREATE EXTENSION vector (the same extension provides both types).
Array#
Array(element: PgType, *, nullable_elements: bool = False) -> _ArrayTypeDeclare a one-dimensional PostgreSQL array of element.
Each element is validated and wired through element's own rules, so Array(Uuid) accepts a list/tuple of UUIDs and rejects anything else. Elements are non-nullable unless nullable_elements=True. Nested arrays are not supported: an element type may not itself be an array.
Bit#
Bit(length: int) -> PgTypeDeclare a PostgreSQL bit(length) column -- one bit per dimension.
This is the storage half of binary quantization: an embedding whose elements are replaced by their signs, so 1,536 float4s (6,148 bytes) become 1,536 bits (192 bytes), a 32x reduction that keeps enough of the geometry to rank candidates before a re-scoring pass over the real vectors.
class Document(Model, table="documents"):
signature: Mapped[str] = column(
Bit(1536), index="hnsw", index_ops="bit_hamming_ops"
)The Python value is a str of '0' and '1', exactly length of them -- what psql shows and what '101'::bit(3) means. bytes is accepted on the way in as a convenience for the quantizers that produce it (numpy.packbits(...).tobytes()), and is unpacked using the declared length; the trailing bits of the final byte must be zero, since they name positions the column does not have. A read always returns the str, because a bytes return would have to carry its own bit count to be unambiguous.
bit is a built-in type; the operators are pgvector's. hamming_distance and jaccard_distance (<~> and <%>) and the bit_hamming_ops / bit_jaccard_ops index classes come from CREATE EXTENSION vector. So a Bit column needs no extension to store, and there is no OID to resolve -- but a query that ranks by one of those distances does.
Bool (value)#
Bool: PgType = <PgType bool oid=16>Bytea (value)#
Bytea: PgType = <PgType bytea oid=17>Date (value)#
Date: PgType = <PgType date oid=1082>ExtensionType#
A type an extension adds, whose OID the database assigns.
Every built-in type here names a compile-time OID. An extension type cannot: CREATE EXTENSION vector allocates vector's OID from the same sequence as everything else in that database, so it differs between one database and the next, and between a schema and the one beside it if the extension was installed twice.
Three consequences shape this class.
oidstarts at0and is filled in at startup, bywreath.orm.introspection.resolve_extension_types, which readspg_catalogonce and hands the answer to both driver backends. Binding a value before that raises rather than encoding against OID 0.shape_valueis derived from the type's name, never its OID. It is the type's contribution to the plan-cache key: an OID in there would give the same query two cache entries against two databases, and would change under a reinstall of the extension. The failure mode is a silently duplicated plan cache, which is invisible until someone profiles it.fingerprint_oidis likewise name-derived, for the same reason one layer up: a model fingerprint that moved with the database would report drift that is not there.
Nothing here is specific to pgvector. hstore, citext, and geometry would all resolve through the same path.
ExtensionType(extension: str, type_name: str, sql: str, coerce: Callable[[Any], Any], *, kind: int, to_wire: Callable[[Any], Any] | None = None, from_wire: Callable[[Any], Any] | None = None) -> Nonerequire_oid#
require_oid(doing: str) -> intThis type's resolved OID, refusing if resolution never reached it.
Call this wherever the OID decides something -- wire framing, a migration descriptor -- and never where an unresolved type is the legitimate state (declaration, fingerprinting, introspection, the probe that is about to resolve it).
The unbound state is not only "startup has not run yet". bind_extension_oid walks the types declared when it is called, so an ExtensionType constructed afterwards -- a model class defined after the application started -- keeps OID 0 until something resolves again. Zero is a legal-looking OID that means "unspecified" on the wire and "built-in" in a descriptor, so it has to be refused rather than framed.
to_wire#
to_wire(value: Any) -> Anycoerce (inherited from PgType)#
coerce(value: Any) -> AnyValidate value for this type, returning the normalized value.
from_wire (inherited from PgType)#
from_wire(value: Any) -> AnyFloat4 (value)#
Float4: PgType = <PgType float4 oid=700>Float8 (value)#
Float8: PgType = <PgType float8 oid=701>GeneratedType#
A type whose column value the database computes, never the application.
A column declared with one of these renders as GENERATED ALWAYS AS (<expression>) STORED, so PostgreSQL recomputes it inside the same statement that changed the columns it reads. That is what keeps a derived index correct without a trigger, and it is also why such a column is not writable: an INSERT or UPDATE naming one is an error, so coerce refuses the value at the assignment rather than at the flush.
The expression itself is not held here. It names other columns, whose database names and types are only known once the registry has compiled the model, so it is rendered there -- see wreath.orm._generated.
coerce (inherited from PgType)#
coerce(value: Any) -> AnyValidate value for this type, returning the normalized value.
from_wire (inherited from PgType)#
from_wire(value: Any) -> Anyto_wire (inherited from PgType)#
to_wire(value: Any) -> AnyFloat32 (value)#
Float32: PgType = <PgType float4 oid=700>Float64 (value)#
Float64: PgType = <PgType float8 oid=701>Int2 (value)#
Int2: PgType = <PgType int2 oid=21>Int4 (value)#
Int4: PgType = <PgType int4 oid=23>Int8 (value)#
Int8: PgType = <PgType int8 oid=20>Int16 (value)#
Int16: PgType = <PgType int2 oid=21>Int32 (value)#
Int32: PgType = <PgType int4 oid=23>Int64 (value)#
Int64: PgType = <PgType int8 oid=20>Json (value)#
Json: PgType = <PgType json oid=114>Jsonb (value)#
Jsonb: PgType = <PgType jsonb oid=3802>Numeric (value)#
Numeric: PgType = <PgType numeric oid=1700>PgType#
One PostgreSQL type, its OID, and its Python conversion rules.
PgType(name: str, oid: int, sql: str, coerce: Callable[[Any], Any], *, to_wire: Callable[[Any], Any] | None = None, from_wire: Callable[[Any], Any] | None = None) -> Nonecoerce#
coerce(value: Any) -> AnyValidate value for this type, returning the normalized value.
from_wire#
from_wire(value: Any) -> Anyto_wire#
to_wire(value: Any) -> AnyPoint (value)#
Point: PgType = <PgType point oid=600>SparseVector#
A pgvector sparsevec value: a dimension, and the positions that are not zero.
Construct one from a mapping of 1-based index to value, or from anything dict() accepts (an iterable of pairs, another SparseVector's to_dict()):
SparseVector(5, {1: 1.5, 3: 3.5}) # '{1:1.5,3:3.5}/5' to pgvector
SparseVector.from_dense([1.5, 0, 3.5, 0, 0]) # the same valueExplicit zeros are dropped rather than stored. That is what the server does to them, so keeping them would mean a value did not survive its own round trip -- write {2: 0.0}, read back {} -- and a difference that appears only after a write is the worst kind to debug.
Immutable in practice: indices and values are tuples and there is no setter. Two values are equal when their dimension and their non-zero elements are, so SparseVector(5, {1: 1.0}) equals itself across a round trip through the database.
Attributes: dim: The declared dimension -- how many positions exist, not how many are stored. indices: The stored positions, 1-based and ascending. values: The stored values, positionally paired with indices.
SparseVector(dim: Any, elements: Any = ()) -> Nonedim (property)#
dim: intThe declared dimension.
from_dense (classmethod)#
from_dense(values: Any) -> SparseVectorThe sparse form of a dense sequence, dropping its zeros.
The dimension is the sequence's length, so a 30,000-word bag whose vocabulary is fixed keeps its dimension even when a document uses nine of the words.
indices (property)#
indices: tuple[int, ...]The stored 1-based positions, materialized at the Python boundary.
to_dict#
to_dict() -> dict[int, float]The non-zero elements, 1-based index to value.
values (property)#
values: tuple[float, ...]The stored values, materialized at the Python boundary.
Sparsevec#
Sparsevec(dim: int) -> ExtensionTypeDeclare a pgvector sparsevec(dim) column -- only the non-zero positions.
The Python value is a SparseVector, not a list. That is the whole difference from Vector: a dimension here can be a million, and storing a million floats to say that nine of them are non-zero is what the type exists to avoid. A dict alone would not do, because it names no dimension, and the dimension is not recoverable from the elements.
from wreath.postgres import SparseVector
class Document(Model, table="documents"):
terms: Mapped[SparseVector] = column(
Sparsevec(30000), index="hnsw", index_ops="sparsevec_l2_ops"
)
await session.add(Document(terms=SparseVector(30000, {17: 0.9, 4021: 1.4})))Indices are 1-based, matching pgvector's own text form ('{1:1.5,3:3.5}/5'); the binary wire format is 0-based and the conversion lives in the codec, so the two numberings never both appear in application code.
The dimension is checked at coercion: a SparseVector declaring a different one is refused here rather than by the server, because the server's message names neither the column nor which of the two dimensions it expected.
At most MAX_SPARSEVEC_NNZ (16,000) elements may be non-zero, whatever the dimension -- pgvector's own ceiling, and a value denser than that wants Vector or Halfvec instead.
Requires CREATE EXTENSION vector (the same extension provides all three types). Note the operator classes are the type's own -- sparsevec_l2_ops, not vector_l2_ops -- and that pgvector indexes a sparsevec to 1,000 non-zero elements even though the column may hold 16,000.
Text (value)#
Text: PgType = <PgType text oid=25>TextArray (value)#
TextArray: _ArrayType = <PgType text[] oid=1009>Timestamp (value)#
Timestamp: PgType = <PgType timestamp oid=1114>TimestampTz (value)#
TimestampTz: PgType = <PgType timestamptz oid=1184>TsVector#
TsVector(config: str = 'english', *, sources: Any) -> TsVectorTypeDeclare a generated tsvector column over sources.
class Document(Model, table="documents"):
title: Mapped[str] = column(Text)
body: Mapped[str] = column(Text)
search: Mapped[bytes] = column(
TsVector("english", sources=("title", "body")), index="gin"
)That renders GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))) STORED, which is the form that keeps the GIN index correct without a trigger: PostgreSQL recomputes the column inside the same statement that changed title or body, so there is no window where the index disagrees with the row.
Each source must be a declared Text column of the same model. Other types are refused because PostgreSQL's own deparsing of the expression casts them ((COALESCE(vch, ''::character varying))::text), and a spelling wreath cannot predict is a permanent, self-renewing migration drift -- the same reason wreath.orm._index_predicate keeps its vocabulary small.
The value is the database's, not the application's: assigning one raises, and passing one to the constructor raises. Query it with .matches() and .rank() rather than reading it.
TsVectorType#
A tsvector PostgreSQL derives from other columns of the same row.
Carries the two things the DDL needs and nothing else: the text-search config to analyse under, and the sources to analyse. The expression is rendered once the registry knows those columns' database names and types.
TsVectorType(config: str, sources: tuple[str, ...]) -> Nonecoerce (inherited from PgType)#
coerce(value: Any) -> AnyValidate value for this type, returning the normalized value.
from_wire (inherited from PgType)#
from_wire(value: Any) -> Anyto_wire (inherited from PgType)#
to_wire(value: Any) -> AnyUuid (value)#
Uuid: PgType = <PgType uuid oid=2950>Varchar (value)#
Varchar: PgType = <PgType varchar oid=1043>Vector#
Vector(dim: int) -> ExtensionTypeDeclare a pgvector vector(dim) column.
The Python value is a list[float] of exactly dim finite floats. Coercion is strict in both directions that matter: a wrong length is a dimension mismatch the database would reject anyway, and NaN or infinity is refused because pgvector stores neither and every distance involving one is meaningless.
class Document(Model, table="documents"):
embedding: Mapped[list[float]] = column(
Vector(1536), index="hnsw", index_ops="vector_cosine_ops"
)Requires CREATE EXTENSION vector in the column's database. A registry that declares one against a database without it fails at startup, naming the extension and the schema, rather than at the first query with an OID error.
bind_extension_oid#
bind_extension_oid(type_name: str, oid: int) -> intGive every declared type named type_name the OID this database uses.
Returns how many types were bound. Idempotent for a repeated identical OID, which is the ordinary case when several registries share one database.
Raises:
ValueError— the same type resolved to a different OID than a previous- call. One process holds one codec table, so two databases whose
vectorOIDs disagree cannot both be served from it; that is- reported here rather than by decoding one database's rows with the
- other's rules.
declared_extension_types#
declared_extension_types() -> tuple[ExtensionType, ...]Every extension type declared in this process, in declaration order.
wreath.orm.dto#
Dataclass projections over mapped models.
Request and response models often repeat a subset of an ORM model's columns. model_dataclass makes that subset explicit while keeping the result an ordinary dataclass understood by Wreath's binding, OpenAPI, and typegen layers:
LlamaCreate = model_dataclass(
Llama,
include={"name", "birth_date"},
name="LlamaCreate",
)The projection is compiled when the declaration executes, never while binding a request. Its cache belongs to the mapped model itself, so two declarations of the same shape return the same type without a process-global registry.
model_dataclass#
model_dataclass(model: type[Model], *, include: Iterable[str] | None = None, exclude: Iterable[str] = (), name: str | None = None) -> typeCompile an ordinary dataclass from selected mapped columns.
include and exclude are mutually exclusive. Unknown names are refused rather than ignored, because a stale projection silently omitting a renamed field changes the wire schema. Fields retain model order and Python defaults; nullable, generated, primary-key, and server-default columns may be omitted and default to None. All fields are keyword-only, matching Wreath's request binder and avoiding declaration-order restrictions.
wreath.orm.registry#
The application-owned model registry.
A registry binds a set of models to one database. It is the only place that resolves relationships, and the only owner of compiled query plans. Two applications can register the same model classes against different databases without sharing sessions, identity objects, or cached plans.
Registry#
Compiled models for one database.
Registry(database: Any, models: Any, *, validate_schema: ValidateSchema = 'error', query_cache_size: int = 512, query_cache_bytes: int = 8388608, schema_mode: SchemaMode | None = None, statement_timeout: float | None = None, identity_map_warn_at: int | None = None) -> Nonecached_plan#
cached_plan(shape_key: bytes) -> Anycached_plan_count (property)#
cached_plan_count: intcached_prepared_plan#
cached_prepared_plan(declaration: Any) -> tuple[bytes, Any] | NoneThe live plan previously associated with declaration, if any.
compile#
compile(models: tuple[type[Model], ...]) -> NoneResolve every model and relationship, then freeze the metadata.
order_of#
order_of(model: type[Model]) -> intThe model's position in specs: its dependency order for writes.
remember_prepared_shape#
remember_prepared_shape(declaration: Any, shape_key: bytes) -> NoneAssociate an explicit declaration with its registry-owned plan key.
spec_for#
spec_for(model: type[Model]) -> ModelSpecstore_plan#
store_plan(shape_key: bytes, plan: Any) -> Anywreath.orm.schema#
Frozen metadata produced by registry compilation.
These objects are the only model description the request path reads. They are immutable: once Registry.compile() returns, nothing re-derives them.
CENTRAL_SCHEMA (value)#
CENTRAL_SCHEMA: SchemaRef = SchemaRef(kind='central', name=None)TENANT_SCHEMA (value)#
TENANT_SCHEMA: SchemaRef = SchemaRef(kind='tenant', name=None)ColumnRef#
A resolved foreign-key target, with its referential actions.
on_delete/on_update are single PostgreSQL confdeltype-style codes (a no action, r restrict, c cascade, n set null, d set default) so the desired signature compares byte-for-byte with the catalog.
ColumnRef(schema: str | SchemaRef, table: str, column: str, position: int, model_type: type[Model], on_delete: str = 'a', on_update: str = 'a', deferrable: bool = False) -> NoneColumnSpec#
ColumnSpec(python_name: 'str', database_name: 'str', position: 'int', pg_type: 'PgType', nullable: 'bool', primary_key: 'bool', unique: 'bool', indexed: 'bool', default: 'Any', server_default: 'str | None', reference: 'ColumnRef | None', column: 'Column', generated_sql: 'str | None' = None)
ColumnSpec(python_name: str, database_name: str, position: int, pg_type: PgType, nullable: bool, primary_key: bool, unique: bool, indexed: bool, default: Any, server_default: str | None, reference: ColumnRef | None, column: Column, generated_sql: str | None = None) -> Noneindex (property)#
index: intindex_method (property)#
index_method: str | NoneThe access method for this column's index, if any.
index_ops (property)#
index_ops: str | NoneThe operator class this column's index uses, if it names one.
index_with (property)#
index_with: tuple[tuple[str, str], ...]This column's index-method options, as ordered (name, value) pairs.
oid (property)#
oid: intThis column's PostgreSQL type OID.
Read through the type rather than copied at compile time, because an extension type's OID is not known until startup resolves it against the live catalog -- a snapshot taken here would be 0 forever. Every built-in type answers the same constant it always did.
ModelSpec#
ModelSpec(model_type: 'type[Model]', schema: 'str', schema_ref: 'SchemaRef', table: 'str', sql_namespace: "Literal['qualified', 'tenant_search_path']", columns: 'tuple[ColumnSpec, ...]', primary_key: 'tuple[ColumnSpec, ...]', relationships: 'tuple[RelationshipSpec, ...]', storage: 'StorageSpec', by_name: 'dict[str, ColumnSpec]', by_database_name: 'dict[str, ColumnSpec]', by_relationship_name: 'dict[str, RelationshipSpec]' = <factory>, table_uniques: 'tuple[Any, ...]' = (), table_indexes: 'tuple[Any, ...]' = (), fingerprint: 'bytes' = b'')
ModelSpec(model_type: type[Model], schema: str, schema_ref: SchemaRef, table: str, sql_namespace: "Literal['qualified', 'tenant_search_path']", columns: tuple[ColumnSpec, ...], primary_key: tuple[ColumnSpec, ...], relationships: tuple[RelationshipSpec, ...], storage: StorageSpec, by_name: dict[str, ColumnSpec], by_database_name: dict[str, ColumnSpec], by_relationship_name: dict[str, RelationshipSpec] = <factory>, table_uniques: tuple[Any, ...] = (), table_indexes: tuple[Any, ...] = (), fingerprint: bytes = b'') -> Nonequalified_name (property)#
qualified_name: strrelationship#
relationship(name: str) -> RelationshipSpec | Nonetemplate_name (property)#
template_name: strRelationshipSpec#
RelationshipSpec(name: 'str', target: 'ModelSpec', local_columns: 'tuple[ColumnSpec, ...]', remote_columns: 'tuple[ColumnSpec, ...]', cardinality: "Literal['one', 'many']", default_load: "Literal['raise', 'selectin', 'joined']", relationship: 'Any')
RelationshipSpec(name: str, target: ModelSpec, local_columns: tuple[ColumnSpec, ...], remote_columns: tuple[ColumnSpec, ...], cardinality: "Literal['one', 'many']", default_load: "Literal['raise', 'selectin', 'joined']", relationship: Any) -> Noneindex (property)#
index: intSchemaMode#
Startup-selected mapping from logical schemas to PostgreSQL namespaces.
SchemaMode(kind: "Literal['single', 'isolated']", schema: str | None = None, central: str | None = None, isolation: "Literal['namespace', 'role']" = 'namespace') -> Noneisolated (classmethod)#
isolated(*, central: str, isolation: "Literal['namespace', 'role']" = 'namespace') -> SchemaModesingle (classmethod)#
single(schema: str) -> SchemaModeSchemaRef#
A logical or fixed schema reference compiled by a registry.
SchemaRef(kind: "Literal['fixed', 'central', 'tenant']", name: str | None = None) -> NoneStorageSpec#
The fixed layout dimensions for one model.
StorageSpec(field_count: int, relation_count: int, basicsize: int) -> Nonefingerprint_model#
fingerprint_model(schema: str, table: str, columns: tuple[ColumnSpec, ...], relationships: tuple[RelationshipSpec, ...], table_uniques: tuple[Any, ...] = (), table_indexes: tuple[Any, ...] = ()) -> bytesA stable SHA-256 fingerprint of one compiled model.
The encoding is versioned and canonical: it never uses repr() or Python's randomized hash(), so a fingerprint is comparable across processes and runs. Table-level constraints append nothing when a model declares none, so a model without them fingerprints exactly as before.
fingerprint_registry#
fingerprint_registry(specs: tuple[ModelSpec, ...]) -> bytesA stable deployment fingerprint over every model, in order.
wreath.orm.compiler#
Compile immutable query objects into parameterized PostgreSQL statements.
This runs once per query shape and the result is cached on the registry, so it is not on the per-row path; readable, auditable SQL generation is worth more here than speed. Values never reach the SQL text or the cache key.
Only semantics-preserving rewrites happen: duplicate projections collapse, primary keys are added because identity requires them, and duplicate load requests merge. PostgreSQL chooses scans and joins; Wreath chooses only the shape of the data and the number of round trips.
CompiledQuery#
CompiledQuery(sql: 'str', bind_values: 'tuple[Any, ...]', bind_oids: 'tuple[int, ...]', result_model: 'ModelSpec | None', selected_columns: 'tuple[ColumnSpec, ...]', load_plan: 'LoadPlan', shape_key: 'bytes', projected_columns: 'tuple[ColumnSpec, ...]' = ())
CompiledQuery(sql: str, bind_values: tuple[Any, ...], bind_oids: tuple[int, ...], result_model: ModelSpec | None, selected_columns: tuple[ColumnSpec, ...], load_plan: LoadPlan, shape_key: bytes, projected_columns: tuple[ColumnSpec, ...] = ()) -> NoneJoinedStep#
A to-one relationship assembled from a LEFT JOIN in the same statement.
JoinedStep(relationship: RelationshipSpec, columns: tuple[ColumnSpec, ...], offset: int, alias: str, nested: tuple[JoinedStep, ...] = ()) -> NoneLoadPlan#
LoadPlan(columns: 'tuple[ColumnSpec, ...]', joined: 'tuple[JoinedStep, ...]' = (), selectin: 'tuple[SelectinStep, ...]' = ())
LoadPlan(columns: tuple[ColumnSpec, ...], joined: tuple[JoinedStep, ...] = (), selectin: tuple[SelectinStep, ...] = ()) -> NoneSelectinStep#
A relationship loaded by a second, batched statement.
SelectinStep(relationship: RelationshipSpec, nested: tuple[LoadOption, ...] = ()) -> NoneSqlBuilder#
Accumulates SQL text and bind values for one statement.
Public because wreath._series renders its own statements against the same predicate machinery. Placeholder numbering is positional, so a caller must emit text and binds in the order they appear in the SQL.
SqlBuilder() -> Nonebind#
bind(value: Any, oid: int) -> strsql#
sql() -> strtext#
text(value: str) -> Nonecheck_predicate_columns#
check_predicate_columns(model: type, node: Expression) -> NoneRaise if a predicate names a column of some other model.
Select.where cannot check this, because a predicate is built before it meets a query. Nothing needs it per request either -- a handler's predicate is written next to the query it filters. A declared query is different: wreath.queries writes the predicate at class-definition time and runs it much later, so a mistyped model would surface as a broken statement on a request instead of at import. This is the walk that moves it back.
Relationship traversals are checked only at their first hop; the registry owns the rest of the path, and it is not available this early.
compile_count#
compile_count(registry: Any, select: Select) -> tuple[str, tuple[Any, ...], tuple[int, ...]]Compile SELECT COUNT(*) for the rows select matches.
Reuses the filter-join and predicate machinery but drops projection, load joins, ordering, paging, and row locking -- none of them change how many parent rows match. Filter joins are always to-one (the compiler rejects to-many filter joins), so COUNT(*) over the joined-and-filtered rows equals the parent-row count; no subquery wrapper is needed.
Unlike compile_select this is not plan-cached: it is called at most once per page request, not per row, so rendering fresh -- which lets the bound values be captured directly, without the cached bind program -- is both simpler and cheap enough. Returns (sql, values, oids).
compile_declared_expression_values#
compile_declared_expression_values(expressions: Iterable[Expression], placeholder: type) -> Callable[[dict[str, Any]], tuple[Any, ...]]Compile direct bind extraction for a fixed expression sequence.
Declared calculated views emit their predicates more than once inside one statement. They need the same parameter/literal program as a declared ORM query, without first manufacturing a Select merely to reach it.
compile_declared_values#
compile_declared_values(select: Select, placeholder: type) -> Callable[[dict[str, Any]], tuple[Any, ...]]Compile direct parameter-to-wire extraction for a declared query.
Unlike the ordinary cached-plan binder, this program does not need a newly rebound Select: fixed literals come from the declaration and named placeholders read the call's value mapping directly. Parameter names are identifiers validated by Param; they enter generated source only through repr, while types and literal values stay in the closed namespace.
compile_rebind#
compile_rebind(node: Expression, placeholder: type, found: list[Any]) -> Callable[[Any], Any] | NoneCompile a substitution program for one predicate holding placeholders.
A declared query (see wreath.queries) fixes its tree once and varies only the values in it, so the traversal that finds those positions can run at declaration time instead of per call. Returns a function that rebuilds the predicate from a mapping of parameter values, or None when the predicate holds no placeholders at all and can be reused as it stands. Every placeholder encountered is appended to found, in the order it binds, so the caller can name its parameters without a second walk.
This lives here rather than in wreath.queries because the shape of an expression tree is this module's knowledge: it has to stay in step with _append_bind_paths and _shape_expression, and the three are only reviewable together. A placeholder node's bind method owns what a parameter means; this function owns only where one may appear.
compile_select#
compile_select(registry: Any, select: Select) -> CompiledQueryCompile select against registry, using its bounded plan cache.
conjoin#
conjoin(predicates: tuple[Predicate, ...]) -> Predicateplan_filter_joins#
plan_filter_joins(registry: Any, spec: ModelSpec, expressions: Iterable[Expression], clauses: list[str]) -> dict[tuple[Any, ...], str]Emit an INNER JOIN per relationship path expressions reach through.
These joins constrain rows and select nothing: filtering is not loading, so a filtered relation stays unloaded unless the query also .include()s it. INNER rather than LEFT because a parent with no matching child cannot satisfy a predicate on the child's column, and INNER lets PostgreSQL reorder the join.
Takes expressions rather than a Select because a calculated view groups by a column as well as filtering by one, and a GROUP BY on a related column needs the same join a predicate on it would get. Aliases are returned keyed by relationship path, which is what render_predicate reads.
qualified#
qualified(spec: ModelSpec) -> strquote#
quote(identifier: str) -> strQuote one registry-validated identifier.
Identifiers reach this function only after validate_identifier, so the embedded-quote escape is belt-and-braces rather than the actual defense.
render_predicate#
render_predicate(node: Expression, builder: SqlBuilder, alias: str, joins: dict[tuple[Any, ...], str], *, registry: Any = None) -> NoneRender one predicate into builder, numbering placeholders positionally.
Args:
node— The predicate tree to render.builder— Accumulates the SQL text and the bound values, in order.alias— The table alias the unqualified columns belong to.joins— Alias per relationship path, for columns reached through one.registry— Needed only to render anIN (SELECT ...)subquery, which has- to resolve its own model to a table. Callers that never build one
- may leave it None; a subquery reached without it raises rather than
- rendering something half-qualified.
shape_of#
shape_of(registry: Any, select: Select) -> bytesThe keyed shape, walking the tree in Python for a node C cannot key.
orm_shape_configure hands the builder a fixed set of expression classes and it dispatches by exact type, so a node added since the extension was built raises ORMError("cannot key ...") rather than quietly producing a key that ignores it. That refusal is what makes the recovery safe: _shape_of_walk raises the identical error for anything genuinely unkeyable, so catching it either recovers a node the C does not know yet or re-raises exactly what the caller would have seen.
Every expression class defined by this release takes the native path. The recovery permits a Python expression class to precede an extension rebuild without letting a stale extension key two different queries identically. A version mismatch may therefore make compilation slower, never incorrect.
An ordering that is not a bare column walks before the call rather than through the recovery: the C keyer reads ordering.expression.column, which a distance node does not have, and that surfaces as an AttributeError -- not the ORMError this recovery is built on. Deciding it from a flag the Select already computed is one attribute read, and it keeps the contract above as narrow as it claims.
wreath.orm.introspection#
Startup schema validation.
Wreath never creates, alters, or drops database objects. It reads pg_catalog once at startup and reports, deterministically, where the database and the compiled models disagree.
information_schema is deliberately not used: it reports SQL-standard type names and loses the type OIDs and array element identity this comparison depends on.
Every catalog column is cast to a type the driver has a codec for. Wreath's PostgreSQL driver decodes a deliberately small set -- bool, the int and float widths, text/varchar, bytea, uuid, the date and timestamp types, numeric, json and jsonb -- and hands anything else back as the raw wire bytes. The catalog is made almost entirely of types outside that set: nspname, relname and attname are name, atttypid and typelem are oid, contype is "char", conkey is int2[] and indkey is int2vector. Read uncast, a column name arrives as b'id' and matches no declared column, so the validator reported every column of every table missing.
The casts are not cosmetic, and they are not only about arrays. The raw bytes also differ between the two result formats -- atttypid is b'20' on a cold operation (text) and b'\x00\x00\x00\x14' once the plan is cached (binary) -- so an uncast read gives a different wrong answer on the first call than on the second. Casting to text/bigint makes both formats decode to the same Python value, which is why this file does not need to care whether the statement is cold or warm.
ExtensionTypeResolution#
Where one extension type's OID came from, or that it has none.
ExtensionTypeResolution(type_name: str, extension: str, oid: int, schema: str, current_schema: str) -> Noneinstalled (property)#
installed: boolSchemaDiff#
SchemaDiff(issues: 'tuple[SchemaIssue, ...]')
SchemaDiff(issues: tuple[SchemaIssue, ...]) -> Nonereport#
report(limit: int = 50) -> strSchemaIssue#
One disagreement, ordered so a diff is stable across runs.
SchemaIssue(schema: str, table: str, column: str, issue_code: str, detail: str) -> Nonedeclared_extension_columns#
declared_extension_columns(registry: Any) -> tuple[tuple[Any, Any], ...]Every (spec, column) in registry whose type comes from an extension.
declared_index_methods#
declared_index_methods(registry: Any) -> tuple[str, ...]The access methods this registry declares that spell an operator class.
btree and GIN are excluded because their index descriptor carries no operator-class field at all, so nothing about them can disagree with the catalog over one. Sorted, so the probe below asks the same question in the same order on every run.
probe_default_opclasses#
async probe_default_opclasses(connection: Any, methods: tuple[str, ...]) -> dict[tuple[str, int], str]Read {(access method, indexed type OID): default operator class}.
Split from resolve_default_opclasses for the same reason probe_extension_types is split from resolve_extension_types: the reading is useful without the binding, and a test that wants to know what this server's defaults are should not have to build a registry to find out.
An access method with no default operator class -- and a method this server has never heard of -- simply contributes no rows. There is nothing to raise about: it means every declared operator class on that method is explicit, which is the case the catalog already records verbatim.
probe_extension_types#
async probe_extension_types(connection: Any, wanted: dict[str, str]) -> tuple[ExtensionTypeResolution, ...]Read one OID per {type_name: extension} entry, without deciding anything.
Split out from resolve_extension_types because wreath.doctor needs the same reading without the startup failure: a readiness report that raised would be a worse tool than the startup check it duplicates.
resolve_default_opclasses#
async resolve_default_opclasses(registry: Any, connection: Any | None = None) -> dict[tuple[str, int], str]Give registry this database's default operator class per index method.
An index declared index_ops="vector_l2_ops" on ivfflat names pgvector's default operator class for that method, and PostgreSQL does not remember that it was named: pg_get_indexdef deparses it away, and wreath's catalog read deliberately records a default as the empty string so that an index declared without an operator class is not reported as drifted. Without this, the desired descriptor keeps saying vector_l2_ops and the catalog keeps saying nothing, so detect rediscovers drift on every run and generate emits a MANUAL forever for an index that is already exactly right.
The answer belongs to the database, so it is read from one, once per registry, and cached on it. A registry that declares no such index does no I/O at all. connection is for the migration entry points, which already hold one and are the only thing that builds a desired descriptor; passing none borrows from the registry's own pool instead.
resolve_extension_types#
async resolve_extension_types(registry: Any) -> tuple[ExtensionTypeResolution, ...]Give this registry's extension-typed columns the OIDs this database uses.
Runs at startup, before anything binds a value. pgvector's vector is not a compile-time OID the way bigint is -- CREATE EXTENSION assigns it, and a different database assigns a different one -- so the OID is read here once and handed to the driver's codec table. Nothing resolves a type at request time; that is the whole point of doing it here.
A registry that declares no extension type does no I/O at all.
Raises:
ExtensionNotInstalledError— a declared type has no OID in this database.- The message names the extension, the schema wreath looked in, and
- the first column that wanted it, because the alternative is an
- unrecognised-OID error at the first query with none of that in it.
validate_registry#
async validate_registry(registry: Any) -> SchemaDiffCompare a registry's models to the live catalog.
Runs on the registry's read pool when it has one, since validation is a read. Returns the diff; raises or warns according to validate_schema.
wreath.orm.validation#
One-pass validation from a request body straight into a model.
Without this there are two validation engines on the write path: request binding checks a JSON payload against a dataclass, and then every assignment to a model re-checks the same value through its column's PgType. The value is proven twice and copied twice.
Here a body is validated by the columns themselves, once, directly into the model's cells. The column type stays the single source of the type rules -- this does not add a second set -- but it runs exactly once per field, and the value that survives it is the value that gets bound to SQL.
The validator is generated per model, at route-compile time. A model's columns are fixed the moment the class exists, so the loop that walked them per request was re-deciding, on every field of every body, facts that were settled at import: what the field is called, whether it is nullable, whether it has a default. Generating the source unrolls all of that into straight-line code, and lets a cross-field rule read the values out of local variables instead of loading them back out of the cells they were just written to. See constraints.py for the same technique applied to a single column's checks.
The database still enforces its own structural constraints (types, NOT NULL, keys). That is deliberate: it is the backstop, not the validator. Application rules such as enums stay in Python, so the schema stays portable.
compile_model_validator#
compile_model_validator(model: type[Model]) -> Callable[[Any, tuple[Any, ...]], Any]Build the validator for model; generated once, run per request.
wreath.orm.errors#
ORM error types.
Declaration mistakes surface as DeclarationError while a registry compiles, never at request time. Everything else is raised by a session.
DeclarationError#
An invalid model, column, or relationship declaration.
Always raised while app.orm() compiles a registry, so a running application never discovers a broken declaration mid-request.
DetachedInstanceError#
An operation required an object that a session still owns.
ExtensionNotInstalledError#
A column declares an extension type the database does not provide.
Raised at startup, while extension type OIDs are being resolved, so a Vector column on a database without CREATE EXTENSION vector fails where the message can name the extension and the schema -- not at the first query, where it would surface as an unrecognised OID.
ExtensionNotInstalledError(message: str, extension: str = '', schema: str = '') -> NoneMappingError#
A result set does not match the model it was asked to hydrate.
MultipleResultsError#
fetch_one() matched more than one row.
NoResultError#
A required primary key or query matched no row.
ORMError#
Base class for every ORM error.
RegistryError#
Invalid registry configuration or lookup.
SchemaMismatchError#
The database schema disagrees with the compiled models.
SchemaMismatchError(message: str, diff: tuple[object, ...] = ()) -> NoneSessionClosedError#
The session was used after it was closed.
SessionError#
Invalid session use.
UnloadedAttributeError#
A scalar column was read on an object that never loaded it.
Attribute access never issues SQL, so an unprojected column raises rather than silently returning None or fetching a row.
UnloadedRelationshipError#
A relationship was read without being loaded.
Load it explicitly with .include(...) on the query or await session.load(instance, Model.relationship).
StaleDataError#
A write matched no row, so the object it came from is out of date.
Raised when an UPDATE or DELETE built from a loaded object affects zero rows: another session deleted it, or changed the key it was found by. The driver may consider the statement successful, but the ORM requires exactly one matching row.
wreath.migrations#
Wreath-metal PostgreSQL migration configuration and bounded result views.
DeferredDeclarationError#
A deferred migration that could not be declared.
DowngradeHazard#
One ORM-mapped object a downgrade would strand or retype under the code.
DowngradeHazard(schema: str, table: str, name: str, kind: str, reason: str) -> Noneexplain#
explain() -> strDowngradeWouldStrandCode#
A downgrade was refused because live ORM code still references what it removes.
DowngradeWouldStrandCode(schema: str, hazards: tuple[DowngradeHazard, ...]) -> NoneDowngradeWouldStrandRecodedData#
A downgrade was refused because a re-encode has changed the values under it.
Reverse DDL can verify cleanly while leaving values encoded for the newer schema. This refusal requires the values to be converted back before the schema definition is reverted.
DowngradeWouldStrandRecodedData(schema: str, hazards: tuple[RecodedColumnHazard, ...]) -> NoneMigrationBlockedByPass#
A migration was refused because a pass has not published for a column it narrows.
MigrationBlockedByPass(schema: str, hazards: tuple[PendingPassHazard, ...]) -> NonePendingPassHazard#
One column this artifact narrows while a pass is still converting it.
PendingPassHazard(schema: str, table: str, column: str, action: str, pass_name: str, tenant: str, phase: str, holes_open: int) -> Noneexplain#
explain() -> strRecodedColumnHazard#
One column this downgrade touches whose values a re-encode has changed.
RecodedColumnHazard(schema: str, table: str, column: str, action: str, pass_name: str, tenant: str, phase: str, finished: bool, ledger_row_present: bool = True) -> Noneexplain#
explain() -> strRecode#
Shape A: re-encode one column's values in place.
Args:
column— the column being re-encoded.mapping— old value to new value. Finite and invertible, because that- is exactly what lets the scan widen
col == newinto col IN (new, old)rather than merely permitting it. A conversion- that cannot enumerate its pairs is not a
Recode. chunk— rows per chunk, passed straight to the pass.pace— how much of the machine the walk may be.name— the pass's ledger identity; derived from the column by default.
The declaration lives beside the model rather than inside a migration file, because this repository generates migration artifacts from the catalog and has no authored-migration surface to hang a decorator on. See the guide.
Recode(column: Any, mapping: Any, chunk: int = 10000, pace: Any = None, name: str | None = None) -> Nonebuild#
build() -> ChunkedPassThe walk that performs the conversion.
No gate: Shape A adds no column, so there is nothing for a later migration to narrow and no fact for it to wait on. The safety mechanism is scan, and it runs before the walk starts rather than after it finishes.
rewrites records the column association needed to refuse an unsafe downgrade. Unlike guards, it is never cleared: completing the pass does not restore the previous encoding.
converts (property)#
converts: strschema.table.column for the column being converted.
pass_name (property)#
pass_name: strscan#
scan(**populations: Any) -> ScanReportEvery read of this column, classified. See ._migrations.scan.
Retype#
Shape B: change one column's type by draining it into a new one.
Args:
column— the column being drained -- **the one a later migration will- narrow**, which is what the published fact names.
into— the new column's name, added by this migration's own DDL as- nullable. Nullable-then-not is a change the ORM already models.
using— the SQL expression producing the new value.chunk— rows per chunk, passed straight to the pass.pace— how much of the machine the walk may be.name— the pass's ledger identity; derived from the column by default.
The gate is not optional here and it is the half X1 cannot ship without. Verification is NOT VALID then VALIDATE CONSTRAINT -- the constraint the swap migration is about to add -- so the thing proven and the thing later enforced are the same predicate, and a walk that was subtly wrong cannot verify its own bug.
Retype(column: Any, into: str, using: Any, chunk: int = 10000, pace: Any = None, name: str | None = None) -> Nonebuild#
build() -> ChunkedPassThe walk, its gate, and the fact the gate publishes.
Ceiling.at_launch() and scope="pass" are not a choice: a gate with scope="unit" cannot publish a fact, and a whole-pass gate is refused on a recurring pass, so a pass that guards a column is necessarily a bounded one that finishes.
constraint_name (property)#
constraint_name: strconverts (property)#
converts: strpass_name (property)#
pass_name: strpublishes (property)#
publishes: strThe fact a later migration waits on before dropping the old column.
scan#
scan(**populations: Any) -> ScanReportShape B has no re-encode window, so there is nothing to scan.
Both columns are separately typed and correct throughout; an unconverted row reads NULL, which the ORM already models and the gate proves gone. Returning an explicitly empty report rather than raising keeps one calling shape for both declarations.
ScanReport#
Everything the scan looked at, and what it concluded.
examined distinguishes a clean result from a scan that inspected no declarations. scanned_nothing makes that absence of evidence explicit.
ScanReport(column: str, examined: int, hazards: tuple[TransitionalHazard, ...] = (), rewrites: tuple[TransitionalHazard, ...] = (), scanned_nothing: bool = False, shape: str = 'recode') -> Noneblocking (property)#
blocking: tuple[TransitionalHazard, ...]explain#
explain() -> strwaived (property)#
waived: tuple[TransitionalHazard, ...]TransitionalHazard#
One read that cannot be proven safe for the conversion window.
TransitionalHazard(site: str, column: str, operation: str, verdict: str, detail: str, rewrite: str | None = None, waiver: str | None = None) -> Noneexplain#
explain() -> strwaived (property)#
waived: boolTransitionalContractUnproven#
A deferred migration whose reads could not be proven safe for the window.
The mirror of MigrationBlockedByPass, one step earlier: that one refuses a migration for narrowing a column a pass has not finished, this one refuses to start the pass while a read of that column would silently mean something else while it runs.
Refusal is the default and the asymmetry is the argument -- a false refusal costs an argument with the tool, a false permission costs a data incident that surfaces a week later.
TransitionalContractUnproven(report: Any) -> NoneFleetResolution#
FleetResolution(current: 'int', apply: 'int', verify: 'int', ambiguous: 'int', blocked: 'int')
FleetResolution(current: int, apply: int, verify: int, ambiguous: int, blocked: int) -> Nonetotal (property)#
total: intMigrationConfig#
MigrationConfig(database: 'str', policy: 'ResolutionPolicy', catalog_chunk_size: 'int' = 256, concurrency: 'int' = 8, max_failures: 'int' = 100)
MigrationConfig(database: str, policy: ResolutionPolicy, catalog_chunk_size: int = 256, concurrency: int = 8, max_failures: int = 100) -> NoneMigrationBaseline#
A reviewed zero-operation root adopting one existing live schema.
artifact is a normal checksummed WMA1 artifact, but its source and target fingerprints are identical and all three operation tapes are empty. Applying it therefore records history and executes no DDL. descriptor is the bounded WMD1 catalog inventory used for human review.
MigrationBaseline(artifact: NativeMigrationArtifact, fingerprint: bytes, descriptor: bytes, object_count: int) -> NoneMigrationDetection#
Bounded result of comparing ORM intent with one live schema.
MigrationDetection(desired_fingerprint: bytes, actual_fingerprint: bytes, diff: NativeMigrationDiff) -> Nonecurrent (property)#
current: boolMigrationGeneration#
Review metadata retained by the native named operation planner.
MigrationGeneration(desired_fingerprint: bytes, actual_fingerprint: bytes, diff: NativeMigrationDiff, plan: NativeMigrationPlan, sql: NativeMigrationSql) -> NoneMigrationApplyResult#
Verified result of one locked, transactional single-schema application.
MigrationApplyResult(migration_id: bytes, checksum: bytes, source_fingerprint: bytes, target_fingerprint: bytes, destructive_approved: bool) -> NoneMigrationRevertResult#
Verified result of one locked, transactional single-schema downgrade.
MigrationRevertResult(migration_id: bytes, checksum: bytes, source_fingerprint: bytes, target_fingerprint: bytes, destructive_approved: bool, forced: bool) -> NoneNativeCatalogSnapshot#
Canonical image plus its bounded native names/signatures descriptor.
NativeCatalogSnapshot(image: bytes, descriptor: bytes) -> NoneNativeMigrationArtifact#
Verified immutable artifact metadata with its packed operation tape.
NativeMigrationArtifact(data: bytes, checksum: bytes, migration_id: bytes, parent_checksum: bytes, source_fingerprint: bytes, target_fingerprint: bytes, operation_tape: bytes, named_plan: bytes, sql_tape: bytes) -> NoneNativeMigrationChain#
Metal-verified artifact-chain tip and migration count.
NativeMigrationChain(checksum: bytes, target_fingerprint: bytes, migration_count: int) -> NoneNativeMigrationDiff#
A deterministic packed operation tape produced entirely by Wreath-metal.
NativeMigrationDiff(operation_count: int, tape: bytes) -> NoneNativeMigrationPlan#
Deterministic named WMP1 operation plan produced by Wreath-metal.
NativeMigrationPlan(operation_count: int, tape: bytes) -> NoneNativeMigrationSql#
Deterministic WMS1 statement tape derived from a named native plan.
NativeMigrationSql(operation_count: int, manual_count: int, destructive_count: int, tape: bytes) -> NoneResolutionPolicy#
Whether fleet readiness trusts verified history or audits every catalog.
ResolutionPolicy(kind: "Literal['managed', 'strict']", sample_size: int = 0) -> Nonemanaged (classmethod)#
managed(*, sample_size: int = 0) -> ResolutionPolicystrict (classmethod)#
strict() -> ResolutionPolicyTenantState#
One tenant's recorded migration state, as the directory knows it.
status is one of the HISTORY_* codes. VERIFIED means the checksummed history was proven and can be trusted as the fast readiness authority; UNKNOWN forces catalog verification; AMBIGUOUS and BLOCKED are terminal operational states the resolver never treats as current. Applications build these from their own tenant directory; the runner never invents tenant identity.
TenantState(tenant_id: int, migration: int, checksum: int, generation: int, status: int = 0) -> Noneapply_single_artifact#
async apply_single_artifact(registry: Any, connection: Any, artifact_data: bytes, *, allow_destructive: bool = False) -> MigrationApplyResultApply one authoritative artifact under a transaction-scoped native plan.
adopt_single_baseline#
async adopt_single_baseline(registry: Any, connection: Any, artifact_data: bytes) -> MigrationApplyResultRecord one zero-operation root after re-verifying code and catalog.
The only DDL this may issue is creation of Wreath's own migration-history schema/table. It never builds or executes an application DDL block. A prior history row, non-root artifact, operation, or fingerprint mismatch is refused inside the same advisory-locked transaction that records adoption.
connect_migration#
async connect_migration(dsn: str) -> AnyOpen a dedicated Wreath-metal migration connection.
detect_single#
async detect_single(registry: Any, connection: Any) -> MigrationDetectionCompare one compiled single-schema registry with live PostgreSQL.
generate_single_plan#
async generate_single_plan(registry: Any, connection: Any, *, fleet: bool = False) -> MigrationGenerationBuild a deterministic named plan for one live physical schema.
fleet=True fingerprints the catalog with every tenant-local schema name blanked, so the artifact verifies against any structurally identical tenant rather than only the one it was generated from. That is the whole reason one artifact can cross a fleet: nspname is the first column of every catalog branch, so two byte-identical tenants otherwise fingerprint differently and an artifact could never match the second one.
A fleet artifact and a single-schema artifact are therefore not interchangeable, and cannot be confused for one another: their fingerprints differ, so applying the wrong kind fails the source-fingerprint refusal rather than migrating anything.
generate_single_baseline#
async generate_single_baseline(registry: Any, connection: Any, *, migration_id: bytes) -> MigrationBaselineAdopt current PostgreSQL state as a root without replaying its history.
The live catalog and declared ORM image must match exactly. Drift is never folded into a baseline: doing that would bless a model that does not describe the database and make the first later migration plan relative to the wrong state. The returned artifact contains no operation and therefore cannot execute DDL when applied.
pack_tenant_directory#
pack_tenant_directory(tenants: Any) -> bytesPack tenant-directory rows into the resolver's contiguous snapshot.
The layout is the one the native metal resolver consumes; keeping the pack here (rather than in each caller) means the wire format has exactly one author.
resolve_fleet#
resolve_fleet(tenants: Any, *, target_migration: int, target_checksum: int, directory_generation: int) -> FleetResolutionClassify a whole tenant fleet's readiness in one metal invocation.
This is the managed fleet runner: it packs the caller's tenant directory into the native snapshot and resolves it with a single Wreath-metal call, returning bounded per-bucket counts rather than one Python object per tenant. A tenant is current only when its trusted, verified history is at the target migration and checksum for the current directory generation; unknown, stale, or wrong-generation tenants fall to verify (catalog audit), and ambiguous/blocked stay terminal.
The runner reads no live database and acquires no DDL authority. Turning a verify count into per-tenant catalog audits is a separate, explicitly privileged step; classification is deliberately side-effect free.
revert_single_artifact#
async revert_single_artifact(registry: Any, connection: Any, artifact_data: bytes, *, allow_destructive: bool = False, force: bool = False) -> MigrationRevertResultUndo one authoritative artifact: the exact inverse of apply_single_artifact.
The named plan is inverted in metal (every add becomes a drop, every signature swaps), so the downgrade tape is derived from the same authority the upgrade was — never guessed. The artifact must be the current history tip; the transaction verifies the live catalog matches the artifact target, runs the reverse DDL block, requires the catalog to return to the artifact source, deletes the tip history row, and commits — or rolls everything back.
Unless force is set, the downgrade is refused when the running ORM still maps a column or table the reverse would drop (or a type it would change): downgrading production under code that still references those objects strands the deployed code. force exists for the legitimate case of rewinding a local stack to re-migrate.
scan_transitional_reads#
scan_transitional_reads(declaration: Any, *, registry: Any = None, queries: Any = (), views: Any = (), strict: bool = True) -> AnyClassify every read of the column declaration converts.
Returns the report. With strict, raises TransitionalContractUnproven when anything is unproven -- including when nothing was scanned at all. An empty scan is absence of evidence, not proof that reads remain valid during the transition.
transitional_read#
transitional_read(column: Any, *, reason: str) -> AnyWaive the transitional check for one read, with a written reason.
The reason is not decoration. It appears in wreath migrations check output as a count and in --json in full, so "we waived everything" is visible in review rather than discovered afterwards.
This form decorates a function. A declared query is a slotted value with nowhere to hang an attribute, so it takes waive_transitional instead; the error below says so rather than failing obscurely.
unpack_catalog_descriptor#
unpack_catalog_descriptor(descriptor: bytes) -> list[dict[str, Any]]Decode a bounded WMD1 catalog inventory into reviewable values.
This is an operator view, not migration execution input. Execution always consumes the native image and verified artifact; editing this list changes nothing about what can be adopted.
waive_transitional#
waive_transitional(column: Any, *, site: str, reason: str) -> NoneWaive one read named by its declared site, for reads that are values.
A QueryDeclaration is immutable and slotted, so there is nothing to decorate. Keying on the declared name rather than on object identity is deliberate: the name is the thing the declaration sells, it is stable across a reload, and id() is not an identity -- a lesson this codebase has paid for twice.
wreath.schema#
The wreath schema: the tables wreath owns, and how they get there.
Wreath's subsystems -- the job queue, the message bus, sessions, rate limits, idempotency, the webhook inbox and outbox, the pass ledger, series buckets -- need tables of their own. Those tables are not the application's data model and they do not belong in the application's migration artifact: the artifact describes what the author declared, and nobody declared a job queue.
So they live in a schema of their own, named wreath by default, and this module puts them there. A framework user who does nothing gets a working database. A framework user whose role cannot CREATE SCHEMA is told exactly what to hand a DBA, and is refused at startup rather than at the first enqueue.
Three properties hold this together, and each is load-bearing:
- The user's migration diff cannot see these tables. The catalog read is scoped to one named schema (
migrations.py), so a diff of the application's schema never observeswreath.*and never proposes dropping it. Separation is by construction, not by a filter someone has to maintain. - Every reference is fully schema-qualified. Nothing here resolves through
search_path, which is what lets it compose with an isolated tenant session -- that bindssearch_pathto the tenant's schema alone. - An upgrade step must be safe for the previous version to keep running against. A fleet mid-rollout has two wreath versions live on one database. Steps are therefore additive: add a table, add a nullable or defaulted column, add an index. A column stops being read in one release and is dropped two releases later, once no supported version reads it. That gap is what makes a rollback survivable.
Read ~/code-maps/designs/35-wreath-schema.md for the reasoning, the rejected alternatives, and the staging.
Component#
A subsystem's claim on the wreath schema.
Args:
name— Stable identifier, used as the version-marker key and the advisory- lock key. Renaming one strands its recorded version, so treat it as
- part of the on-disk format.
steps— Ordered steps. Step 1 creates the component's tables.relations— The relations that must exist for the component to work,- unqualified. Used by
verifyto say precisely what is missing when - wreath is not managing the schema.
schema— The schema the component's tables live in.
Component(name: str, steps: tuple[Step, ...], relations: tuple[str, ...] = (), schema: str = 'wreath') -> Nonequalified (property)#
qualified: boolWhether this component's tables live in schema.
Not every one does, and the difference is history rather than design. Six components name their tables "wreath"."jobs"; five predate that and use an unqualified wreath_-prefixed name, which PostgreSQL resolves through search_path -- normally public. Moving them is a non-additive change: a worker on the previous version looks for wreath_session and would not find it, which is precisely what the additive rule exists to prevent. So they are registered where their rows actually are, and a move is a later, staged concern.
sql#
sql(*, from_version: int = 0, create_schema: bool = True) -> strThe component's DDL as one semicolon-joined script.
This is a derivation, not a source. Step.statements holds the DDL; this joins it. Every schema_sql() in wreath returns this, so the tuple and the script cannot disagree -- which they did while jobs carried both, spelled out twice.
Args:
from_version— Emit only steps above this version.create_schema— PrependCREATE SCHEMA IF NOT EXISTS. A component- whose tables are unqualified has no schema to create, and
- emitting one would create a schema nothing then uses.
Returns:
- SQL, one statement per
;\n, with a trailing;\n. Callers that - execute it must split on
;\n-- or better, usestatements(), - which is why the tuple exists.
statements#
statements(*, from_version: int = 0) -> tuple[str, ...]Every statement of every step above from_version, in order.
target_version (property)#
target_version: intThe version a database is brought to once every step has applied.
SchemaContributor#
A registered subsystem that declares one wreath-owned schema component.
SchemaContributor(*args, **kwargs)component#
component() -> ComponentSchemaError#
A wreath-owned schema is missing, unmanageable, or inconsistent.
SchemaNotManaged#
A required relation is absent and wreath was told not to create it.
Raised at startup, never at first use. A subsystem that registers is a subsystem that will be used, so deferring the check to the first enqueue reproduces exactly the failure this module exists to remove: a missing table surfacing as a runtime error, far from the configuration that caused it.
Step#
One ordered, additive change to a component's tables.
statements is a tuple rather than a semicolon-joined string on purpose. The driver speaks the extended query protocol exclusively, so it prepares every statement and PostgreSQL refuses cannot insert multiple commands into a prepared statement. Four call sites in this repository had each grown the same sql.split(";\n") workaround to get around a schema_sql() that returned a blob; a tuple of statements removes the workaround rather than repeating it a fifth time.
Args:
version— The version this step brings the component to. Steps apply in- ascending order and a component's first step is version 1.
statements— The statements to execute, in order, each on its own.
Step(version: int, statements: tuple[str, ...]) -> Nonebootstrap#
async bootstrap(database: Any, components: Sequence[Component], *, schema: str = 'wreath', workload: str = 'write', manage: bool = True) -> dict[str, int]Bring the wreath schema up to date, or refuse and say what is missing.
Idempotent and safe to run concurrently: the work for each component happens under an advisory lock keyed on that component, so a fleet of workers starting together serialises rather than racing. Locking per component rather than globally means an unrelated subsystem's upgrade does not block a worker that does not use it.
Each step applies in its own transaction together with the version row that records it, so a crash mid-bootstrap leaves a database that describes itself accurately rather than one whose marker overstates what landed.
Args:
database— A startedwreath.postgres.Database.components— The components to bring up to date.schema— The schema to manage.workload— The pool to use. Must be a primary; DDL cannot run on a replica.manage— When False, create nothing andverifyinstead.
Returns:
- The version of each component after the run.
Raises:
SchemaNotManaged—manageis False and a required relation is absent.
emit_sql#
emit_sql(components: Sequence[Component], *, schema: str = 'wreath', from_version: int = 0) -> strThe full ordered DDL, for a DBA to apply by hand.
This is the supported spelling for a deployment whose application role cannot CREATE SCHEMA. It is the same statements bootstrap would run, in the same order, so what a DBA applies and what wreath would have done cannot drift.
Args:
components— The components to emit, in registration order.schema— The schema to emit for.from_version— Emit only steps above this version, for a database that is- already partly upgraded.
Returns:
- Executable SQL, one statement per
;on its own line.
marker_statements#
marker_statements(schema: str = 'wreath') -> tuple[str, ...]DDL for the schema and its version marker. Idempotent, applied first.
Per-component versions, not one global counter: a global number forces unrelated subsystems into a single order, so a jobs change and a webhooks change collide for no reason and a deployment using neither still has to move it.
verify#
async verify(database: Any, components: Sequence[Component], *, schema: str = 'wreath', workload: str = 'write') -> NoneRefuse if any component's relations are absent. Never creates anything.
Raises:
SchemaNotManaged— A required relation is missing. The message names the- component, the relation, and the command that emits the DDL.
wreath.sql#
SQL that cannot carry an injection, because the values never enter the text.
Every SQL injection has the same shape: a value the caller supplied was turned into syntax instead of being sent as data. The defence has been known for decades -- bind the value, do not format it -- and applications keep getting it wrong anyway, because the two spellings look almost identical:
sql = f"SELECT id FROM shipments WHERE reference ILIKE '%{needle}%'" # a hole
sql = "SELECT id FROM shipments WHERE reference ILIKE $1" # notThe first one is shorter, it reads better, it is what an f-string is for, and nothing about it is flagged by the language, the type checker, or the driver. It is a defect that has to be caught by a person noticing, every time, forever.
Python 3.14 finally makes the difference visible to the machine. A t-string (PEP 750) is not a string: it evaluates to a string.templatelib.Template that keeps the literal parts and the interpolated values apart, exactly as the compiler saw them. So a template can be turned into $1-style SQL mechanically, and the interpolated values can only ever leave as parameters:
from wreath.sql import Identifier, Statement
rows = await db.raw(
t"SELECT id, reference FROM {Identifier(schema, 'shipments')} "
t"WHERE org_id = {org} AND reference ILIKE {pattern}"
).fetch()org and pattern become $1 and $2. There is no argument the caller can pass -- no quote, no comment marker, no UNION -- that changes the statement, because their text is never concatenated into it. Changing that t back to an f does not quietly reintroduce the hole either: the result is a str, and Statement refuses a str with a message naming the fix.
Three interpolated types are spliced into the text rather than bound, because PostgreSQL resolves them at parse time and a parameter cannot stand in:
Identifier-- a schema, table, or column name, double-quoted and escaped.Fragment-- literal SQL, the audited escape hatch (see its docstring).- another
TemplateorStatement-- a nested clause, whose own parameters are renumbered into the outer statement, so clauses compose.
Related: wreath.hardening is the rule set that finds the f-string spelling in an application's own source, at boot and in CI, and wreath.orm.Session.raw is the usual place a Statement is handed to the database.
Fragment#
Literal SQL spliced into a statement without quoting or binding.
This is the escape hatch, and it is deliberately the only one. Some SQL is genuinely syntax rather than data -- ASC, NULLS LAST, a whole ORDER BY clause assembled from an allow-list -- and no amount of binding can express it.
A Fragment is not a defence. Its whole purpose is to put caller-influenced text into the statement, so the value must come from the application, not from the request: look the caller's choice up in a mapping the application owns and pass the mapped value.
DIRECTIONS = {"asc": "ASC", "desc": "DESC"}
order = Fragment(DIRECTIONS[requested]) # KeyError, not an injectionNothing about a Fragment is checked, and that is why it is spelled as a type rather than allowed implicitly: it is one greppable word that says "this text is SQL on purpose", so a review can find every one of them in a tree and read the mapping behind it.
Fragment(text: str) -> NoneIdentifier#
A schema, table, or column name, quoted for interpolation into SQL.
A parameter cannot name a relation: PostgreSQL resolves identifiers while it parses, long before a bind value exists. So an application that computes a table or schema name has to put it in the text, and this is the safe way to do it -- the name is double-quoted, any embedded quote is doubled, and a NUL is refused outright.
Several parts qualify a name: Identifier("northwind", "shipments") renders "northwind"."shipments".
Quoting makes a hostile name harmless rather than rejected; it becomes a relation that does not exist, and the statement fails with an ordinary "relation does not exist" instead of running something else. Deciding which names a caller may reach is a separate question, and one this class deliberately does not answer -- look the name up in a directory the application owns, as wreath.orm.TenantContext does.
Identifier(*parts: str) -> Nonetext (property)#
text: strThe quoted SQL for this name.
Statement#
SQL text with $1-style placeholders, compiled from a t-string.
text is what goes to the server and args is what is bound to it. The split is decided by the compiler, from the source, so no runtime value can move itself from one side to the other.
Statement(template: Template) -> Nonewreath.store#
One keyed store, declared once, backed by memory or by PostgreSQL.
A rate limiter, an idempotency ledger, and a session table are three different features that need the same small thing: values under a key, aged by a timestamp, in one worker's memory or in a table every worker shares. Written three times, the PostgreSQL half is re-derived three times -- and the six re-derivations are exactly the parts that are easy to get subtly wrong:
- the table name reaches SQL by interpolation, so it must be a plain identifier;
- the schema is offered (
Keyed.schema_sql) and never applied, because schema changes belong in the migration history with the rest of the schema; - statements are prepared lazily, because a store is built while the application is being described and the database is not up yet;
- a claim is one
INSERT ... ON CONFLICT ... RETURNING, never a read followed by a write; - the clock is
clock_timestamp()and nevernow(), which is frozen at transaction start; - expired rows are dropped by an explicit
PostgresStore.purge, never by a background thread.
Declare the shape once and pick a backend:
declaration = Keyed(
table="wreath_session",
columns=(Column("data", "jsonb", null=False),),
key="sid",
prefix="wreath_session",
)
store = PostgresStore(database, declaration)What is not shared is the payload and the semantics: what the columns mean, whether the deadline is fixed per store or supplied per write, and whether the rows are claimed at all. Each caller keeps those.
CLAIMED (value)#
CLAIMED: _Claimed = CLAIMEDSql#
A fragment the caller is asserting is safe to interpolate.
Column names are checked as identifiers; the expressions beside them cannot be, because an expression is arbitrary SQL by definition. The type is the check: a plain str in an expression position is refused, so text that arrived from a request cannot reach the statement without somebody having written Sql(...) around it and meant it.
A bind placeholder ($1) needs no marker -- it is the safe form, and requiring ceremony for it would push callers toward the unsafe one.
Column#
One payload column: its name, its SQL type, and whether it may be NULL.
Keyed#
The declaration of a keyed store: one key, some payload, one timestamp.
A last-touched store (deadline=False) expires nothing: the stamp is arithmetic for whoever reads it, and only an idle purge ages a row out.
claim=True requires a deadline -- there is nothing to reclaim without one -- and every payload column nullable, because a claim resets the payload.
An index on the stamp is what keeps purging a large table cheap, and the prefix is what keeps two stores over different tables from colliding on one prepared-statement name.
Args:
table— the backing table. Interpolated, so it must be a plain identifier.columns— the payload. Their meaning belongs to the caller.key— the primary-key column.stamp— thetimestamptzcolumn rows are aged by.deadline— whetherstampis a deadline or a last-touched mark.ttl— seconds a row lives.Nonemeans the caller supplies it per write.index_stamp— also declare an index onstamp.claim— build the atomic claim statement.prefix— prepended to prepared-statement names.
Keyed(table: str, columns: tuple[Column, ...] = (), key: str = 'key', stamp: str = 'expires', deadline: bool = True, ttl: float | None = None, index_stamp: bool = False, claim: bool = False, prefix: str = 'wreath_store') -> Noneschema_claim#
schema_claim(name: str) -> AnyThis declaration's claim on the wreath schema, under name.
name is the caller's because one Keyed shape backs three different subsystems -- sessions, rate limits, idempotency -- and each needs its own version marker and its own advisory lock. Sharing one would make an upgrade to sessions block a worker that only uses rate limits.
Not component(). That name belongs to the zero-argument protocol Wreath.schema_components calls, and this needs an argument, so an object that reached the walk holding this method would have raised TypeError rather than contributing a claim. Two layers, two names.
schema_sql#
schema_sql() -> strDDL for the backing table, semicolon-joined.
A derivation of statements(). Wreath applies these itself during lifespan now; this is retained for a caller applying the DDL by hand, and wreath schema sql is the supported spelling.
statements#
statements() -> tuple[str, ...]DDL for the backing table, one statement per element.
The table name is unqualified, so it lands in whatever search_path resolves to. That is where every deployment's rows already are, and moving them into the wreath schema would not be additive -- a worker on the previous version would look for the old name. See wreath.schema.Component.qualified.
MemoryStore#
The same keyed store in one worker's memory: bounded, TTL'd, synchronous.
Enough on its own for a single-worker deployment or a sticky load balancer. Behind anything else it is a fast path in front of a shared store rather than a substitute for one, because a second worker's memory knows none of this.
Being synchronous is the feature, not an implementation detail: it is what makes claim atomic. There is no await between the read and the write, so no other task on this loop can interleave -- the in-process counterpart of the single statement PostgresStore.claim uses.
Nothing needs purging: entries expire lazily when read and max_entries bounds whatever is never read again.
The window opens when a key is first written, and writing again does not move it. A store that restarted the clock on every write would let a slow holder extend its own key indefinitely -- an idempotency key would outlive its TTL for as long as the handler kept touching it.
Over PostgreSQL the same rule is enforced a statement at a time rather than by this class: a caller's own upsert omits the stamp from its DO UPDATE, the way middleware.idempotency.PostgresIdempotencyStore's store statement does. The generated PostgresStore.claim is the deliberate exception -- its DO UPDATE does reset the stamp, because it only fires on an already-expired row and a reclaimed row must look exactly like a fresh one. Matching here is what lets one caller swap the backends and have a key honoured for the same length of time.
MemoryStore(*, ttl: float | None = None, max_entries: int = 4096, clock: Callable[[], float] = <built-in function monotonic>) -> Noneclaim#
claim(key: str) -> boolTake ownership of key, or report that it is already held.
read#
read(key: str) -> AnyThe stored value, CLAIMED when claimed but unwritten, or None.
set#
set(key: str, value: Any) -> NoneStore value under key, keeping the deadline the key already has.
A key that is absent or already past its deadline starts a fresh window; one that is live keeps the deadline it was claimed or first written with.
PostgresStore#
A keyed store in a table every worker shares.
Holds the six disciplines named in this module's docstring, and generates the statements a keyed store always wants -- claim, read, delete, purge -- from the declaration. Anything shaped by the caller's payload is written by the caller and registered with define, which gets it the same lazy preparation and the same statement naming.
PostgreSQL owns the clock. Every generated statement reads clock_timestamp(), so workers on disagreeing wall clocks cannot disagree about when a row expires, and clock_timestamp rather than now() because now() is fixed at transaction start -- inside a transaction it would freeze time for as long as the transaction runs.
Generated reads go to the write pool by default, because a store that claims must read back from the primary that accepted its claim. A store that only reads may pass read_workload="read".
Args:
database— awreath.postgres.Database.declaration— what the store holds.read_workload— the pool generated reads go to."write"by default.
PostgresStore(database: Any, declaration: Keyed, *, read_workload: str = 'write') -> Noneclaim#
async claim(key: str) -> boolTake ownership of key, or report that someone else has it.
One INSERT ... ON CONFLICT (key) DO UPDATE ... WHERE expired RETURNING. A row comes back only when the insert succeeded or an expired row was reclaimed, so "a row came back" is the claim -- no owner column, no second round trip, and no window in which two workers both proceed. A read followed by a write would let both of them conclude they were first, which is the one thing a claim exists to prevent.
declaration (property)#
declaration: KeyedThe Keyed this store was built from. Frozen, so it is safe to share.
What a purge pass is handed: everything needed to walk the table -- its name, its key, its stamp -- with no reference back to this store or its connection pool.
delete#
async delete(key: str) -> strDrop key. Not an error when it is already gone.
expired (property)#
expired: SqlThe predicate for a row the store no longer honours.
live (property)#
live: SqlThe predicate for a row that is still good.
The exact complement of expired, so a purge can never drop a row a read would still have honoured.
purge#
async purge(idle_seconds: float | None = None) -> strDrop rows the store no longer honours.
Nothing calls this for you: a background thread would duplicate across workers and swallow its own failures. Run it from a durable job.
With idle_seconds, drops rows untouched for that long instead -- which is the only way to age out a last-touched store, whose stamp is never a deadline.
purge_count#
async purge_count(idle_seconds: float | None = None) -> int | Nonepurge, reporting how many rows went.
None when the driver's status cannot be read -- a test double, a backend that reports nothing. Splitting this from purge keeps the status string available to callers that want it while giving the scheduled job that runs this something it can actually record: "the purge ran" and "the purge removed 40 000 rows" are different facts.
read#
async read(key: str, *, live: bool = False) -> AnyThe payload columns for key, or None.
live=True refuses a row whose deadline has passed, for a store that purges lazily -- which is every store, since nothing purges in the background.
schema_claim#
schema_claim(name: str) -> AnyThis store's claim on the wreath schema, under name.
schema_sql#
schema_sql() -> strDDL for the backing table, semicolon-joined.
table (property)#
table: strThe backing table's name, already checked as a plain identifier.
upsert#
upsert(*, values: Mapping[str, str], update: Mapping[str, str], where: str | None = None, returning: str | None = None) -> strOne INSERT ... ON CONFLICT DO UPDATE over this store's key.
Both mappings are column -> expression. The columns are interpolated, so each is checked as an identifier; the expressions are checked as Sql -- a bind placeholder, or a fragment the caller marked deliberately. The result is a single statement by construction -- two statements would be two chances for a concurrent worker to interleave.
window#
window(seconds: float | str | datetime.timedelta | None = None) -> SqlA deadline seconds from the database's clock, not the caller's.
A string lifetime must be a bind placeholder; see Sql.
define (inherited from _Statements)#
define(name: str, sql: str, *, workload: str = 'write') -> NoneRegister sql under name for lazy preparation.
sql (inherited from _Statements)#
sql(name: str) -> strThe text registered under name. Useful when explaining a plan.
statement (inherited from _Statements)#
statement(name: str) -> AnyThe prepared statement for name, preparing it on first use.
workload (inherited from _Statements)#
workload(name: str) -> strThe pool the statement registered under name runs against.
rows_affected#
rows_affected(status: Any) -> int | NoneThe row count in a PostgreSQL command tag, or None when unreadable.
A tag is DELETE 5, UPDATE 3, or -- the one that catches people -- INSERT 0 5, where the first number is a legacy OID and the second is the count. So the count is the last field, never the second, and four separate readers in this tree disagreed about that: two took the second field and returned None for every INSERT, which is indistinguishable from "the driver reported nothing" at the call site.
None rather than 0 when it cannot be read, because "no rows matched" and "this backend does not say" are different facts and a caller recording the first when it meant the second is reporting a clean sweep that never happened. A test double or a backend that reports nothing is a supported caller, not a broken one.
sql_identifier#
sql_identifier(value: str, *, what: str = 'table') -> strReturn value if it is a bare SQL identifier, else raise.
Table and column names are interpolated into statement text (they cannot be bound as parameters), so the only safe policy is to refuse anything that is not a plain identifier rather than to quote it.
wreath.queries#
Named, compiled reads over one model.
Every application grows a hand-written data-access layer: a module of functions that build the same five queries, with the filtering inlined at each call site and no shared name for the query that fetches a paddock's llamas. Wreath already owns the hard half of doing better -- a plan cache keyed by query shape -- and until now used it only internally.
A Queries class gives those reads names:
class Llamas(Queries[Llama]):
by_paddock = query(Llama.paddock_id == Param("paddock"))
overdue = query(Llama.checked_at < Param("before")).order_by(Llama.checked_at)
herd = await Llamas(session).by_paddock(paddock=7)Two properties follow from declaring rather than building:
- The shape is fixed at class-definition time and only values vary, so each declaration compiles exactly once through the registry's existing plan cache, however many times it runs and whatever it is called with. There is no second cache here; a bound declaration is an ordinary
Selectwhose plan-cache key is byte-identical to the hand-written query's. - Mistakes move to import time. A column that belongs to another model, a parameter written somewhere a value cannot be bound -- those fail when the class is defined, not on the request that first runs them.
Declared searches also fuse. A vector distance and a relevance score are numbers on different scales, and no weighting between them survives a change of embedding model — but a rank is a rank, so fuse merges two named searches by where each row placed rather than by what either scored it:
class Documents(Queries[Document]):
nearest = query().order_by(D.embedding.cosine_distance(Param("q"))).limit(50)
matching = query(D.search.matches(Param("terms"))).order_by(...).limit(50)
hybrid = fuse(nearest, matching).limit(10)Reads only, deliberately. Writes already have an owner: the Session, whose one-way direction is an ORM invariant. A layer that also wrote would duplicate that or quietly work around it, which is why this is Queries and not a repository -- the smaller surface is the correct one, not a subset of one.
BoundFusion#
A declared fusion with a session behind it; call it to run it.
BoundFusion(fusion: Fusion, session: Any) -> NoneBoundQuery#
A declared query with a session behind it; call it to run it.
BoundQuery(declaration: QueryDeclaration, session: Any) -> Nonecount#
async count(**values: Any) -> intHow many rows this query matches, without hydrating them.
Fusion#
Two or more declared searches, merged into one ranked answer.
Built by fuse, and used exactly as a declared query is -- an attribute of a Queries subclass, called with the parameters its searches need:
class Documents(Queries[Document]):
nearest = (
query()
.order_by(Document.embedding.cosine_distance(Param("q")))
.limit(50)
)
matching = (
query(Document.search.matches(Param("terms")))
.order_by(Document.search.rank(Param("terms")).desc())
.limit(50)
)
hybrid = fuse(nearest, matching).limit(10)
found = await Documents(session).hybrid(q=vector, terms="llama husbandry")The merge is by rank, not by score. A cosine distance of 0.18 and a ts_rank of 0.06 cannot be added, subtracted, or weighted against each other: they are different units, and any constant that reconciles them is wrong again the next time the embedding model changes. Reciprocal-rank fusion throws the numbers away and keeps the positions -- score = sum(1 / (k + rank)) over the searches that returned the row, ranks counted from 1. A row both searches placed mid-table beats a row one of them placed first, which is the behaviour hybrid retrieval is wanted for.
k damps how much a first place is worth; 60 is the conventional default from the paper the technique comes from, and lowering it makes the top of each search count for more.
Every search in a fusion is named, ordered, and bounded, and all three are checked when the class is defined. The rank is the position, so a query with no order_by has no ranking to contribute; and the bound is what keeps a fusion a merge of two shortlists rather than of two whole tables -- for a vector search it is also the LIMIT that lets the approximate index answer at all.
Named means fuse takes the attributes, never a query(...) written inside the call: a search on no class is in no declarations() listing, and the tools that walk a query set by name -- the transitional-column scanner among them -- would skip it in silence. A search declared on another Queries class is named, and fusing one of those is fine.
Ties in the fused score are broken by primary key ascending, so the same data returns the same order.
Fusion(halves: tuple[QueryDeclaration, ...], *, k: int, limit: int | None = None, settled: bool = False, name: str = 'fusion', parameters: tuple[str, ...] = ()) -> Nonek (property)#
k: intThe reciprocal-rank constant this fusion scores with.
limit#
limit(value: int) -> FusionCap the fused rows. Fixed at declaration time, as query()'s is.
limit_ (property)#
limit_: int | NoneHow many rows the fusion returns, or None for all of them.
Param#
One named value a declared query binds per call.
Param("paddock") stands where a literal would:
by_paddock = query(Llama.paddock_id == Param("paddock"))It subclasses the column expression rather than the value expression for a reason worth knowing: Python gives the right operand of a comparison first refusal only when its type is a proper subclass of the left operand's, and the left operand here is a column. Inheriting from RelatedColumnExpr -- the deepest column type a declaration can compare against -- is what lets both Llama.paddock_id == Param(...) and Llama.paddock.name == Param(...) build a placeholder instead of trying to coerce one into a bigint. A Param is bait for an operator, never a node in a tree; the tree gets a Placeholder.
Because interception happens in the operator, a parameter works with the six comparisons (==, !=, <, <=, >, >=) in either order.
Operators spelled as methods -- like, the jsonb and array operators, the vector distances -- bind their operand before this class can intercept anything, so they reach it the other way round: _bind recognises the _as_placeholder seam and asks for a placeholder of the type that position takes. in_ is the exception, because its operand is a list of values rather than one, and the list's length is part of the query's shape.
Param(name: str) -> Noneall_eq (inherited from ColumnExpr)#
all_eq(value: Any) -> BinaryExprvalue = ALL(self) -- every array element equals value.
any_eq (inherited from ColumnExpr)#
any_eq(value: Any) -> BinaryExprvalue = ANY(self) -- value is an element of the array.
asc (inherited from ColumnExpr)#
asc() -> OrderExprcontained_by (inherited from ColumnExpr)#
contained_by(other: Any) -> BinaryExprself <@ other -- reverse jsonb/array containment.
contains (inherited from ColumnExpr)#
contains(other: Any) -> BinaryExprself @> other -- jsonb/array containment.
cosine_distance (inherited from ColumnExpr)#
cosine_distance(other: Any) -> BinaryExprself <=> other -- cosine distance, the common default. Orderable.
covered_by (inherited from ColumnExpr)#
covered_by(region: Any) -> BinaryExprST_Covers(ST_GeogFromText($1), self) — rows inside a region.
The question tier 1 structurally cannot answer: a catchment, a council boundary, an exclusion zone. region is a wreath.geospatial.Polygon, which is built from Coordinates and therefore cannot arrive with its longitude and latitude the wrong way round.
ST_Covers rather than ST_Contains, and not as a preference: ST_Contains(geography, geography) does not exist, so a query reaching for it fails at the database. ST_Covers also answers "yes" for a point exactly on the boundary, which is the reading a catchment wants -- an address on the line is in the district.
The polygon travels out as text and PostGIS lifts it, so there is no bind-only column type and no OID to resolve. Storing polygons is a different feature; this one only queries against them.
Args:
region— ThePolygonto test containment in.
Returns:
- The predicate, ready for
.where().
Raises:
DeclarationError— This is not aGeographycolumn.TypeError—regionis not aPolygon.
desc (inherited from ColumnExpr)#
desc() -> OrderExprhamming_distance (inherited from ColumnExpr)#
hamming_distance(other: Any) -> BinaryExprself <~> other -- how many bits differ. Orderable.
The query half of binary quantization: over a Bit column, this counts the positions where two signatures disagree, which is the cheap stand-in for distance that lets a 32x smaller index shortlist candidates before the real vectors re-score them.
has_all (inherited from ColumnExpr)#
has_all(keys: Any) -> BinaryExprself ?& keys -- the jsonb object has all of these keys.
has_any (inherited from ColumnExpr)#
has_any(keys: Any) -> BinaryExprself ?| keys -- the jsonb object has any of these keys.
has_key (inherited from ColumnExpr)#
has_key(key: str) -> BinaryExprself ? key -- the jsonb object has this top-level key.
ilike (inherited from ColumnExpr)#
ilike(pattern: str) -> BinaryExprin_ (inherited from ColumnExpr)#
in_(values: Any) -> InExpr | InSubqueryExprself IN (...) against a list of values or a one-column Select.
Args:
values— An iterable of values, rendered asIN ($1, $2, ...); or aSelectprojecting exactly one column, rendered asIN (SELECT ...)so the filter stays in the database.
Returns:
- The membership predicate, ready to pass to
.where().
Raises:
TypeError—valuesis neither an iterable nor aSelect.ValueError—valuesis an empty list. A subquery may legitimately- return no rows, so this applies to the list form only.
DeclarationError— The subquery is not a shapeINaccepts -- see_check_subqueryfor the four refusals and why each exists.
inner_product (inherited from ColumnExpr)#
inner_product(other: Any) -> BinaryExprself <#> other -- negative inner product, as pgvector defines it.
Negative so that ordering ascending still puts the most similar row first, which is the whole reason pgvector spells it that way. Read a result of -0.9 as an inner product of 0.9.
is_not_null (inherited from ColumnExpr)#
is_not_null() -> UnaryExpris_null (inherited from ColumnExpr)#
is_null() -> UnaryExprjaccard_distance (inherited from ColumnExpr)#
jaccard_distance(other: Any) -> BinaryExprself <%> other -- one minus the Jaccard similarity. Orderable.
Set overlap rather than positional agreement: it counts the bits set in both against the bits set in either, so two sparse signatures that share their few set bits are close even though most positions agree trivially by being zero in both. That is the difference from hamming_distance, which those shared zeros dominate.
l1_distance (inherited from ColumnExpr)#
l1_distance(other: Any) -> BinaryExprself <+> other -- taxicab (L1) distance. Orderable.
l2_distance (inherited from ColumnExpr)#
l2_distance(other: Any) -> BinaryExprself <-> other -- Euclidean distance. Orderable.
like (inherited from ColumnExpr)#
like(pattern: str) -> BinaryExprmatches (inherited from ColumnExpr)#
matches(terms: Any, *, parser: str = 'websearch_to_tsquery') -> BinaryExprself @@ websearch_to_tsquery('<config>', terms) -- a text-search predicate.
The configuration comes from the column's own TsVector declaration, so the query is analysed exactly as the stored vector was; a query analysed under a different configuration matches nothing and looks like missing data.
Args:
terms— The search text, bound as a parameter. Awreath.queries.Param- works here as it does for the distance operators.
parser—"websearch_to_tsquery"(the default) or"to_tsquery".- The default never raises on user input --
",&,!and a lone—are just characters to it.to_tsqueryunderstands&/|/!/<->and raises a syntax error on malformed input,- so choose it only where the query is the application's and the
- error is handled.
Returns:
- The predicate, ready for
.where().
Raises:
DeclarationError— This is not aTsVectorcolumn, orparseris- not one of the two.
model (property, inherited from ColumnExpr)#
model: typenearest (inherited from ColumnExpr)#
nearest(centre: Any) -> BinaryExprDistance to centre in metres -- an order key, not a predicate.
order_by(Model.at.nearest(here)) with a limit is the shape an index answers. A number rather than a boolean, exactly as the pgvector distances and ts_rank are, so where() refuses it on its own.
On a Geography column this is PostGIS's <->, which the GiST index answers directly as an Index Scan ... Order By: -- a true nearest-neighbour search rather than tier 1's bounding-box approximation, and measured on the spheroid rather than on a sphere. Both answer metres, so the two tiers are comparable; they differ by the ~0.5% the guide states.
On a Point column it renders the great-circle haversine, unchanged.
not_in (inherited from ColumnExpr)#
not_in(values: Any) -> InExpr | InSubqueryExprself NOT IN (...) against a list of values or a one-column Select.
Takes the same operands as in_, with one extra refusal: a subquery projecting a nullable column. SQL's three-valued logic makes NOT IN (SELECT ...) return no rows at all the moment the subquery yields a single NULL, because x <> NULL is unknown rather than true. That passes every test written against data without NULLs and empties a page in production, so it is refused at the call site instead.
Args:
values— An iterable of values, or aSelectprojecting exactly one- NOT NULL column.
Returns:
- The negated membership predicate.
Raises:
DeclarationError— The subquery projects a nullable column, or any of- the refusals
in_also makes.
overlaps (inherited from ColumnExpr)#
overlaps(other: Any) -> BinaryExprself && other -- the arrays share at least one element.
path (inherited from ColumnExpr)#
path(elements: Any, *, as_json: bool = False) -> _JsonPathA jsonb path extraction completed by a comparison.
Model.data.path(["a", "b"]) == "x" renders (data #>> $1) = $2. as_json=True extracts a sub-document with #> instead, usable with the jsonb operators.
rank (inherited from ColumnExpr)#
rank(terms: Any, *, parser: str = 'websearch_to_tsquery') -> BinaryExprts_rank(self, websearch_to_tsquery('<config>', terms)) -- a relevance score.
A number, not a predicate: order by it (.rank(t).desc()) or compare it against a threshold. where() refuses one on its own, because PostgreSQL would refuse it later with a message about the argument of WHERE rather than about the line that wrote it.
Takes the same arguments as matches, and should be given the same terms and parser -- ranking one query while filtering by another is a bug that looks like bad relevance.
within (inherited from ColumnExpr)#
within(centre: Any, metres: Any) -> PredicateRows whose point is within metres of centre, great-circle.
On a Geography column this is ST_DWithin(col, $1, $2), which PostGIS plans as an && index condition with the exact spheroidal test filtered over what survives -- literally the shape the tier-1 rendering below hand-builds, which is why both are spelled within().
On a Point column it renders as two things ANDed, and both halves are load-bearing:
self <@ box(point(...), point(...))-- the degree-aligned bounding box, which is the only form a GiSTpoint_opsindex can answer, and therefore the only reason this does not read the whole table.- the exact haversine distance, compared against
metres.
The box is a superset of the circle -- its corners reach about 40% further than its edges -- so dropping the second half would return rows that are not within the radius. Dropping the first half would be correct and would scan everything. Neither is optional.
A circle crossing the antimeridian produces two boxes, ORed, because <@ has no notion of a wrapped edge; one reaching a pole produces a box spanning every longitude. wreath.geospatial.bounding_boxes makes that decision and this method only renders it.
Placeholder#
The gap a declaration leaves in its predicate tree.
Not a ValueExpr: a placeholder must never be mistaken for a bound value. Neither the cache key nor the SQL renderer knows this node, so one that reached them by another route fails loudly rather than compiling a query with a missing value in it.
Public because it is half of a contract rather than an implementation detail: compile_rebind takes the marker class to look for, so any module that lets a caller write Param in a predicate and binds it later has to name this type. wreath.series is the second such module. What it is not is something to construct — a Param compared against a column builds one, and that is the only route that gives it a type to coerce with.
Placeholder(name: str, pg_type: Any) -> Nonebind#
bind(values: Any) -> ValueExprThe bound value for this call, coerced to the column's type.
Queries#
A named set of reads over one model.
Subclass it with the model as its type argument and declare the reads as class attributes; construct it with a session to run them:
class Llamas(Queries[Llama]):
by_paddock = query(Llama.paddock_id == Param("paddock"))
herd = await Llamas(session).by_paddock(paddock=7)A subclass with no declarations of its own may leave the model out, so a base class can hold whatever a family of query sets shares.
Queries(session: Any) -> Nonedeclarations (classmethod)#
declarations() -> dict[str, QueryDeclaration]Every declared read on this class, by attribute name.
A declared query is a stable name, and this is where anything that wants to work from those names -- authorization, generated clients -- starts.
fusions (classmethod)#
fusions() -> dict[str, Fusion]Every declared fusion on this class, by attribute name.
Separate from declarations because a fusion is not one query: the things that walk declarations -- the transitional-column scanner, a typegen pass -- want the Select behind each, and a fusion has one per search. Its searches are ordinary declarations and appear there.
QueryDeclaration#
One named read: a query shape, its parameters, and how to bind them.
Created by query in a class body, where the model is not known yet, so it starts as a recording of builder calls and becomes a real Select when Queries names its model. Both states are immutable -- a builder method returns a new declaration, and resolving one produces a new one -- so a declaration written once at module level can be reused by more than one class.
QueryDeclaration(steps: tuple[tuple[str, tuple[Any, ...]], ...], *, single: bool = False, select: Select | None = None, binders: tuple[Any, ...] = (), ordering_binders: tuple[Any, ...] = (), execution_select: Select | None = None, value_program: Any = None, name: str = 'query', parameters: tuple[str, ...] = ()) -> Nonebind#
bind(**values: Any) -> SelectThe query this declaration runs for one set of parameter values.
The result is an ordinary Select, so anything that takes one -- Session.fetch, Session.count, wreath.pagination -- takes a declared query too.
include#
include(*load_options: Any) -> QueryDeclarationLoad relationships with this query.
limit#
limit(value: int) -> QueryDeclarationCap the rows this query returns. Fixed at declaration time.
A per-call limit belongs to wreath.pagination.paginate, which bounds it.
Raises:
DeclarationError— if given aParam.
offset#
offset(value: int) -> QueryDeclarationSkip the first value rows. Fixed at declaration time.
Raises:
DeclarationError— if given aParam.
one#
one() -> QueryDeclarationReturn a single object (or None) rather than a list.
Raises MultipleResultsError if the query matches more than one row, exactly as Session.fetch_one does.
order_by#
order_by(*expressions: Any) -> QueryDeclarationOrder this query's rows. Part of the shape, so no Param here.
A sort column supplied per call would be a different query shape each time, which is the one thing a declared query is not. For request-driven sorting, wreath.pagination.apply_sort checks an allow-list instead.
Raises:
DeclarationError— if given aParam.
single (property)#
single: boolWhether this declaration returns one object rather than a list.
where#
where(*predicates: Predicate) -> QueryDeclarationNarrow this query; predicates combine with AND.
fuse#
fuse(*queries: QueryDeclaration, k: int = 60) -> FusionMerge two or more declared searches into one ranked answer.
hybrid = fuse(nearest, matching).limit(10)Each row is scored by where it placed -- sum(1 / (k + rank)) over the searches that returned it -- so a vector distance and a relevance score become comparable without inventing a weighting between them. See Fusion for what each search must supply and what k does.
query#
query(*predicates: Predicate) -> QueryDeclarationDeclare one named read, to be finished by the builder methods.
Written in the body of a Queries subclass, where the model comes from the class rather than from this call:
class Llamas(Queries[Llama]):
overdue = query(Llama.checked_at < Param("before")).order_by(Llama.checked_at)wreath.crud#
Generate REST CRUD routes from an ORM model — opt-in, and safe by default.
Auto-CRUD is convenient and dangerous: the danger is a GET /users that returns everyone's password_hash. Wreath's version is built to make that impossible by accident:
- Off unless you ask — twice. It is enabled at the app level (
Wreath.enable_crud()) and opted into per model (you callcrud_router()/Wreath.crud()for each one). A model is never exposed just because it exists. - Sensitive fields are hidden and unwritable by default. Any column whose name looks like a secret —
password,*_hash,token,secret,salt,api_key,ssn, … — is excluded from both responses and accepted input. To expose one you must name it explicitly inexpose=(...), an auditable, deliberate act. That name check is a backstop, not a boundary: it withholds the column nobody thought about, and it cannot recognise a secret that does not look like one.fields=(...)is the control. - Retrieval columns are withheld too, and that one is not a guess. A
Vectorembedding or aTsVectoris how rows are found, not what they say, so it is excluded from responses — a page of twentyVector(1536)rows is thirty thousand floats — and from accepted input, because an embedding moves a row to the top of every semantic query while its visible content stays put.expose=(...)names one back; a generated column stays unwritable regardless.
router = crud_router( Widget, open_session, expose=(), readonly=("owner_id",), authorize=Access.authenticated(), ) app.include_router(router) # after app.enable_crud()
Routes (any subset via operations=): GET / (paginated list), GET /{id}, POST /, PATCH /{id}, DELETE /{id}.
Access#
A per-operation authorization rule for generated CRUD routes.
Build one with a factory and hand it to crud_router(authorize=...), either as a single rule for every operation or keyed by operation / group:
crud_router(Widget, open_session, authorize={
"read": Access.public(), # list + retrieve
"create": Access.roles("editor", "admin", mode="any"),
"update": Access.roles("admin"), # only admins
"delete": Access.deny(), # nobody, ever (403)
})Keys may be an operation (list/retrieve/create/update/ delete), a group (read = list+retrieve, write = create+update+ delete), or "*" as the default. A more specific key wins.
Access.cedar() attaches a policy decision that the app's configured CedarAuthorizer resolves — that authorizer (its principal/resource/entity mappers) is the adapter layer for richer evaluations. For decisions that need the loaded row (ownership, tenant match), pass crud_router(object_authorizer=...) instead.
.within(seconds) adds step-up to any of these — DELETE is the operation that most often wants it, and it composes with the rule rather than replacing it.
Access(kind: str, values: tuple[str, ...] = (), mode: str = 'all', action: str | None = None, resource: Any = None, second_factor: float | None = None) -> Noneauthenticated (classmethod)#
authenticated() -> AccessAny authenticated identity.
cedar (classmethod)#
cedar(*, action: str, resource: Any) -> AccessA Cedar policy decision (needs a configured CedarAuthorizer).
resource is a Type::"{id}" template ({id} and other path params are filled in), a plain 'Type::"id"' string, or a (request) -> resource callable.
A string resource is parsed here, at declaration, rather than when a request arrives. The shape is knowable now, and a reference that cannot parse would otherwise surface as a 500 on a route whose declaration plainly intends a 403 -- the framework blaming the caller for the author's typo.
Args:
action— The Cedar action name the policy is asked about.resource— The entity reference, its{param}template, or a- callable that builds one per request.
Returns:
- The rule, for
crud_router(authorize=...).
Raises:
ValueError—resourceis a string that is not a Cedar entity- reference. Pass a callable or an
EntityUidwhen a custom CedarAuthorizer(resource=...)mapper builds the reference- from something else.
deny (classmethod)#
deny() -> AccessNobody — the route exists but always answers 403.
permissions (classmethod)#
permissions(*names: str, mode: str = 'all') -> AccessCallers holding these permissions.
public (classmethod)#
public() -> AccessAnyone, authenticated or not.
roles (classmethod)#
roles(*names: str, mode: str = 'all') -> AccessCallers holding these roles (mode="all" requires every one).
within#
within(seconds: float) -> AccessThis rule, and a second factor proved within the last seconds.
crud_router(Account, open_session, authorize={
"read": Access.authenticated(),
"delete": Access.roles("admin").within(300), # and prove it again
})A combinator rather than a factory, so it composes: the roles check still runs, and the window is checked on top of it by the same @second_factor machinery a hand-written route uses. Stacking two windows keeps the shorter one, exactly as stacking the decorators does.
Step-up implies authentication, so this is refused on public() and on deny(): the first would silently stop being public, and the second already refuses everyone, which makes a window on it a declaration that can never be read.
Args:
seconds— The freshness window. Five minutes is the usual choice.
Returns:
- A new rule;
Accessis frozen, so the original is unchanged.
Raises:
ValueError—secondsis not positive, or the rule ispublic()ordeny().
crud_router#
crud_router(model: type, open_session: Callable[[Any], Any], *, prefix: str | None = None, operations: Iterable[str] = ('list', 'retrieve', 'create', 'update', 'delete'), expose: Iterable[str] = (), fields: Iterable[str] | None = None, readonly: Iterable[str] = (), exclude: Iterable[str] = (), page_size: int = 20, tags: Iterable[str] = (), authorize: Access | Mapping[str, Access] | None = None, object_authorizer: Callable[..., Any] | None = None, precision: Mapping[str, Any] | None = None) -> AnyBuild a Router of CRUD routes for model, mounted under prefix.
model must have a single-column primary key; anything else raises ValueError. prefix defaults to the lower-cased model name, and so does the OpenAPI tag when tags is empty.
There are two ways to control what leaves the server and they are mutually exclusive. expose is the deny-list's escape hatch: SENSITIVE_FIELD hides every column whose name looks like a secret, and expose names the ones to send anyway. fields is an allow-list, and the answer to the deny-list's real weakness — iban, date_of_birth, tax_id, and passport_number do not look like secrets, and widening the pattern until they do would start withholding ordinary columns instead. Naming what may leave is the only form that stays correct when somebody adds a column. Passing both raises ValueError, as does naming a column in fields that the model does not have.
Sensitive columns are unwritable as well as unreadable, and expose does not change that: no CRUD route will ever set one. Change a password through a purpose-built endpoint, not through PATCH. The primary key and everything in readonly are likewise refused on input — silently dropped from the body rather than rejected, so a client sending them gets the row it would have got.
Retrieval columns are withheld both ways, and expose widens both. A Vector embedding or a TsVector (see retrieval_fields) is how rows are found rather than what they say, so it is neither serialized nor accepted by default. On output that is bandwidth: a tsvector is derived from columns already in the same payload, and a page of twenty Vector(1536) rows is thirty thousand floats. On input it is control: ranking is the one thing a row's own editor can change invisibly, since an embedding puts a row at the top of every semantic query while the visible content stays put. An application whose clients compute their own embeddings names the column in expose=("embedding",), which makes both the reading and the writing an explicit, auditable act. A generated column — every TsVector — has no such opt-in on input: PostgreSQL derives it on every write, so expose makes it readable and nothing makes it writable. It is dropped from the body like the primary key rather than rejected with a 422.
authorize rules attach as route metadata that the app enforces in its single-pass pipeline, so a denied write never touches the database. Access.deny() is the exception: it is enforced inside the handler and answers 403 whatever the identity. There is no default: every generated operation must resolve to a rule, and an intentionally open route says Access.public() explicitly.
object_authorizer is the seam for decisions that need the row itself — ownership, tenant match, a Cedar evaluation over the object's own attributes. It runs after the row is loaded on retrieve, update and delete, on the new instance before create commits, and on every row of a list page, which is why a page can come back shorter than size. It may be async, and it returns a bool or an AuthorizationDecision — anything falsey, or a decision whose allowed is false, answers 403.
page_size sets the default page size for GET /. A size query parameter overrides it and is clamped to 100; page is clamped to pagination.MAX_PAGE, because OFFSET makes the database walk every skipped row and an unbounded page number is a table scan on request.
Args:
open_session—(request) -> Session, one per request, closed by the handlerexpose— columns withheld by default — sensitive-looking names, and- retrieval indexes — to include anyway, an explicit opt-in
fields— the only columns to serialize; mutually exclusive withexposereadonly— columns excluded from create and update input, e.g. server-set onesexclude— columns never serialized at alloperations— which of list/retrieve/create/update/delete to generatepage_size— default page size forGET /, raisable per request up to 100authorize— oneAccessrule, or a complete mapping keyed by operation,- group or
"*"; useAccess.public()for intentional public access object_authorizer—(request, op, instance) -> bool, optionally asyncprecision— column name toPrecisionLadder, making a coordinate's- resolution an authorization outcome rather than a verdict — exact
- for one caller, 10 km for another, absent for a third. Applied in
serialize, which is the only path a column takes out of these- routes, so there is no select-the-column-directly way around it.
Raises:
ValueError— the model has a composite primary key, the field lists- conflict,
authorizedoes not cover a selected operation, or precisionnames a column that is not serialized
retrieval_fields#
retrieval_fields(model: type) -> frozenset[str]Column names of model that index content rather than carry it.
A Vector embedding and a TsVector are how rows are found, not what a row says, so generated CRUD treats them as infrastructure: withheld from responses and refused on input unless named in expose=. Unlike sensitive_fields this is not a guess from a name -- it is the declared column type, so it cannot miss one and cannot catch an ordinary column by accident.
sensitive_fields#
sensitive_fields(model: type) -> frozenset[str]Column names of model that look sensitive (hidden by default).
wreath.objects#
Object storage: one protocol, three backends, and no cloud SDK.
An application that stores uploads wants the same handful of operations wherever the bytes end up -- read, write, stat, exists, list, delete, presign -- and wants them to behave the same in a test, on a developer's disk, and against S3. This module is that protocol (ObjectStore) and the backends that satisfy it: MemoryObjectStore for tests, LocalObjectStore for a directory on disk, and S3ObjectStore for S3 and anything speaking its REST API (MinIO, R2, Wasabi, Spaces). ObjectPath is the pathlib-shaped handle over any of them.
Every key goes through normalize_key, which is the single containment gate. A backend cannot be trusted to refuse a traversal on its own -- a local filesystem resolves ../ and an S3 bucket stores a key spelled ../../etc without complaint -- so the check lives in one function that every entry point calls, rather than in each backend where one omission is invisible.
No boto3, and no third-party dependency at all. S3 authentication is SigV4, an HMAC chain over a canonicalised request; wreath._sigv4 is that chain in under two hundred lines, and the transport is wreath's own HTTPClient, so there is no second HTTP stack in the process.
Memory is bounded on the paths where an object can be large, and the paths where it is not say so. Reading from S3 streams back in ranged windows; writing switches to a real multipart upload once the data passes the part size; zip_stream emits a Zip64 archive without ever holding an object or the archive. What buffers, deliberately and by necessity, is unzip_stream (the stdlib zipfile reader needs a seekable archive, bounded by ZipExtractionLimits), file_chunks (its argument is already bytes in hand), and each individual S3 request (the HTTP client is buffered per operation).
Wire a store into an application with wreath.app.Wreath.objects, which gives an S3 store a pinned outbound client started and stopped with the app, and closes a local store's root descriptor on shutdown:
app.objects("assets", backend="s3", bucket="ev-assets", region="ap-southeast-2")
store = app.state.objects_assetsThe guide is Object storage.
ObjectError#
The one exception every backend raises for every failed operation.
Covers a key this module refuses (normalize_key), an object that is not there, a containment escape a local store caught, and a non-success status from S3. One type across the backends is what lets a handler written against MemoryObjectStore catch the same thing in production against S3ObjectStore.
Transport failures below the storage layer are not converted: a connection that never reached S3 raises wreath.http_client.ClientError or OSError, because "the object is missing" and "the network is down" call for different responses and collapsing them loses that.
ObjectStat#
What a backend knows about one object without reading its bytes.
An etag is unquoted in every backend, and comparable only against another stat from the same backend. The quotes an HTTP ETag header carries are a wire detail, so they are stripped on the way in and a caller emitting the header adds them back; what a backend puts inside them still differs. MemoryObjectStore uses an MD5 of the content, LocalObjectStore an mtime-size pair -- cheap, and not a content hash -- and S3ObjectStore whatever S3 returned. Comparing two stats from the same backend is meaningful; comparing across backends is not.
last_modified is None wherever the backend does not supply one: MemoryObjectStore never does, and S3ObjectStore.list does not either, since only a HEAD carries the header. size is -1 only from S3ObjectStore.write_stream, when a multipart upload completed but the confirming HEAD did not answer.
Args:
key— the object's normalised key.size— length in bytes.etag— the backend's version tag, in the backend's own format.last_modified— POSIX timestamp, when the backend supplies one.content_type— the media type, when the backend records or guesses one.
ObjectStat(key: str, size: int, etag: str, last_modified: float | None = None, content_type: str | None = None) -> NoneObjectStore#
The contract every backend satisfies, and the type to annotate against.
Runtime-checkable, so isinstance(store, ObjectStore) holds for anything with these methods -- which is a structural check on names only, not on signatures. Annotate a handler with this rather than with a concrete backend and the same code runs against memory in a test and S3 in production.
Every method normalises the key it is given, so a caller never has to. The keys a store returns are always already normalised.
ObjectStore(*args, **kwargs)delete#
async delete(key: str) -> NoneRemove key. Deleting an object that is not there is not an error.
exists#
async exists(key: str) -> boolWhether key is present, without transferring the object.
list#
list(prefix: str = '', *, delimiter: str | None = None) -> AsyncIterator[ObjectStat]Yield the objects whose key starts with prefix, in key order.
delimiter is honoured only by S3ObjectStore, which passes it to the bucket; the local and memory backends ignore it. No backend surfaces the common prefixes a delimiter groups -- only the objects.
path#
path(key: str) -> ObjectPathAn ObjectPath bound to this store and key.
read#
async read(key: str) -> bytesThe whole object as bytes. Raises ObjectError when it is absent.
read_stream#
read_stream(key: str, *, range: tuple[int, int] | None = None) -> AsyncIterator[bytes]Stream the object in chunks, so a large one never has to fit in memory.
Args:
key— the object to read.range— an inclusive(first, last)byte range, as HTTP spells one.
stat#
async stat(key: str) -> ObjectStatMetadata for one object. Raises ObjectError when it is absent.
url#
url(key: str, *, expires: int = 3600, method: str = 'GET') -> strA signed URL granting method on key, minted without a round trip.
Args:
key— the object the URL grants access to.expires— lifetime in seconds from now, not an absolute time.method— the HTTP method authorised. AGETURL will not serve aPUT.
write#
async write(key: str, data: bytes | bytearray | memoryview, *, content_type: str | None = None) -> ObjectStatStore data under key, replacing whatever was there.
data may be any of bytes, bytearray or memoryview, and no backend keeps a reference to it past the await. A caller may reuse or mutate the buffer as soon as the call returns; a backend that wanted to hold on to one would have to copy it itself.
The parameter was bytes alone, which forced every caller assembling an object in a bytearray to freeze it first. That copy is pure memcpy and measured 391us on an 8 MiB append -- roughly half of what an append costs -- so it was work being done for nobody: no backend needed the immutability. Widening an input is backwards compatible, and an existing implementation annotated bytes still satisfies this at runtime; the contract it now has to honour is the no-retention sentence above, which all three backends here already did.
write_stream#
async write_stream(key: str, chunks: AsyncIterable[bytes], *, content_type: str | None = None) -> ObjectStatStore an async stream of chunks under key, without buffering it whole.
ObjectPath#
An immutable handle to one key in one store, shaped like pathlib.Path.
Joins with /, inspects with name/suffix/parent, and moves bytes with read_bytes/write_bytes/open. The store travels with the handle, so a function can take a path and need to know nothing about which backend is behind it:
assets = store.path("assets")
await (assets / "2026" / "logo.svg").write_bytes(svg, content_type="image/svg+xml")"" and "/" both mean the store's root, which is the one key normalize_key would refuse -- a root handle is the natural starting point for joining and for iterdir, so it is admitted here and only here. Every other key goes through the gate at construction.
Two paths are equal when they name the same key in the same store object (compared by identity), because the same key in two different buckets is not the same object and treating it as one would let a cache serve the wrong bytes.
Nothing here is cached: a path is a name, not a snapshot, so exists and read_bytes always ask the store.
ObjectPath(storage: ObjectStore, key: str) -> Noneexists#
async exists() -> boolWhether an object is stored at this key, asked afresh each call.
glob#
glob(pattern: str) -> AsyncIterator[ObjectPath]Every object beneath this path whose key matches pattern.
Matching is fnmatch.fnmatch over the whole key, so * crosses /: under reports/, the pattern *.csv matches reports/2026/q3.csv as well as reports/summary.csv. That is not pathlib's rule, where * stops at a separator and ** is needed to descend; here there is no separator to stop at, only a key.
The filter is applied in this process, after the store has listed the prefix. A pattern is not a cheaper listing -- it is the same listing with rows discarded -- so narrow the path rather than the pattern when the prefix is large.
iterdir#
iterdir() -> AsyncIterator[ObjectPath]Every object beneath this path, recursively, in key order.
The name is pathlib's and the behaviour is deliberately not: object stores have no directories, only keys with slashes in them, so there is no such thing as a direct child to stop at. This yields every key under the prefix at any depth. For one level only, pass delimiter="/" to ObjectStore.list -- on S3, the one backend that honours it.
key (property)#
key: strThe normalised key, or "" for the store's root.
name (property)#
name: strThe last segment of the key, like pathlib.PurePath.name.
open#
open(mode: str = 'rb') -> _PathIOAn async context manager over this object.
"rb" iterates chunks and answers .read() with the whole object; "wb" collects .write() calls in memory and stores them as one object when the block ends normally. A "wb" block therefore holds the whole object -- for a large one, call ObjectStore.write_stream directly. A block that raises stores nothing.
There is no append and no read-write mode: an object store replaces objects whole, so either would be a shape this cannot honour.
Args:
mode—"rb"or"wb".
Raises:
ObjectError— for any other mode, including"r","w"and"a".
parent (property)#
parent: ObjectPathThe path one segment up, in the same store.
The root is its own parent, so walking upwards terminates rather than producing a key that means something else.
presigned_url#
presigned_url(*, expires: int = 3600, method: str = 'GET') -> strA signed URL for this key. See ObjectStore.url.
read_bytes#
async read_bytes() -> bytesThe whole object as bytes.
Raises:
ObjectError— when the object is not there.
suffix (property)#
suffix: strThe final extension including the dot, or "" when there is none.
A leading dot does not begin an extension, so .gitignore has no suffix -- the same rule pathlib.PurePath.suffix follows.
unlink#
async unlink() -> NoneDelete this key. Not an error when it is already gone.
write_bytes#
async write_bytes(data: bytes | bytearray | memoryview, *, content_type: str | None = None) -> ObjectStatStore data at this key, replacing whatever was there.
Args:
data— the object's new contents, asbytes,bytearrayormemoryview. The backend retains none of it; seeObjectStore.write.content_type— the media type. What a backend does with it varies; see each.
Returns:
- The stat of the object just written.
MemoryObjectStore#
A store in a dict: the test and development twin of a real backend.
Satisfies ObjectStore completely, so a handler written against the protocol runs unchanged against this in a test and against S3 in production -- including key normalisation, which is the check most hand-rolled fakes leave out and so the one a test with a fake never catches.
Everything lives in one process's memory, for the lifetime of the object. Nothing is bounded and nothing expires: a test that writes a gigabyte holds a gigabyte. There is no locking either, which costs nothing here because every method's mutation is a single dict operation and there is no await inside one for another task to interleave at.
MemoryObjectStore(*, url_secret: bytes | None = None) -> Nonedelete#
async delete(key: str) -> NoneRemove key. Not an error when it is already gone.
exists#
async exists(key: str) -> boolWhether key is present.
An unusable key is a caller's mistake rather than an absent object, so it is refused rather than reported as False.
Raises:
ObjectError— whenkeyis not a valid key.
list#
list(prefix: str = '', *, delimiter: str | None = None) -> AsyncIterator[ObjectStat]Every object whose key starts with prefix, in key order.
prefix is matched literally against normalised keys and is not itself normalised, so it can end mid-segment ("report" matches "reports/q3.csv") exactly as an S3 prefix does. delimiter is accepted for protocol compatibility and ignored.
path#
path(key: str) -> ObjectPathread#
async read(key: str) -> bytesThe whole object as bytes.
Raises:
ObjectError— whenkeyis absent or is not a valid key.
read_stream#
read_stream(key: str, *, range: tuple[int, int] | None = None) -> AsyncIterator[bytes]Yield the object in 64 KiB chunks.
The chunking is for shape, not for memory: the object is already whole in this process, so this slices what it has. It exists so the calling code is the same code that streams from S3. An empty object yields no chunks, which is what the other two backends do with one.
Args:
key— the object to read.range— an inclusive(first, last)byte range, clamped by plain slicing.
stat#
async stat(key: str) -> ObjectStatMetadata for one object, with last_modified always None.
There is no clock here on purpose: a fake that invented modification times would let a test assert on ordering this backend cannot actually guarantee.
Raises:
ObjectError— whenkeyis absent or is not a valid key.
url#
url(key: str, *, expires: int = 3600, method: str = 'GET') -> strA memory:// URL signed with this store's secret.
Nothing serves it -- the scheme is unroutable on purpose, so a test that accidentally hands one to a real HTTP client fails rather than reaching somewhere. It exists so the presign call site is exercised, and so a route's verification can be tested against verify_local_url here exactly as it will run against LocalObjectStore.
The query's expires is the absolute UNIX time the URL stops being valid -- expires=900 here means "valid for the next 900 seconds", and what lands in the URL is that deadline. See LocalObjectStore.url.
Args:
key— the object the URL grants access to.expires— lifetime in seconds from now; the URL carries the deadline.method— the method authorised. Case-insensitive, and part of the signature.
verify_local_url#
verify_local_url(key: str, *, method: str, expires: int, signature: str) -> boolWhether a URL from url is authentic and still inside its deadline.
Identical in behaviour and arithmetic to LocalObjectStore.verify_local_url, so a route that checks presigned URLs can be tested against this store and deployed against that one.
Args:
key— the key from the URL's path.method— the request's method.expires— the URL'sexpiresvalue -- an absolute UNIX time.signature— the URL'ssignaturevalue.
Returns:
- Whether the signature matches and the deadline is still ahead.
Raises:
ObjectError— whenkeyis not a valid key.
write#
async write(key: str, data: bytes | bytearray | memoryview, *, content_type: str | None = None) -> ObjectStatStore data under key, replacing whatever was there.
The bytes are copied on the way in, so this backend retains nothing of the caller's buffer and a bytearray may be reused the moment the call returns. content_type is recorded and returned by stat -- this is the only backend that stores it verbatim.
write_stream#
async write_stream(key: str, chunks: AsyncIterable[bytes | bytearray | memoryview], *, content_type: str | None = None) -> ObjectStatDrain chunks and store the result. Buffers the whole object.
LocalObjectStore#
A directory on disk, confined beneath a root that cannot be escaped.
Containment is enforced by descriptor, not by string comparison: the root is opened once at construction and every read resolves relative to that descriptor through wreath._fsguard's openat walk -- the same machinery static-file serving uses. A key that resolves outside the root, or that traverses a symlink on the way, is refused. Comparing resolved paths instead would be a check with a window in it: the path can be replaced between the check and the open.
Writes land on a temporary file beside the target and arrive by os.replace, so a reader sees the old object or the new one and never a partial one. The rename's durability is not guaranteed: the file's contents are fsync'd before the rename, but the containing directory is not, so a machine that loses power immediately afterwards can come back with the object missing. Its contents are never torn.
The root is created if it does not exist. Call close to release the root descriptor -- wreath.app.Wreath.objects does it on shutdown.
Omitting url_secret generates a fresh random one, so signed URLs do not survive a restart and are not valid across workers. Pass one explicitly for anything but a single process.
Args:
root— the directory everything lives beneath. Created if absent.url_secret— HMAC key of at least 32 bytes. Random when omitted.
LocalObjectStore(root: str | os.PathLike[str], *, url_secret: bytes | None = None) -> Noneclose#
close() -> NoneRelease the root descriptor. Safe to call more than once.
The descriptor is forgotten before it is closed, so a second call does nothing at all. That matters more than it looks: a file descriptor number is reused by the very next open in the process, so closing a remembered number twice can close a completely unrelated file that happened to inherit it -- a bug that surfaces far away from here, as an EBADF on something that was never touched.
A store is unusable afterwards; every operation resolves against that descriptor.
delete#
async delete(key: str) -> NoneRemove key. Not an error when it is already gone.
A missing parent directory counts as already gone, as does a missing file, as does a path component that is a symlink -- the containment refusal reaches the same conclusion a caller wanted, which is that nothing is stored at that key afterwards. Empty directories left behind by the last object under them are not removed: pruning them would race a concurrent write creating the same path.
Raises:
ObjectError— when the key itself is not a valid key.OSError— for any filesystem failure other than "not there".
exists#
async exists(key: str) -> boolWhether an object is stored at key.
False covers every refusal, not only absence: a key that escapes the root or crosses a symlink is reported as not present, because from a caller's side there is nothing there to read either way.
list#
list(prefix: str = '', *, delimiter: str | None = None) -> AsyncIterator[ObjectStat]Every object whose key starts with prefix, in key order.
The native walker materializes and sorts one subtree on a thread before the first result is yielded. A prefix containing complete directory components starts at that directory, so unrelated sibling trees are not visited. Symlinks are skipped and never followed, both for entries and for directories, so the listing cannot leave the root.
A key that vanishes between the walk and its stat is dropped silently rather than raising: a listing that races a delete should report what is there, not fail.
delimiter is accepted for protocol compatibility and ignored; this backend always lists recursively.
Every regular file under the root is an object here except a write's own temporary, which is skipped: a file matching the exact shape write_stream gives one (.<name>.<12 hex digits>.tmp) is left behind only by a write that was killed outright, and reporting it would put a key in the listing that no caller ever wrote. The pattern is deliberately narrow rather than "hidden files" or "anything ending in .tmp", so an object a caller genuinely named notes.tmp -- or .env -- is still listed. A key that happens to collide with the pattern is invisible here, though read and stat still answer for it.
path#
path(key: str) -> ObjectPathAn ObjectPath bound to this store and key.
read#
async read(key: str) -> bytesThe whole object as bytes, assembled from read_stream.
Raises:
ObjectError— when the object is absent, escapes the root, or crosses a symlink.
read_stream#
read_stream(key: str, *, range: tuple[int, int] | None = None) -> AsyncIterator[bytes]Yield the object in 64 KiB chunks, off a thread.
Every file-system call runs through asyncio.to_thread, because a read from a slow disk or a network mount would otherwise block the event loop for every other request in the process. The descriptor is closed when the iterator is exhausted, and by the generator's aclose when a consumer stops early.
Args:
key— the object to read.range— an inclusive(first, last)byte range, clamped to the size.
Raises:
ObjectError— when the object is absent, escapes the root, or crosses a symlink.
stat#
async stat(key: str) -> ObjectStatMetadata for one object.
The etag is <mtime_ns hex>-<size hex>, not a hash of the content: hashing would mean reading the whole object to answer a metadata question. It changes whenever the object is rewritten, which is what an etag is for, but it is not comparable with any other backend's.
The content type is guessed from the key's extension by mimetypes, because nothing was persisted at write time.
Raises:
ObjectError— when the object is absent, escapes the root, or crosses a symlink.
url#
url(key: str, *, expires: int = 3600, method: str = 'GET') -> strA signed relative path: /<key>?expires=…&signature=….
There is nothing to redirect a browser to on a local disk, so this is half of a presign: wreath mounts no route for it, and the application serves the object from a route of its own after checking the signature with verify_local_url. That keeps a development handler and its S3 counterpart the same call -- store.url(...) -- with only the route behind it differing.
The URL carries a deadline, not a lifetime. expires is seconds from now, as it is on every backend, but what lands in the query string is the absolute UNIX time the URL stops working -- and that timestamp is covered by the HMAC, so it cannot be edited to buy more time. verify_local_url refuses a URL past it. Carrying the deadline rather than the issue time is what makes the check possible at all with no state kept between the two calls, and it is how SigV2 presigning spelled the same idea.
The clock that matters is this process's, not the client's, since the deadline is compared here on the way back in. S3 differs in mechanism -- SigV4 signs the issue time and AWS applies the window -- but agrees in effect: neither depends on the client's clock.
Rotating url_secret still invalidates every outstanding URL at once, which is the lever to reach for when a deadline is too far away.
Args:
key— the object the URL grants access to.expires— lifetime in seconds from now; the URL carries the deadline.method— the method authorised. Case-insensitive, and part of the signature.
verify_local_url#
verify_local_url(key: str, *, method: str, expires: int, signature: str) -> boolWhether a URL from url is authentic and still inside its deadline.
The route-side half of url: read expires and signature off the query string, pass them here with the key and the request's method, and serve the object only when this answers true. The signature comparison is hmac.compare_digest, so it does not leak the correct signature through its timing.
Two things are checked. The signature must be this store's over the method, the key and the deadline -- so none of the three can be edited. The deadline must still be ahead of this process's clock, which is what makes expires mean something rather than merely being signed.
Both a forged signature and an expired one answer False; a route that wants to tell a user which is which has to decide that on its own, and usually should not.
Args:
key— the key from the URL's path.method— the request's method.expires— the URL'sexpiresvalue -- an absolute UNIX time.signature— the URL'ssignaturevalue.
Returns:
- Whether the signature matches and the deadline is still ahead.
Raises:
ObjectError— whenkeyis not a valid key.
write#
async write(key: str, data: bytes | bytearray | memoryview, *, content_type: str | None = None) -> ObjectStatStore data at key atomically, without copying it. See write_stream.
write_stream#
async write_stream(key: str, chunks: AsyncIterable[bytes | bytearray | memoryview], *, content_type: str | None = None) -> ObjectStatStream chunks to key, atomically and without buffering.
Parent directories are created as needed, each one opened with O_NOFOLLOW so a symlink planted in the path is refused rather than followed out of the root. Bytes go to a hidden temporary file in the target's own directory, are fsync'd, and then os.replace puts them at the key -- so a concurrent reader gets the old object or the new one.
A failure anywhere, including cancellation, unlinks the temporary file before the exception continues. Only a hard kill leaves one behind, and it is inert: nothing ever reads it back, and list skips its .<name>.<hex>.tmp shape rather than reporting a key nobody wrote. It does still occupy disk until something removes it.
content_type is not persisted. A filesystem has nowhere to keep it, so a later stat guesses from the key's extension instead; a media type the extension does not imply survives only as far as this call's return value.
Args:
key— where to store the object.chunks— the object's contents, asbytes,bytearrayormemoryview. Each is written straight to the file descriptor- and none is retained. Empty chunks are skipped.
content_type— echoed into the returned stat, and stored nowhere.
Returns:
- The stat of the object as it landed, read back with
os.stat.
Raises:
ObjectError— when the key is invalid or a path component is a symlink.OSError— from the filesystem -- a full disk, a permission failure.
S3ObjectStore#
S3, and anything that speaks its REST API: MinIO, R2, Wasabi, Spaces.
Requests go over wreath's own HTTPClient and are authenticated with SigV4 from wreath._sigv4. There is no boto3 and no third-party dependency: SigV4 is an HMAC chain over a canonicalised request, which is a small amount of exact code, and taking on an SDK to avoid writing it would put a second HTTP stack and a second retry policy in the process.
The client must already be pinned to the host this store signs for. Requests are issued with a path, not an absolute URL, so the scheme and host come from the client's base_url while the signature covers host=. A mismatch between the two produces a signature the server rejects rather than anything more helpful. wreath.app.Wreath.objects wires the pair together and is the supported way to build one.
Reads and writes are both memory-bounded, by different mechanisms. read_stream walks the object in ranged windows; write_stream switches to a real multipart upload once buffered data passes the part size. A single request is still buffered whole by the HTTP client, so window and part_size are the actual bound on how much of an object is resident.
Nothing here retries. A failed request raises, and a caller that wants to try again decides that with the context to do it safely -- a PUT is idempotent and a completed multipart upload is not.
The public orphaned_uploads counter reports multipart uploads this store began, failed to finish, and then failed to abort; each one is billable storage nothing will reclaim without a bucket lifecycle rule. Export it if the application exports anything.
This store signs its URLs with its AWS credentials, so it has no url_secret of its own and refuses one rather than accepting a security-shaped argument it would ignore.
region must match the bucket's even when a custom host is given, because it is part of the signing scope; an S3-compatible server that ignores regions will accept any consistent value. The default host is the AWS virtual-hosted name and is wrong for every such server -- pass the endpoint's host with path_style=True for those.
part_size is raised to 5 MiB if a smaller value is given, because S3 rejects any part but the last below that; window is raised to 64 KiB. A larger window means fewer round trips and more resident memory.
Args:
client— anHTTPClientwhosebase_urlresolves tohost.bucket— the bucket name.region— the AWS region. Part of the signing scope, not just routing.access_key— the access key id.secret_key— the secret access key. Never appears in__repr__.session_token— an STS token for assumed-role credentials.host— theHostheader to sign and send.scheme— used only byurl; the transport's scheme comes from the client.path_style— address objects as/{bucket}/{key}.service— the SigV4 service name."s3"unless deliberately impersonating.part_size— bytes buffered beforewrite_streamflushes a multipart part.window— bytes requested per ranged GET inread_stream.url_secret— refused. Named only so passing one raises here, not later.
Raises:
TypeError— whenurl_secretis given; SigV4 signs with the credentials.
S3ObjectStore(client: Any, *, bucket: str, region: str, access_key: str, secret_key: str, session_token: str | None = None, host: str | None = None, scheme: str = 'https', path_style: bool = False, service: str = 's3', part_size: int = 8388608, window: int = 8388608, url_secret: bytes | None = None) -> Nonedelete#
async delete(key: str) -> NoneRemove key. Not an error when it is already gone.
S3 answers 204 whether or not the object was there, so this cannot and does not report which happened.
Raises:
ObjectError— for any status but 200 or 204.
exists#
async exists(key: str) -> boolWhether key is present, from a HEAD.
403 is reported as absent, not as an error. A bucket whose policy does not grant s3:ListBucket answers a HEAD for a missing object with 403 rather than 404, deliberately, so that a caller cannot probe which keys exist. Treating that as a failure would make this method unusable on a correctly locked-down bucket; treating it as absence gives the same answer a permitted caller would get. The cost is that a genuine permission problem reads as an empty bucket -- if this always answers False, check the policy before concluding the objects are missing.
Raises:
ObjectError— for any status but 200, 403 or 404.
list#
list(prefix: str = '', *, delimiter: str | None = None) -> AsyncIterator[ObjectStat]Yield the bucket's objects under prefix, following pagination.
Uses ListObjectsV2 and follows the continuation token until the bucket says it is not truncated, so a caller sees every match without handling a page size. Results arrive in the bucket's order, which for S3 is lexicographic by key.
delimiter makes the bucket omit objects whose key continues past the next occurrence of it after the prefix. The CommonPrefixes returned in their place are not yielded, so delimiter="/" gives the direct children only, with no way to see the subdirectories from here.
A listing carries neither modification time nor media type, so both are None on every stat; call stat for either. Keys are normalised on the way out, so a zero-byte "folder marker" that some consoles create as photos/ is reported as photos -- a different key from the one the bucket holds.
Args:
prefix— matched literally against keys by the bucket. Not normalised.delimiter— groups keys in the bucket; see above.
Yields:
- One
ObjectStatper object, in the bucket's key order.
Raises:
ObjectError— for any status but 200, or for a key that is refused.
path#
path(key: str) -> ObjectPathAn ObjectPath bound to this store and key.
read#
async read(key: str) -> bytesThe whole object, in one unranged GET.
Holds the object in memory, so reach for read_stream for anything whose size is not known to be small.
An ObjectError message carries the first 512 bytes of S3's XML error body, which is where the actual reason is.
Raises:
ObjectError— for any status but 200, including an absent object's 404.
read_stream#
read_stream(key: str, *, range: tuple[int, int] | None = None) -> AsyncIterator[bytes]Yield the object in ranged windows of window bytes.
Each window is a separate signed GET with a Range header, so resident memory is one window rather than one object. The cost is one round trip per window, which is the trade window tunes.
With no range, the walk runs until a window comes back short or empty -- a short read is the end of the object. Reaching past the end returns 416, which is treated as completion rather than as an error, because an object whose size is an exact multiple of the window legitimately lands there.
Args:
key— the object to read.range— an inclusive(first, last)byte range. Windows are clipped to it.
A server may answer 200 only to the first unbounded read, when the complete object fits inside one requested window. A 200 for an explicit range, or after a previous window, means it ignored the range and is refused before its body is yielded.
Raises:
ObjectError— for an invalid range, an oversized response window,- or a response status inconsistent with a ranged request.
stat#
async stat(key: str) -> ObjectStatMetadata for one object, from a HEAD. No bytes are transferred.
A bucket that does not grant s3:ListBucket answers 403 rather than 404 for a missing object, and this reports that as an error; exists is the method that treats it as absence.
Raises:
ObjectError— for any status but 200, including a 404 or a 403.
url#
url(key: str, *, expires: int = 3600, method: str = 'GET') -> strA fully signed URL a browser can use directly, minted without a round trip.
The signature is query-string SigV4, computed in this process from the credentials the store already holds, so handing a client an upload or download URL costs no call to S3. The credentials themselves are never in the URL -- only the access key id, the scope, and the signature.
Unlike LocalObjectStore.url, this really expires: the URL carries its own signing time in X-Amz-Date and AWS rejects it after expires seconds, so the client's clock is irrelevant.
AWS caps a SigV4 presign at seven days. This method refuses zero, negative, non-integer, and longer lifetimes before minting a URL S3 would reject later.
Args:
key— the object the URL grants access to.expires— lifetime in seconds from now.method—"PUT"for a direct browser upload,"GET"for a download.
Returns:
- An absolute URL using this store's
schemeandhost.
write#
async write(key: str, data: bytes | bytearray | memoryview, *, content_type: str | None = None) -> ObjectStatStore data at key with one signed PUT.
The body is signed rather than sent unsigned, so its SHA-256 is computed over the whole object before the request goes out. Use write_stream above a few megabytes.
content_type is sent as the content-type header and covered by the signature, so the object is stored with that media type and served with it afterwards. Omitting it leaves S3's own default, binary/octet-stream -- nothing is guessed from the key here, because a guess that is wrong is served to every future reader and there is no way to tell it from a deliberate choice.
Args:
key— where to store the object.data— the object's contents, asbytes,bytearrayor- `
memoryview. Nothing here retains it` — the HTTP client copies - the body once on its way to the socket.
content_type— the media type, sent as a header and stored by S3.
Returns:
- A stat with S3's etag, unquoted, and no
last_modified.
Raises:
ObjectError— for any status but 200.
write_stream#
async write_stream(key: str, chunks: AsyncIterable[bytes | bytearray | memoryview], *, content_type: str | None = None) -> ObjectStatStore a stream at key, as one PUT or as a multipart upload.
Chunks accumulate in a buffer; nothing is sent until it reaches part_size, at which point a multipart upload is initiated and each full part is uploaded and dropped from the buffer. A stream that never reaches part_size is stored with a single PUT instead, so a small upload does not pay for three round trips. Resident memory is therefore bounded by part_size plus the largest single chunk.
Once the upload has been initiated, every failure aborts it -- a part that S3 rejects, a chunk iterator that raises, a completion that fails, a cancellation -- and then the original exception propagates. An upload left open is parts already stored and billed that belong to no object, so the abort covers the whole window in which one can exist rather than only the last step of it.
If the abort itself fails, orphaned_uploads is incremented and the parts stay in the bucket until a lifecycle rule reaps them: there is no third attempt worth making from inside a request that is already failing, but there is a number that says it happened. A bucket taking streamed writes wants that lifecycle rule regardless, since a process killed outright never reaches the abort at all.
When the confirming HEAD after a multipart upload fails -- an eventually consistent endpoint, a bucket that permits writes but not reads -- the object is nonetheless stored, so a stat with size=-1 and an empty etag is returned to say so rather than raising over an upload that succeeded.
Args:
key— where to store the object.chunks— the object's contents.content_type— sent on thePUT, or on the multipart initiate.
Returns:
- The stat read back with
stat, or thesize=-1stat described above.
Raises:
ObjectError— from any failing request in the sequence.
ZipExtractionLimits#
Hard resource ceilings for one unzip_stream extraction.
The archive and each entry are buffered because the stdlib ZIP reader needs seekable input and verifies an entry after decompression. These limits make both allocations explicit and also bound entry-count work and total output.
ZipExtractionLimits(max_archive_bytes: int = 16777216, max_entries: int = 1024, max_entry_bytes: int = 16777216, max_total_bytes: int = 67108864) -> Nonenormalize_key#
normalize_key(key: str) -> strReturn the canonical form of key, or refuse it.
The single containment gate. Every method on every backend calls it before the key reaches a filesystem or a URL, so a traversal is refused once here rather than four times in four backends.
Empty and . segments are collapsed, and a backslash is treated as a separator, so a key that arrived from a Windows client normalises to the same value it would have anywhere else. The result always uses /, on every platform, because a key is not a path -- it is what an S3 bucket will be asked for.
Refused, each as an ObjectError: a key that is not a string, an empty key, an absolute key, a key containing a .. segment, one containing a C0 control character or DEL, one that is not valid UTF-8 or exceeds S3's 1024-byte key limit, and one that collapses to nothing ("."). A non-string says so and names the type it got: None arriving here is almost always a field that was never populated, and reporting it as an empty key sends the reader looking for the wrong bug.
A .. segment is refused rather than resolved. Resolving it would make a/../b and b the same object, so a caller that built a key by concatenating request data would silently address something it did not name.
Args:
key— the object key to canonicalise.
Returns:
- The key with
//and.segments collapsed and separators as/.
Raises:
ObjectError— for any of the refusals above.
zip_stream#
zip_stream(storage: ObjectStore, keys: Iterable[str]) -> AsyncIterator[bytes]Stream a Zip64 archive of keys straight out of storage.
Hand the result to a streaming response and a caller downloads a hundred objects as one file while the process holds none of them:
return StreamingResponse(
zip_stream(store, keys),
headers=[(b"content-type", b"application/zip")],
)Each entry is stored, not deflated (method 0). Compression would mean knowing the compressed size before the local header is written, which is exactly the thing that forces the whole entry into memory; instead the sizes and the CRC32 are computed as the bytes go past and emitted afterwards in a data descriptor. So the archive is at least as large as its contents, and that is the price of never buffering one.
The central directory always uses Zip64 fields, whatever the size, so there is no threshold at which the format changes and no untested branch that only a four-gigabyte archive would reach.
Output is deterministic: every entry's modification time is a fixed 1980-01-01, so the same keys over the same bytes produce byte-identical archives and a caller may cache or checksum one.
Memory is bounded by the number of entries, not by their size -- one name, CRC, size and offset are retained per key to build the central directory at the end.
A missing key fails part-way through. Entries are streamed as they are read, so the ObjectError for a key that is not there can arrive after bytes have already been yielded. A response that has begun cannot be turned into an error, so the client receives a truncated archive; check the keys exist before starting if that matters.
Args:
storage— the store the objects are read from.keys— the objects to include, in order. Each is normalised for its name.
Yields:
- The archive's bytes, starting with the first entry's header.
Raises:
ObjectError— for a key that is not there, possibly mid-stream.
unzip_stream#
async unzip_stream(storage: ObjectStore, key: str, *, prefix: str = '', limits: ZipExtractionLimits = ZipExtractionLimits(max_archive_bytes=16777216, max_entries=1024, max_entry_bytes=16777216, max_total_bytes=67108864)) -> list[str]Expand a bounded zip object into one object per entry.
The stdlib ZIP reader needs a seekable archive and verifies each entry after decompression, so this function buffers the archive and one entry at a time. limits bounds both allocations, the number of entries parsed, and the cumulative output before any destination is written.
Directory entries are skipped -- an object store has no directories, so a zero-byte object named after one would be noise. Every destination key and every declared size is validated before extraction starts, so a refused archive leaves no partial output behind.
Args:
storage— where the archive is read from and the entries are written back.key— the archive object.prefix— prepended verbatim to each entry's name. Not a path join.limits— hard ceilings for archive bytes, entries, one entry, and total output.
Returns:
- The normalised keys written, in archive order.
Raises:
ObjectError— when a resource limit or destination-key check is refused.zipfile.BadZipFile— when the object is not a readable zip.
file_chunks#
file_chunks(data: bytes, *, chunk_size: int = 1048576) -> AsyncIterator[bytes]Adapt bytes already in hand to the stream ObjectStore.write_stream wants.
await store.write_stream(key, file_chunks(upload.content))This saves no memory, and is not pretending to. The argument is already a whole object in this process; slicing it merely gives write_stream the shape it takes, which is what lets an S3 store turn a large payload into a multipart upload instead of one enormous PUT.
It is the wrong bridge for a spooled upload. A multipart part over RequestLimits.spool_max_bytes (1 MiB by default) is written to a temporary file and wreath.request.UploadedFile.data is left empty, so file_chunks(upload.data) stores a zero-byte object for exactly the uploads large enough to want streaming. Iterate upload.chunks() instead, which reads either kind, and wrap it in an async generator for write_stream.
Args:
data— the object's contents.chunk_size— bytes per chunk. The 1 MiB default is below every part size.
Yields:
- Successive slices of
data; nothing at all whendatais empty.
MemoryUploadStore#
Upload state in one worker's memory.
The default, and it confines an upload to the worker that created it: a resume that lands on a sibling worker finds no such upload and is answered 404, so the client starts again. That is a wasted upload, not a corrupt one -- the failure is closed -- but it makes this the wrong choice behind any load balancer that is not sticky. ObjectUploadStore is the shared one, and it needs no second datastore.
Not synchronised, for the same reason MemoryObjectStore is not: every method's mutation is a single dict operation with no await inside it, so there is nothing for another task to interleave at.
MemoryUploadStore(*, max_entries: int = 1024) -> Noneadvance#
async advance(state: UploadState, *, expected: int) -> boolclaim#
async claim(state: UploadState, *, expected: int) -> boolcreate#
async create(state: UploadState) -> Nonedelete#
async delete(upload_id: str, *, claim: str = '') -> boolexpired#
async expired(before: float) -> _List[UploadState]read#
async read(upload_id: str) -> UploadState | Nonerelease#
async release(state: UploadState) -> boolObjectUploadStore#
Upload state as a small object beside the bytes, in the same store.
Admission and state advances require the backing store's atomic upload compare-and-swap capability. Construction refuses a store without it so a multi-worker deployment cannot silently degrade to a read-then-write race.
Args:
store— where records live; ordinarily the same store as the bytes.prefix— key prefix for records, kept distinct from the objects' own.
ObjectUploadStore(store: ObjectStore, *, prefix: str = '.uploads/', max_entries: int = 1024) -> Noneadvance#
async advance(state: UploadState, *, expected: int) -> boolclaim#
async claim(state: UploadState, *, expected: int) -> boolcreate#
async create(state: UploadState) -> Nonedelete#
async delete(upload_id: str, *, claim: str = '') -> boolexpired#
async expired(before: float) -> _List[UploadState]read#
async read(upload_id: str) -> UploadState | Nonerelease#
async release(state: UploadState) -> boolResumableUploads#
The upload-creating resource and the upload resources beneath it.
Serves draft-ietf-httpbis-resumable-upload-12: POST to create, HEAD to learn the current offset, PATCH to append, DELETE to cancel. Mount it with router() and the routes are ordinary wreath routes, so permissions= and every AuthRequirement the router already understands apply to creation -- there is no second authorization vocabulary here, and there is nothing to bypass, which is the whole difference from a presigned URL.
Why this exists beside url(). A presigned URL sends the bytes straight to S3, which scales beautifully and routes around every control this framework owns: the policy engine never sees the write, the quota is never consulted, the declared media type is never checked, and the application learns the object exists only if the client bothers to say so. This path keeps the application in the way of the decisions -- authorize, check the quota, choose the key, sniff the type -- while the bytes stream through in restartable pieces. It costs the bandwidth the presigned path avoided. Both remain available and the guide says which to choose.
Admission happens at creation, before the first byte. A declared Upload-Length over limits.max_size, or refused by quota, is a 413 against a request that has sent nothing. Enforcing it only on append would mean accepting gigabytes in order to refuse them.
The client's Upload-Offset is a claim, never an authority. It is compared against the stored offset and a mismatch is a 409 carrying the real one, per §4.4.2. Advancing is conditional on the offset not having moved underneath, so two appends racing lose one rather than punching a hole in the object.
Completion enqueues a durable job. on_complete names a task registered on jobs; it is enqueued with the upload id as its idempotency key, so a retried completion does not run it twice. A callback awaited in the request would be lost on the next deploy, which is the failure every hand-rolled upload pipeline eventually finds. This is also the seam that keeps image processing out of this module: the pixels are the application's.
Args:
store— where finished objects and parts are written.uploads— where in-progress upload state lives. Defaults toMemoryUploadStore, which confines an upload to one worker; passObjectUploadStore(store)behind more than one. Its backing store- must provide atomic upload compare-and-swap.
prefix— key prefix for finished objects.staging— key prefix for parts and records; swept, never served.limits— what to advertise and enforce.min_append_sizeis raised to- the backend's own floor when the backend has one.
expire— seconds an idle upload survives. Also advertised asmax-age.max_append_bytes— clamp formax-append-size; pass the application'sRequestLimits.max_body_bytesso the advertised ceiling is one the- application will actually read.
jobs— aJobRunner, required whenon_completeis given.on_complete— name of a registered task, enqueued with(upload_id, key).quota— awaited at creation with(request, declared_length_or_None);- returning False refuses with 413. The seam a metering subsystem
- fills -- storage is the most commonly metered resource and this is
- the only moment before the bytes arrive.
key_for— chooses the final object key from the request and the new- upload id. Defaults to
prefixplus the id. Tenant-bound requests - store that key below a framework-owned tenant namespace.
sniff— refuse an append whose leading bytes contradict a declared- content type.
Raises:
ValueError—on_completewithoutjobs, or a non-positiveexpire.
ResumableUploads(store: ObjectStore, *, uploads: UploadStore | None = None, prefix: str = 'uploads/', staging: str = '.uploads/', limits: UploadLimits | None = None, expire: float = 86400.0, max_append_bytes: int | None = None, jobs: Any = None, on_complete: str | None = None, quota: Any = None, key_for: Any = None, sniff: bool = True) -> Noneappend#
async append(request: Request) -> ResponsePATCH: add bytes at the offset the client says it is at.
The type must be application/partial-upload (415 otherwise), the offset must match (409 with the real one otherwise), and the result must fit inside the declared length and the advertised maximum (413 otherwise).
cancel#
async cancel(request: Request) -> ResponseDELETE: give up on an upload and reclaim what it staged.
create#
async create(request: Request) -> ResponsePOST: open an upload, optionally with its first bytes.
Answers 201 with Location naming the new upload resource, per §4.3. A creation carrying Upload-Complete: ?1 and the whole representation finishes in one round trip and still goes through every check an append does.
limits (property)#
limits: UploadLimitsWhat this resource advertises, after backend and body-size clamping.
offset#
async offset(request: Request) -> ResponseHEAD/GET: how much of this upload the server holds.
204 with Upload-Offset, Upload-Complete, the declared length when there is one, the advertised limits, and Cache-Control: no-store.
router#
router(path: str = '/uploads', *, permissions: Iterable[str] | None = None, public: bool = False) -> AnyOrdinary wreath routes for the four protocol operations.
permissions is the router's own keyword and applies to every route, so authorization here is the same declaration as on any other route -- which is the property a presigned URL gives up.
sweep#
async sweep(*, now: float | None = None) -> intReclaim uploads idle for longer than expire. Returns how many.
Part of the feature, not a follow-up. An abandoned S3 multipart upload is billed for its parts until something aborts it or a lifecycle rule reaps it, and an abandoned parts-backend upload is objects nobody will ever read. Wire this to a schedule:
@runner.schedule("*/15 * * * *")
async def reap_uploads() -> None:
await uploads.sweep()A failure to abort one upload does not stop the sweep -- the next one may well succeed -- but it is counted in aborted_uploads, because a sweeper that silently gives up leaves a bill nobody sees.
UploadLimits#
What the server will accept, advertised on every upload response.
Emitted as the Upload-Limit Structured Fields Dictionary so a client can size its chunks before it starts rather than discovering a refusal four gigabytes in. Every field is optional and an absent field means "no limit of this kind"; the header is omitted entirely when nothing is limited.
min_append_size is the one that surprises people, and it is not arbitrary: an S3 multipart upload requires every part except the last to be at least 5 MiB, so a backend assembling parts natively cannot accept a smaller non-final append without staging the remainder somewhere and re-uploading it on the next call. Advertising the floor costs one header; hiding it costs a silent write amplification on exactly the slow network the protocol exists to survive. A final append -- one carrying Upload-Complete: ?1 -- is exempt, as the last part is.
max_append_size interacts with RequestLimits.max_body_bytes, which bounds every request body the application will read at all. Advertising a larger append than the application will accept is a refusal waiting to happen, so ResumableUploads clamps this to the smaller of the two when it knows both.
Args:
max_size— largest complete upload, in bytes.min_size— smallest complete upload, in bytes.max_append_size— largest single append body.min_append_size— smallest non-final append body.max_age— seconds an idle upload survives before the sweeper reclaims it.
UploadLimits(max_size: int | None = None, min_size: int | None = None, max_append_size: int | None = None, min_append_size: int | None = None, max_age: int | None = None) -> Noneto_header#
to_header() -> bytes | NoneThe Upload-Limit value, or None when nothing is limited.
UploadState#
One upload in progress: where it is, where it is going, and how big.
offset is the number of bytes the server has durably accepted, and it is the only authority on that. A client's Upload-Offset is a claim checked against this and never a source of truth -- a client that could move the offset by asserting it could overwrite a region it had already sent, or read across into another upload's parts.
length is the declared total, once declared. The draft forbids it changing, so a second declaration that disagrees is refused rather than accepted as a correction.
Args:
id— opaque upload identifier, and the last path segment of its resource.key— the object key the completed upload will be stored under.offset— bytes durably accepted so far.length— the declared total size, or None while it is unknown.complete— whether the final append has been accepted.finalized— whether backend assembly completed and only callback enqueue remains.content_type— the media type, sniffed or declared.created— creation time, as a POSIX timestamp.updated— last-accepted-append time, as a POSIX timestamp.backend— opaque per-backend assembly state (an S3 upload id, a part count).
UploadState(id: str, key: str, offset: int = 0, length: int | None = None, complete: bool = False, finalized: bool = False, content_type: str | None = None, created: float = 0.0, updated: float = 0.0, backend: dict[str, Any] = <factory>, principal: str = '', tenant: str = '', claim: str = '', claimed: float = 0.0) -> Nonecopy#
copy() -> UploadStateAn independent copy, including the backend's own dict.
MemoryUploadStore hands these out and takes them back, and a handler mutates the one it is holding before asking the store to accept it. Sharing the instance would make that mutation land in the store early, so the conditional advance would compare the new offset against itself and refuse every append -- a store that appears to work in isolation and rejects everything the moment it is used the way it is meant to be.
from_json (classmethod)#
from_json(raw: bytes) -> UploadStateto_json#
to_json() -> bytesSerialized JSON, which is what the name says and what this returns.
The one to_json in the tree. Nine others returned a dict -- a verb naming a wire format handing back a Python object -- and are now as_dict, so the two names mean two things: as_dict for a plain mapping a caller may still shape, to_json for bytes ready to store.
UploadStore#
Where the state of an in-progress upload lives, as opposed to its bytes.
Separate from ObjectStore because the two have opposite lifetimes: the bytes become an object that outlives the request by years, and the record saying how many of them have arrived is dead the moment the upload completes or expires.
advance is the reason this is a protocol rather than a dict. It is a conditional advance -- it moves the offset only if the offset is still what the caller read -- and it reports whether it won. Two appends racing on one upload is not hypothetical (a client retrying a chunk it thinks timed out is exactly this), and an unconditional write would let the loser rewind the offset and hand the next append a hole in the middle of the object.
UploadStore(*args, **kwargs)advance#
async advance(state: UploadState, *, expected: int) -> boolStore state if the stored offset is still expected.
Returns: whether the write won.
claim#
async claim(state: UploadState, *, expected: int) -> boolFence backend work if the stored offset is still expected.
create#
async create(state: UploadState) -> NoneRecord a new upload. Raises ObjectError if the id is taken.
delete#
async delete(upload_id: str, *, claim: str = '') -> boolForget an unclaimed upload, or the upload holding claim.
expired#
async expired(before: float) -> list[UploadState]Every upload whose last activity predates before.
read#
async read(upload_id: str) -> UploadState | NoneThe current state, or None when there is no such upload.
release#
async release(state: UploadState) -> boolRelease the claim carried by state without advancing it.
resumable#
resumable(store: ObjectStore, **options: Any) -> ResumableUploadsA ResumableUploads over store. Keywords are ResumableUploads'.
uploads = objects.resumable(
store,
uploads=objects.ObjectUploadStore(store),
limits=objects.UploadLimits(max_size=5 * 1024**3),
jobs=runner,
on_complete="process_photo",
)
app.include_router(uploads.router("/uploads", permissions=["uploads::write"]))sniff_content_type#
sniff_content_type(prefix: bytes) -> str | NoneThe media type prefix looks like, or None.
Deliberately a short table of unambiguous magic numbers rather than a general detector. The purpose is to refuse a lie -- a client declaring image/png over an HTML document that a browser will then render from your origin -- not to classify every format in existence, and a detector that guesses at ambiguous input would turn "unknown" into a wrong answer that something downstream trusts.
RIFF-family containers (WebP, WAV, AVI) are absent for that reason: they share one signature and separating them needs the sub-chunk at offset 8, which is a second decision this table does not model.
wreath.provenance#
Stored-artifact provenance: chained Ed25519 signatures and quorum checks.
HTTP Message Signatures authenticate an HTTP message. This module owns the different problem of proving that bytes stored today are the bytes a set of people approved, even when they are checked much later and outside HTTP.
Hashing, base64url and Ed25519 run in Wreath's native/data kernels. Python owns only the immutable envelope and its canonical serialization.
ArtifactChanged#
The supplied artifact no longer has the digest the signers approved.
Attestation#
One signatory's Ed25519 signature over the artifact and prior chain.
Attestation(key_id: str, signature: bytes) -> NoneInvalidProvenance#
A provenance envelope, signature, key, or quorum is invalid.
Provenance#
An immutable approval chain bound to one exact stored artifact.
Provenance(digest: bytes, quorum: int = 1, name: str = '', media_type: str = 'application/octet-stream', attestations: tuple[Attestation, ...] = ()) -> Nonecountersign#
countersign(artifact: bytes | bytearray | memoryview, key: ProvenanceKey) -> SelfAppend key after verifying the artifact still matches.
Each new signature covers the complete preceding chain, so it is a counter-signature as well as an independent approval of the bytes.
dump#
dump() -> bytesSerialize the sidecar deterministically as UTF-8 JSON.
for_artifact (classmethod)#
for_artifact(artifact: bytes | bytearray | memoryview, *, quorum: int = 1, name: str = '', media_type: str = 'application/octet-stream') -> SelfCreate an unsigned envelope for these exact bytes.
load (classmethod)#
load(data: bytes | bytearray | memoryview) -> SelfParse a serialized sidecar, refusing unknown or malformed shapes.
matches#
matches(artifact: bytes | bytearray | memoryview) -> boolWhether the artifact is byte-for-byte the version this chain names.
verify#
verify(artifact: bytes | bytearray | memoryview, keys: Mapping[str, ProvenanceKey | bytes], *, quorum: int | None = None) -> VerifiedProvenanceVerify the bytes, every counter-signature, and the required quorum.
ProvenanceKey#
One Ed25519 identity, optionally able to sign.
sign is a callback so production keys can stay in an HSM or KMS. from_seed is the dependency-free local/test spelling and delegates all secret-scalar work to Wreath's native RFC 8032 kernel.
ProvenanceKey(key_id: str, public: bytes, sign: Callable[[bytes], bytes] | None = None) -> Nonefrom_seed (classmethod)#
from_seed(key_id: str, seed: bytes) -> SelfBuild a local signing identity from one 32-byte Ed25519 seed.
verifier (classmethod)#
verifier(key_id: str, public: bytes) -> SelfBuild a verification-only identity from its public key.
VerifiedProvenance#
The verified signatory set and the quorum it satisfied.
VerifiedProvenance(digest: str, signatories: tuple[str, ...], quorum: int) -> Nonewreath.privacy#
Erasure, retention and subject access, over the graph the ORM already has.
A right-to-erasure request has a one-month clock under GDPR Article 17 and a 45-day one under CCPA/CPRA, and the European Data Protection Board's February 2026 coordinated enforcement report found late and incomplete responses still widespread. Incomplete is the interesting half. Almost nobody refuses an erasure on purpose; they miss a table.
Missing a table is a graph problem, and wreath is already holding the graph. wreath.migrations derives every foreign key with its referential action and its deferrability, because diffing a schema needs exactly that. A vendor selling "graph discovery and cascade-safe deletes" is selling a hand-maintained copy of the thing your models already declare, and the copy goes stale on the next migration.
privacy = Privacy(registry) # a compiled ORM registry
privacy.subject(User, key="id", delete=True)
privacy.classify(Photo, subject="owner_id", personal={"exif_gps": Erase.NULL})
privacy.classify(Comment, personal={"body": Erase.REDACT})
privacy.classify(AuditRecord, exempt=Declared(
"retained under Art. 17(3)(b): the record of an erasure is the evidence it "
"was performed, and erasing it would defeat the obligation to demonstrate "
"compliance"))
privacy.retain(SupportTicket, after=days(90), on="closed_at")
print(privacy.render(privacy.plan("4711"))) # read it
await privacy.erase(database, "4711", digest="6f1c…") # then run itPlan first, and the plan is the product. wreath privacy plan prints the traversal; wreath privacy erase runs a plan that was printed, and refuses when the digest has moved. wreath.infra set this precedent for the same reason -- an inference that is subtly wrong is worse than none, because it looks authoritative -- and an erasure that is subtly wrong is worse still, because it cannot be undone and the subject has been told it is done.
Five findings, because five things go wrong quietly. A classified table no foreign key connects to the subject; a SET NULL edge that orphans a child row where nothing can ever find it again; a NO ACTION edge from a row that survives the erasure to one it deletes, which the database refuses so the erasure stops half-way; a foreign-key cycle that admits no ordering; and rows retained under an exemption. All but the last block the erasure outright.
And the erasure records that it happened. erase writes one row per completed erasure -- subject, timestamp, plan digest, counts, and nothing else -- in a transaction that first establishes from the pass ledger that every walk finished. That row is the evidence the erasure was performed and the only thing that lets a restore from backup know to replay it. See wreath._privacy.record.
Two limits, stated here rather than discovered later#
Backups are out of scope. This walks live tables. A restore from a backup taken before an erasure reinstates the data, and no amount of application code changes that. What wreath does instead is record the erasure, so a restore can replay it -- which is small, checkable, and more than the alternative of implying a guarantee nobody can keep.
Anonymisation is not erasure unless it is irreversible. A hash of an email address is not irreversible: it is a stable identifier for the same person, which is the definition of pseudonymous data. So there is no hash disposition here. Erase.NULL and Erase.REDACT destroy the value; Pseudonymise(Declared("why")) keeps it joinable and says so, and every plan containing one prints that the subject is still identifiable in those columns.
And one inherited limit worth knowing before you need it. The generated passes reach their tables the way every wreath.passes.ChunkedPass over a model does: a model whose schema= is a plain string renders unqualified and resolves through search_path, because only a logical SchemaRef is qualified into the statement. So an erasure over models in a schema that is not on the connection's search_path will not find them. The nested subqueries this module builds are rendered by wreath.passes' own resolver rather than by qualifying the name here, so the subquery and the walk can never disagree about which table they mean -- but they agree on being unqualified too. Put the schema on the search_path, or use a logical schema.
Classification#
One model's declared personal data, and how it reaches its subject.
Classification(model: type, subject: str | None, personal: dict[str, Any], delete: bool, exempt: str | None) -> NoneClassified#
The privacy facet: what erasure does to this model's rows as a whole.
Built by classified. Row-level rather than column-level, which is exactly the split wreath.orm.Facet exists for: whether a table's rows are deleted or retained is a fact about the table, and Annotated has nowhere to put it.
Classified(columns: tuple[str, ...] = (), *, delete: bool = False, exempt: Any = None) -> NoneColumnAction#
One classified column and what the erasure does to it.
ColumnAction(column: str, erase: str, pseudonym_reason: str | None = None, note: str = '') -> Noneirreversible (property)#
irreversible: boolWhether this action destroys the value.
The property the whole module turns on, so it is computed in one place rather than re-derived by each renderer and each executor.
CycleFinding#
A foreign-key cycle among the tables this erasure touches.
Named rather than resolved. A cycle means no ordering of plain deletes exists; either the constraints are deferrable and the erasure runs in one transaction, or a human breaks the cycle by nulling an edge first. Guessing which is how an erasure half-runs.
CycleFinding(tables: tuple[str, ...], deferrable: bool, detail: str) -> NoneDisposal#
What happens to one whole table's rows for this subject.
Edge#
One foreign key, as the ORM declared it.
on_delete is PostgreSQL's own confdeltype code -- a no action, r restrict, c cascade, n set null, d set default -- carried verbatim from wreath.orm.schema.ColumnRef rather than translated, so it compares byte-for-byte with the catalog and with a migration signature.
Edge(from_table: str, from_column: str, to_table: str, to_column: str, on_delete: str, deferrable: bool = False) -> Noneexplain#
explain() -> strErase#
What happens to one classified column when its subject is erased.
Three values, and the absent fourth is the point. NULL and REDACT are irreversible: after either, the original value cannot be recovered from the row. RETAIN keeps the value and says so out loud, so a plan can show a reader exactly which personal data survives and force the question of why.
There is deliberately no hash disposition here. See Pseudonymise.
ErasureBlocked#
The plan would leave personal data behind, so it will not run.
ErasureIncomplete#
A pass this erasure declared has not finished, so nothing is recorded.
Raised rather than returned, and raised before the record is written. An erasure that stopped part-way and recorded itself anyway would be a receipt for something that did not happen, and the subject has already been told the opposite.
ErasurePlan#
Everything one subject's erasure would do, derived from declarations.
Inert. Building it opens no connection and deletes nothing; digest is what lets wreath privacy erase prove it is executing the plan that was printed rather than a different one computed later.
ErasurePlan(subject_model: str, subject_column: str, subject_id: str, tables: tuple[TableAction, ...] = (), retained: tuple[Retained, ...] = (), unreachable: tuple[Unreachable, ...] = (), orphan_risks: tuple[OrphanRisk, ...] = (), cycles: tuple[CycleFinding, ...] = (), surviving_references: tuple[SurvivingReference, ...] = (), notes: tuple[str, ...] = ()) -> Noneblocked (property)#
blocked: boolWhether a person must resolve something before this can run.
Three ways the plan is incomplete, and an incomplete erasure that runs anyway is worse than one that refuses: the subject is told they were erased. Unreachable classified data would leave rows behind; an unresolvable cycle admits no ordering; and a surviving reference means the database will refuse the delete, which leaves the erasure half-run rather than not run.
digest (property)#
digest: strExportPlan#
The read-mode traversal behind a subject-access request.
The same graph walk as an erasure with none of the writes, which is why it is derived from the same plan rather than from a second traversal that could disagree with it.
ExportPlan(subject_model: str, subject_column: str, subject_id: str, tables: tuple[TableAction, ...] = (), withheld: tuple[Retained, ...] = (), unreachable: tuple[Unreachable, ...] = ()) -> NoneOrphanRisk#
A SET NULL/SET DEFAULT edge that would strand personal data.
The subtlest defect in the category. Deleting the parent leaves the child row alive with its foreign key nulled -- so the child still holds the subject's data and, having lost the only column that pointed at the subject, can never be found again. The plan therefore orders the child before the parent and says why.
OrphanRisk(edge: Edge, detail: str) -> NonePersonal#
This column holds the subject's personal data, and how erasure treats it.
erase takes exactly what classify(personal={...}) takes -- an Erase value or a Pseudonymise -- and is checked by the same code, so a disposition that claims to erase while leaving the subject identifiable is refused here in the same words.
The default is Erase.REDACT rather than Erase.NULL because redaction works on a NOT NULL column and nulling does not; a default that was refused for half of all columns would be a default nobody could use.
Personal(erase: Any = <Erase.REDACT: redact>) -> NonePlanMoved#
The plan changed since it was printed; the review no longer applies.
PreparedErasure#
One reviewed plan, resolved into the passes that carry it out.
PreparedErasure(plan: ErasurePlan, steps: tuple[tuple[TableAction, Any], ...], record: ErasureRecord = <factory>, schema: str = 'wreath', workload: str = 'write') -> Nonepasses (property)#
passes: tuple[Any, ...]Privacy#
One application's data classification, and what can be done with it.
Args:
registry— the compiledwreath.orm.Registrywhose foreign-key graph an- erasure walks. Held rather than passed per call, because a plan
- derived from a different registry than the declarations were made
- against is a plan for a different application.
erasure_record_retain— how long an erasure record is kept, in seconds.Set it to your backup horizon. There is deliberately no default—- the honest one is "as long as your oldest backup" and nothing here
- can know that, so unset means kept-and-reported rather than a
- silent forever. See
wreath._privacy.retention.
Privacy(registry: Any = None, *, erasure_record_retain: float | None = None) -> Noneaccess#
access(subject_id: str, *, registry: Any = None) -> ExportPlanThe read-mode traversal behind a subject-access request.
classify#
classify(model: type, *, subject: str | None = None, personal: dict[str, Any] | None = None, delete: bool = False, exempt: Any = None) -> NoneDeclare a model's personal columns and how erasure treats them.
See wreath._privacy.registry.PrivacyRegistry.classify for the refusals; the important one is that a disposition claiming to erase while leaving the subject identifiable is rejected by name.
declare#
declare(model: type) -> boolRead one model's Subject/Personal annotations and its facet.
Privacy(registry) already does this for every model the registry compiled. This is for a model that was not in it -- one imported after construction, or one a test declares in a function body.
Returns:
- Whether the model declared anything.
erase#
async erase(database: Any, subject_id: str, *, digest: str | None = None, registry: Any = None, **kwargs: Any) -> PreparedErasureRun a reviewed plan, children before parents.
Each table is a wreath.passes.ChunkedPass: paced, resumable, with the cursor advanced inside the chunk transaction. A crash resumes rather than restarting, which matters at the size where an erasure takes long enough to be interrupted.
It records itself. When every pass has finished, one transaction re-reads the pass ledger, establishes that they all reached done, and writes the erasure record -- the evidence the erasure happened, and the only thing that lets a restore from backup know to replay it. A record is never written for an erasure that did not finish; see wreath._privacy.record for what the record holds and why it is retained despite naming the subject.
The statutory clock started when the subject asked, not when this ran. A caller driving this from a durable job owes the request an age alarm -- a silently retrying erasure job is a compliance incident, and it is the one failure mode this module cannot see from in here.
Raises:
ErasureIncomplete— a pass stopped before the end of its walk. The- walks resume, so the fix is to run this again.
erasure_records#
erasure_records(database: Any, *, schema: str = 'wreath') -> AnyThe erasure record table as a wreath.log.PostgresLog.
The read side, and the only place a Database belongs: replaying outstanding erasures after a restore reads it, and enforcing erasure_record_retain calls purge() on it -- from a durable job, as wreath.log says, because nothing calls that for you.
graph#
graph(*, registry: Any = None) -> AnyThe foreign-key graph this application declares.
plan#
plan(subject_id: str, *, registry: Any = None) -> ErasurePlanWhat erasing one subject would do. Opens nothing, writes nothing.
prepare#
prepare(subject_id: str, *, digest: str | None = None, registry: Any = None, **kwargs: Any) -> PreparedErasureResolve a reviewed plan into passes, refusing anything unreviewed.
Raises:
PlanMoved— the plan changed sincedigestwas printed.ErasureBlocked— the plan would leave personal data behind.
render#
render(plan: ErasurePlan | ExportPlan, *, format: str = 'text') -> strA plan as text to read, or JSON to diff.
retain#
retain(model: type, *, after: float, on: str, reason: str = '') -> NoneDeclare how long this model's rows live, measured from on.
retention#
retention() -> tuple[str, ...]Every declared retention window, and every table that lacks one.
retention_passes#
retention_passes(**kwargs: Any) -> tuple[tuple[Retention, Any], ...]One ChunkedPass per declared window, for jobs.drive to schedule.
subject#
subject(model: type, *, key: str = 'id', delete: bool = False) -> NoneName the model that is a data subject, and its identity column.
unmodelled_edges#
async unmodelled_edges(database: Any, *, schema: str = 'public', registry: Any = None, workload: str = 'write') -> list[tuple[str, str, str, str]]Foreign keys the live catalog has that the ORM does not model.
The one method here that opens a connection, and it is a check rather than part of planning: every edge it returns is a path an erasure would not walk. Run it in CI against a real schema and treat a non-empty result as a finding.
PrivacyDeclarationError#
A classification that cannot be executed, refused where it was written.
Pseudonymise#
Replace a value with a stable non-identifying token, having said why.
This is not erasure and this type exists so that nothing can pretend it is. A pseudonymised column still distinguishes one subject from another, so the data remains personal data; every plan that contains one says so, and the reason is printed beside it.
The reason is wreath.passes.Declared rather than a second string wrapper, because it is the same idea -- a decision a human has to have made in writing, refused when empty -- and two spellings of that would drift.
Pseudonymise(reason: Any) -> Nonetext (property)#
text: strReach#
How a table reaches the subject, as the chain of foreign keys walked.
An empty path means the table carries the subject column itself. A plan prints the path because "why is this table in my erasure?" is the first question a reviewer asks, and a table name alone cannot answer it.
Reach(path: tuple[Edge, ...] = ()) -> Nonedepth (property)#
depth: intexplain#
explain() -> strRetained#
A table reached by the traversal that is deliberately not erased.
The audit log is the canonical member: an erasure that deletes the record of itself is a compliance failure in the other direction. Every one carries the written exemption that put it here.
Retained(model: str, schema: str, table: str, reason: str, reach: Reach = <factory>) -> NoneRetention#
A model's declared retention window, enforced by a scheduled pass.
Retention(model: type, after: float, on: str, reason: str) -> NoneSubject#
This column says whose data the row is.
On an ordinary model it is the foreign key to the subject, and it is the annotation form of classify(model, subject="owner_id").
On the subject model itself -- the person -- write Subject(root=True), which is privacy.subject(User, key="id"). The distinction is declared rather than inferred: "this column references the subject" and "this table is the subject" are different claims, and a planner that guessed between them would pick the wrong root on any schema where a user references another user.
Args:
root— whether this model is the data subject rather than a table about- one. Exactly one model per registry may say so.
delete— whether erasing the subject deletes this model's rows outright,- rather than emptying their classified columns.
Subject(root: bool = False, delete: bool = False) -> NoneSurvivingReference#
A row that outlives the parent this erasure deletes, and still points at it.
The finding a plan review cannot make by eye, and the one that turns a successful-looking erasure into a half-run one. NO ACTION and RESTRICT are handled by ordering only when the child rows are themselves deleted; a child that is anonymised, retained under an exemption, or simply not classified keeps its foreign key, and the parent's DELETE is then refused by the database at three in the morning with the subject already told they were erased.
Reported against the whole graph rather than against the plan's tables, because the row that refuses the delete is very often one nothing declared -- an unclassified join table is exactly as good at holding a foreign key as a classified one.
SurvivingReference(edge: Edge, detail: str) -> NoneTableAction#
One table's part of an erasure, and how it was reached.
TableAction(model: str, schema: str, table: str, disposal: str, reach: Reach, columns: tuple[ColumnAction, ...] = (), match_column: str = '', reason: str = '', order: int = 0) -> NoneUnreachable#
A table holding classified personal data with no path to the subject.
The finding this module exists to produce. Silent omission -- an erasure that quietly misses a table -- is exactly what the EDPB's February 2026 coordinated enforcement report found controllers doing, and it is invisible unless something enumerates the classified tables and subtracts the reachable ones.
Unreachable(model: str, schema: str, table: str, columns: tuple[str, ...], reason: str) -> Noneas_dict#
as_dict(plan: ErasurePlan | ExportPlan) -> dict[str, Any]The plan as plain JSON-compatible data, for --format json.
dataclasses.asdict drops properties, so blocked and digest are re-added here rather than stored: computing them twice from one definition is what stops the two renderings disagreeing.
classified#
classified(*, delete: bool = False, exempt: Any = None) -> ClassifiedDeclare model-level erasure behaviour beside the columns it is about.
Args:
delete— erasing the subject removes these rows outright.exempt— a written reason these rows survive an erasure whole -- a legal- hold, a financial record, an audit trail. Refused when empty by the
- same check the registration call uses.
render_export_text#
render_export_text(plan: ExportPlan) -> strThe read-mode traversal, for a subject-access request.
render_json#
render_json(plan: ErasurePlan | ExportPlan, *, indent: int = 2) -> strThe plan as JSON, one trailing newline.
render_text#
render_text(plan: ErasurePlan) -> strThe plan as text, in the order the erasure would run.
schema_sql#
schema_sql(schema: str = 'wreath', *, retain: float | None = None) -> strDDL for the erasure record in schema. Apply it as a migration.
Offered, never applied -- schema changes belong in the migration history with the rest of the schema, which is the rule wreath.store and wreath.passes already follow.
retain only decides whether the age index is declared, because that index exists to keep the retention purge from being a sequential scan of a table that only ever grows. Emitting it for a KEEP_FOREVER record would be an index nothing ever reads.
wreath.audit_log#
Who changed what, recorded by the thing that does the changing.
Every hand-rolled audit trail develops holes, and always the same way: it depends on somebody remembering to call record(). The call is added to the handler that changes a row and not to the background job that changes the same row at 03:00, and the gap is invisible until an auditor asks a question the log cannot answer.
Wreath does not have to work that way. The ORM knows what it wrote and which fields changed, and it knows it inside the transaction that wrote them. So an audit record here is complete by construction: it is not a thing the application remembers to do, it is a thing the write does.
Declare which models are audited on the models themselves:
class Photo(Model, table="photos"):
caption: Mapped[str] = column(Text)
exif_gps: Mapped[str | None] = column(Text, null=True)
_audit = audited(redact={"exif_gps"})and bind an actor to whatever is doing the writing:
async with actor(f"user:{request.user.id}"):
session.add(photo)
await session.commit()Three properties, each of which is the whole point rather than a refinement:
- Atomic with the write. The record is appended in the same transaction, so a crash between the row changing and the record landing cannot happen. A trail assembled after the fact is a trail with a window in it.
- Attribution fails loudly. A write to an audited model with no actor bound raises. A background job is an actor, a migration is an actor, a test is an actor -- what is not acceptable is a record that is complete except for the one field that makes it evidence.
- Append-only in the database.
wreath.audit_logemits aREVOKEand a trigger, so the application's own role cannot rewrite history. An audit table the application canUPDATEis not an audit log to anybody who asks for one.
Retention is deliberately KEEP_FOREVER. An audit trail's lifetime is a compliance decision rather than a disk-space one, and it is frequently the record that must survive an erasure the underlying row does not -- PostgresLog.drop_stream is the deliberate, subject-naming exception.
AuditTrail#
Appends a Change per audited write, inside the writing transaction.
Installed on a session by wreath.orm.Session(audit=...). The session calls record from inside its flush, which is inside its transaction, so the record and the row it describes commit or roll back together.
AuditTrail(log: PostgresLog) -> Noneattribute#
attribute() -> strThe actor for the write about to happen, or raise.
Split out from record so the session can refuse before it issues the statement rather than after: a write that has already happened cannot be un-happened by a failed append, and an audited write with no attribution must not reach the database at all.
forget#
async forget(table: str, key: str | tuple[Any, ...]) -> strDrop one subject's records outright.
The erasure path, and deliberately the only one: retention is KEEP_FOREVER, so nothing ages an audit record out by accident. Removing evidence is always an explicit act naming exactly whose evidence it is.
The SET LOCAL is what the append-only trigger looks for. It is transaction-scoped, so the permission to delete exists for exactly this statement and cannot travel with the pooled connection into whatever runs next.
history#
async history(table: str, key: str | tuple[Any, ...], *, after: Cursor | None = None) -> AnyEverything that ever happened to one row, oldest first.
log (property)#
log: PostgresLogThe underlying log, for reads and for retention.
record#
async record(change: Change, *, connection: Any = None) -> CursorAppend one change, on the connection that made it.
connection is not optional in practice: without it the append takes its own pooled connection and its own transaction, and the record then outlives a write that rolled back. It is keyword-with-a-default only because a caller replaying records into a fresh trail has no transaction to join. The session always passes one.
record_many#
async record_many(changes: Sequence[Change], *, connection: Any = None) -> intAppend a flush's worth of changes together, returning how many landed.
What the session calls. A record per statement would put an INSERT inside the writing transaction for every audited instance in the flush, which is a hundred round trips for a hundred rows -- the audit trail paying its own tax on top of the write it describes. append_many collapses that to one statement per power-of-two rung, on the same connection, so the property that makes the record evidence is unchanged: the batch is in the caller's transaction and commits if and only if it does.
No cursors come back, because a batched INSERT makes no promise about the order of RETURNING and nothing here wanted one. record is still the single-record spelling, for a caller replaying into a fresh trail.
No empty-batch guard here: append_many already issues no statement for an empty sequence and answers zero, so a second spelling of that check would be one more place for the two to drift apart.
recorded (property)#
recorded: intRecords appended. Never resets.
refused (property)#
refused: intWrites refused for want of an actor. Never resets.
AuditLog#
A log declaration whose schema claim includes its append-only guard.
AuditLog(table: str, retain: float | None, columns: tuple[Column, ...] = (), stream: str = 'stream', dedup: bool = False, flush: Flush = <factory>, schema: str = 'wreath', prefix: str = 'wreath_log') -> Noneschema_claim#
schema_claim(name: str) -> AnyThis declaration's claim on the wreath schema, under name.
name is the caller's, because one Log shape backs several subsystems and each needs its own version marker and its own advisory lock -- sharing one would make an upgrade to the audit trail block a worker that only streams.
Named apart from the zero-argument component() protocol for the reason wreath.store.Keyed.schema_claim gives: a declaration needs the argument, and the walk cannot pass one.
schema_sql#
schema_sql() -> strDDL for the backing table, semicolon-joined.
A derivation of statements(); wreath schema sql is the supported spelling for a caller applying it by hand.
qualified_table (property, inherited from Log)#
qualified_table: strThe table as it reaches SQL: schema-qualified unless the schema is "".
statements (inherited from Log)#
statements() -> tuple[str, ...]DDL for the backing table, one statement per element.
xid8 rather than xid: the 32-bit type wraps, and a cursor built from a wrapping counter compares wrongly exactly once every four billion transactions, which is both rare enough to survive review and certain enough to happen.
Audited#
The audit facet: this model is audited, and these columns are redacted.
Built by audited. The redacted column names are the facet's columns, so the ORM metaclass validates them when the class is created -- a redaction naming a column that was renamed two migrations ago is a DeclarationError at import rather than a redaction that quietly stopped covering anything.
redact (property)#
redact: frozenset[str]Columns whose values are replaced by REDACTED.
Change#
One recorded write: what changed, on which row, by whom.
Change(table: str, key: str, operation: str, actor: str, fields: Mapping[str, Any], key_parts: tuple[Any, ...] | None = None) -> Nonesubject (property)#
subject: strThe log stream this record belongs to: one per audited row.
A stream per row rather than per table is what makes "everything that ever happened to this row" a range scan, and it is also the unit an erasure request names.
actor#
actor(name: str) -> AnyBind name as the actor for writes inside this block.
with actor(f"user:{identity.sub}"):
await session.commit()Nests: an inner block wins for its own duration and the outer one resumes afterwards, which is what makes "this request, except this bit, which is the system" expressible.
append_only_statements#
append_only_statements(table: str, *, schema: str = 'wreath') -> tuple[str, ...]DDL that stops the application rewriting its own history.
Two mechanisms, because either alone is escapable: the REVOKE stops the ordinary role, and the trigger stops anyone whose role was granted more -- including a superuser running a hand-written UPDATE at 2am, which is the case the trigger exists for.
Emitted through wreath.migrations rather than executed here, because it is schema and schema belongs in the migration history with the rest of it.
audited#
audited(*, redact: Iterable[str] = ()) -> AuditedDeclare that a model's writes are recorded, redacting redact.
class Photo(Model, table="photos"):
_audit = audited(redact={"exif_gps"})current_actor#
current_actor() -> str | NoneThe actor bound to this task, or None.
declaration#
declaration(table: str = 'audit_records', *, schema: str = 'wreath') -> AuditLogThe wreath.log declaration an AuditTrail is built on.
Separate from the trail so a deployment can emit the DDL (wreath schema sql) without constructing a database handle, the way every other wreath component does.
wreath.temporal#
Instants, durations, and zones — one meaning for time at every boundary.
Time is the type that has to mean the same thing in six places at once: the column it is stored in, the parameter it arrives as, the JSON it leaves as, the OpenAPI schema, the generated TypeScript, and the GraphQL scalar. Most applications re-decide it in each of them, and the drift is invisible until a client reads a naive timestamp as if it were UTC.
The usual answer is to reach for arrow or pendulum in every module. Wreath's core carries no mandatory dependencies, so that answer is unavailable — which turns out to be the better outcome, because it forces the decision into one place that every surface already goes through:
from wreath.temporal import Instant, now, relative
started = Instant.parse(request.query["since"]) # aware, or it raises
when = relative(started, locale=request.locale) # "3 hours ago"An Instant is always zone-aware. It subclasses datetime.datetime, so it stores, compares, and does arithmetic exactly like one — but it cannot be constructed without an offset. Assuming UTC for a naive value is the single bug this module exists to prevent, and it is refused loudly rather than guessed at. Where a naive value genuinely needs a zone, say so: `Instant.of(value, assume="Australia/Sydney")`.
The relative formatter takes a locale. "3 hours ago" is the string every codebase ends up hand-rolling at the edge, and it is also the one that is locale-dependent. Keeping it here — reached through request.locale — means translating it later is a parameter to one function rather than a hunt through every template. English ships today; _LOCALES is where the next language goes, and the docstring there marks exactly where CLDR plural rules slot in.
The public types remain stdlib-compatible. Dense local-wall-clock spine walks are native data operations, including a cardinality-only form that avoids materialising Instant objects when only the size crosses the boundary.
Bucket#
One interval width, in the three vocabularies that have to agree.
trunc is the PostgreSQL date_trunc unit that assigns a row to a bucket; step is the generate_series interval that walks from one bucket to the next; and floor and end_of are the Python answers to the same two questions, for code that has a moment in hand and no connection.
Both SQL fragments are drawn from BUCKETS rather than from a caller, so neither is ever user input -- bucket is the only way to reach one by name, and it refuses anything not in the table.
Bucket(name: str, trunc: str, step: str, months: int = 0, delta: datetime.timedelta | None = None) -> Noneend_of#
end_of(value: datetime.datetime, in_zone: str | datetime.tzinfo) -> InstantThe instant the next bucket starts -- this one's exclusive end.
Ranges here are half-open throughout, so a bucket runs from floor up to but not including this. Sealing (when a bucket becomes final) is the other caller: a bucket cannot settle before the moment it stops accepting rows, and that moment is this one.
The step is added on the local wall clock and then converted back, so a day spanning a DST change is 23 or 25 hours rather than 24, and a month is a calendar month rather than an approximation. The conversion back resolves an ambiguous or skipped boundary the same way floor does -- see from_wall_clock -- so a bucket's end and the next bucket's start are one instant.
floor#
floor(value: datetime.datetime, in_zone: str | datetime.tzinfo) -> InstantThe instant this bucket starts, for the wall clock in in_zone.
The mirror of date_trunc(unit, value AT TIME ZONE zone): read the moment on the zone's wall clock, truncate there, and convert back. Doing it in that order is what makes a "day" the reader's calendar day rather than a fixed 24 hours -- see end_of for why that distinction has teeth.
On the two days a year a zone changes offset, a local wall clock can be ambiguous or absent. Both cases resolve to the later of the candidate instants, via from_wall_clock, because that is what AT TIME ZONE does -- measured against a live PostgreSQL, across nine zones and both transition directions, rather than reasoned from the documentation. An earlier revision resolved an ambiguous local time to the first of its two instants, which disagreed with date_trunc for every value in the second pass of a repeated hour.
Comparing the result across zones needs care, and this is CPython's rule rather than this module's: by PEP 495 a datetime inside an ambiguous hour compares unequal to the same instant expressed in another zone, so that comparison stays transitive when one local time names two instants. Convert with astimezone before comparing, or compare two values in the same zone. The trap is that the naive form is correct on every day but one.
floor(v) <= v < end_of(v) does not hold for every value, at minute granularity inside a repeated hour, and that is a property of the calendar rather than a defect here: one local time names two instants, a bucket start can only be one of them, and values at the other one fall outside their own bucket. PostgreSQL does the same thing with the same inputs — verified, not assumed — so this is the shared answer rather than a divergence. At hour and coarser the window is wide enough to contain both passes and the invariant holds.
Every caller in the tree passes an already-truncated boundary except the sealing watermark, and there the boundary is now the one generate_series will emit, which is the property sealing actually needs.
Day (value)#
Day: Bucket = <Bucket day>Duration#
A span of time, declared once and read wherever a length is configured.
This module already refuses a naive datetime, because a moment without an offset has no single meaning. A length had no type at all: every span in the framework was a bare float of seconds — a job lease, a quota period, a notification digest window, a store window, a rate-limit span — so five subsystems each documented "seconds" and a caller learned "how long" five times. Duration is the one spelling, and of is what every one of those parameters now accepts.
A datetime.timedelta subclass, so it compares, sorts, and adds to an Instant exactly like one, and total_seconds() is inherited — which is what a call site that genuinely needs a float asks for.
Arithmetic returns a plain timedelta, not a Duration. CPython preserves the subclass for datetime but not for timedelta (timedelta.__add__ constructs the base type directly). That is not worked around: Duration is a type for the declaration boundary, and a value that has been through arithmetic has left it. Duration.of takes it back when a boundary needs one.
Note .seconds is timedelta's own field — the seconds component, 0 to 86399 — and deliberately not shadowed. total_seconds() is the whole span.
iso#
iso() -> strThe ISO-8601 form — PT3H. The spelling of reads back.
of (classmethod)#
of(value: Any) -> DurationAdopt a length however it was written.
Accepts a Duration, a timedelta, a number of seconds, or an ISO-8601 duration string such as "PT3H". A bool is refused: True is an int in Python and one second is never what it meant.
Numbers are seconds because that is what every parameter this replaces already meant, so an existing call site keeps working unchanged.
Hour (value)#
Hour: Bucket = <Bucket hour>Instant#
A moment in time that always knows its offset.
A datetime.datetime subclass, so it is stored by TimestampTz, compared, and added to a timedelta exactly like one — arithmetic and astimezone return an Instant because CPython preserves the subclass. What it will not do is exist without a tzinfo.
iso#
iso() -> strThe ISO-8601 form, with an explicit offset.
UTC renders as +00:00 rather than Z so that two services never produce different bytes for the same moment.
of (classmethod)#
of(value: datetime.datetime, *, assume: str | datetime.tzinfo | None = None) -> InstantAdopt an existing datetime.
A naive value needs assume to say which zone it was written in. Without it this raises rather than picking one — the caller knows and this module does not.
parse (classmethod)#
parse(text: str) -> InstantAn ISO-8601 timestamp that carries an offset.
Accepts everything datetime.fromisoformat does, which since 3.11 includes the trailing Z every JSON client emits. A string without an offset raises: there is no correct default, and UTC is merely the most popular wrong one.
relative#
relative(*, now: datetime.datetime | None = None, locale: str = 'en') -> strThis moment as a person would say it — "3 hours ago".
to#
to(in_zone: str | datetime.tzinfo) -> InstantThe same moment, read on another zone's wall clock.
Minute (value)#
Minute: Bucket = <Bucket minute>Month (value)#
Month: Bucket = <Bucket month>Quarter (value)#
Quarter: Bucket = <Bucket quarter>Recurrence#
A repeating schedule that knows the zone its wall clock is read on.
wreath.series goes out of its way to bucket on the local wall clock, because stepping naive timestamps advances a calendar day while stepping timestamptz advances exactly 24 hours — the DST bug, twice a year, in one bucket. Scheduling had the same problem and no answer: a cron expression is read in UTC, so "rebalance at 03:00 depot-local" is an hour wrong for half the year, in whichever half the reader does not test in.
A Recurrence carries its zone, so that is one declaration rather than a conversion the caller has to remember to redo when the offset changes:
Recurrence.cron("0 3 * * *", tz="Australia/Sydney")
Recurrence.rrule("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=3", tz="Europe/London")Both spellings compile to the same five sets, which is why the calendar form is here rather than in a translator beside it: the calendar UIs people actually mount emit it, and every application that has one grows a lossy translator to cron that is wrong across a DST boundary. The supported subset is named in calendar, and everything outside it is refused by name rather than approximated.
The two DST days, stated rather than discovered. A local time that does not exist (spring forward) never occurs as a wall-clock reading, so it does not match and does not fire — the same answer cron gives. A local time that occurs twice (fall back) matches on both passes, but bucket_key names the local minute, so a scheduler keyed on it fires once. Both are pinned by tests rather than left to the reader.
Recurrence(minute: frozenset[int], hour: frozenset[int], day: frozenset[int], month: frozenset[int], weekday: frozenset[int], tz: datetime.tzinfo, text: str) -> Nonebucket_key#
bucket_key(moment: datetime.datetime) -> strThe local minute moment falls in, as a stable string.
This is what a scheduler deduplicates on, and it is local rather than UTC on purpose. The hour that repeats on a fall-back day is two distinct instants but one wall-clock minute, so a schedule declared at that time fires once rather than twice — which is what "03:30 every day" means to the person who wrote it.
calendar (classmethod)#
calendar(text: str, *, tz: str | datetime.tzinfo = datetime.timezone.utc) -> RecurrenceThe calendar spelling — FREQ=WEEKLY;BYDAY=MO,TU — on tz's wall clock.
This is the syntax a calendar client emits and RFC 5545 defines, named once here so a caller knows what they can paste in. A leading RRULE: is accepted for the same reason.
Supported: FREQ of MINUTELY, HOURLY, DAILY, WEEKLY or MONTHLY; INTERVAL where it divides its parent unit evenly; BYMINUTE, BYHOUR, BYDAY (without an ordinal prefix), BYMONTHDAY and BYMONTH.
Everything else is refused by name: COUNT and UNTIL because a recurrence that stops is a different contract from one that repeats and a scheduler holding this has nowhere to record exhaustion; BYSETPOS, BYWEEKNO, BYYEARDAY and an ordinal BYDAY (2MO) because they select from a generated set rather than constrain a field, which these five sets cannot express. Approximating any of them would put a job on the wrong day quietly, which is the failure this type exists to remove.
cron (classmethod)#
cron(expression: str, *, tz: str | datetime.tzinfo = datetime.timezone.utc) -> RecurrenceA five-field cron expression, read on tz's wall clock.
tz defaults to UTC. Pass a zone name or tzinfo when wall-clock recurrence must follow another zone.
matches#
matches(*, minute: int, hour: int, day: int, month: int, weekday: int) -> boolWhether a wall-clock reading is a firing time. weekday is Monday=0.
Vixie-cron semantics for the day fields: when both day-of-month and day-of-week are restricted, either matching is sufficient; otherwise both must match. Every crontab implements that rule and a rewrite that does not gets one job a month wrong.
This takes components rather than an instant, so it cannot apply the zone itself — which is exactly why matches_at exists and is the one to reach for. A caller holding a UTC reading and calling this directly gets UTC semantics, and that is the bug this type was built to remove.
matches_at#
matches_at(moment: datetime.datetime) -> boolWhether moment, read on this recurrence's zone, is a firing time.
The one a scheduler asks. matches is the same question in already-split wall-clock components, for a caller that has them.
next_after#
next_after(moment: datetime.datetime) -> InstantThe first firing time strictly after moment.
Searches forward at most four years and then raises, so an unsatisfiable recurrence (0 0 31 2 * — February 31st) reports itself instead of looping. A local time that does not exist on the search day is skipped, which is what keeps this and matches_at answering the same question.
RecurrenceError#
A recurrence could not be understood, or names something not supported.
A subclass of TemporalError, so a caller that already catches the one this module raises does not have to learn a second name.
TemporalError#
A value could not be understood as a time, a duration, or a zone.
A ValueError subclass because that is what a caller parsing untrusted input already handles.
Timestamp#
google.protobuf.Timestamp: seconds and nanos from the Unix epoch, UTC.
The two field numbers are the wire contract with every peer and are fixed by the specification. nanos is always in 0..999_999_999, including before the epoch -- see to_timestamp.
Timestamp(seconds: int = 0, nanos: int = 0) -> NoneWeek (value)#
Week: Bucket = <Bucket week>Year (value)#
Year: Bucket = <Bucket year>bucket#
bucket(name: str | Bucket) -> BucketThe named bucket, or a TemporalError listing the real ones.
days#
days(value: float) -> Durationvalue days.
A fixed 24 hours, which is what a timedelta can hold honestly. The two days a year that are 23 or 25 hours long are a calendar question, and the answer to those is Bucket and Recurrence, both of which read a zone.
format_duration#
format_duration(value: datetime.timedelta) -> strAn ISO-8601 duration, the inverse of parse_duration.
Zero renders as PT0S rather than the empty P, which is what a reader — and most parsers — expect.
Built from the timedelta's own integer days/seconds/microseconds rather than from total_seconds(). A float total loses precision on large values, and formatting one with %g rounds to six significant digits and switches to scientific notation for small ones — so timedelta.max came out a day too long and one microsecond came out PT1e-06S, which parse_duration rejects. A formatter whose own inverse cannot read its output is worse than no formatter, so the integer components are the only honest source.
format_iso#
format_iso(value: Any) -> strThe ISO-8601 form of a date, time, datetime, or duration.
One function so every surface renders a temporal value identically — the JSON encoder, a template, and a log line cannot disagree.
Exact datetime and Instant values use Wreath's bounded native writer. The full stdlib-compatible shape was measured before it landed: the UTC component retired about 76% fewer instructions, and the dashboard request was lower in all five interleaved trials. Subclasses still call their own isoformat, preserving an override rather than treating it as raw storage.
from_timestamp#
from_timestamp(value: Timestamp) -> InstantA google.protobuf.Timestamp as an aware Instant in UTC.
The wire carries a moment and no zone, so the result is in UTC. Where the reader's zone matters it has to travel in its own field; .to(zone) places the result once it arrives.
Sub-microsecond nanos are refused rather than truncated. Python's resolution is microseconds, and silently dropping the remainder would make a round trip through wreath lossy for a peer that had the precision -- the kind of loss that is invisible until two systems disagree about ordering.
Raises:
TemporalError—nanosis outside0..999_999_999, or carries- precision finer than a microsecond.
from_wall_clock#
from_wall_clock(local: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetimeA naive local wall clock put back on the timeline, as PostgreSQL does it.
The inverse of wall_clock, and public for the same reason: every calendar calculation here ends by putting a local time back on the timeline, and a caller that reinvents that reinvents the fold question with it.
On the two days a year a zone changes offset, a local time can name two instants or none at all. This resolves to the later of the two candidates, because that is what timestamp AT TIME ZONE zone does. That is measured, not assumed: 864 samples across nine zones, both transition directions, and the ambiguous, skipped and ordinary cases, with no disagreement -- including a zone with a half-hour DST step (Australia/Lord_Howe), one whose tzdata entry uses negative DST (Europe/Dublin), and four whose transition is at local midnight (America/Santiago, Asia/Beirut, America/Havana, Africa/Cairo) so that a day boundary lands in the gap rather than an hour boundary.
Matching it is not tidiness. A bucket boundary computed here and one generated by generate_series have to be the same instant: when they differ, a settled row files itself under a bucket the spine never emits and the value silently disappears from every later read.
hours#
hours(value: float) -> Durationvalue hours.
jsonable#
jsonable(value: Any) -> Anyvalue with every temporal object replaced by its ISO-8601 string.
Used by the JSON encoder's fallback so a handler never writes .isoformat() by hand. Containers are rebuilt rather than mutated, and anything that is not temporal is returned untouched — so a genuinely unserializable object reaches the encoder again and raises the error it should, rather than being swallowed here.
An object may also say how to become JSON by defining __jsonable__, which is how a result type like wreath.series.SeriesResult can be returned from a handler directly. The hook is opt-in and deliberately not a blanket dataclass rule: "serialize any dataclass" would put every field of every model a handler happened to return on the wire, including the ones a sensitive-field guard exists to keep off it. A type has to say it knows how to become JSON. The result is walked again, so a hook may return temporal values without converting them itself.
Cost, stated as inspection rather than measurement: this whole function runs only after the encoder has already raised TypeError, so a payload that encodes normally never reaches it and pays nothing. Within the walk the hook is one attribute lookup per non-temporal, non-container leaf.
milliseconds#
milliseconds(value: float) -> Durationvalue milliseconds.
minutes#
minutes(value: float) -> Durationvalue minutes.
now#
now(in_zone: str | datetime.tzinfo = datetime.timezone.utc) -> InstantThe current moment, in UTC unless another zone is named.
parse#
parse(text: str) -> InstantAn ISO-8601 timestamp that carries an offset. See Instant.parse.
parse_duration#
parse_duration(text: str) -> datetime.timedeltaAn ISO-8601 duration such as PT3H or P1DT2H30M.
The stdlib has no parser for these and configuration files are full of them, which is how every codebase ends up with its own half-correct one.
Years and months are rejected: they are not a fixed number of seconds, so a timedelta cannot hold one honestly.
relative#
relative(value: datetime.datetime, *, now: datetime.datetime | None = None, locale: str = 'en') -> strvalue phrased the way a person would say it, relative to now.
"just now", "3 minutes ago", "yesterday", "in 2 hours". Pass request.locale to honour the caller's Accept-Language; an unknown locale renders English rather than failing.
Both value and now must carry an offset. Comparing an aware moment to a naive one is a TypeError waiting to fire on whichever request first supplies the other kind, so it is refused here with an explanation.
seconds#
seconds(value: float) -> Durationvalue seconds.
spine#
spine(start: datetime.datetime, end: datetime.datetime, *, bucket: str | Bucket, in_zone: str | datetime.tzinfo) -> tuple[Instant, ...]Dense local-wall-clock bucket starts for start <= value < end.
This is the in-memory counterpart of the calculated-view SQL spine. It truncates and advances on the reader's wall clock before putting each boundary back on the timeline, including the 23- and 25-hour days around a DST change. The native operation takes only temporal data and a unit index; it knows nothing about a series declaration, model, connection or SQL.
spine_length#
spine_length(start: datetime.datetime, end: datetime.datetime, *, bucket: str | Bucket, in_zone: str | datetime.tzinfo) -> intNumber of local-wall-clock buckets in a half-open range.
This is len(spine(...)) without materialising every Instant. Use it when only cardinality crosses the boundary, such as sizing a chart or enforcing a result limit.
spine_lengths#
spine_lengths(start: datetime.datetime, end: datetime.datetime, *, buckets: tuple[str | Bucket, ...], in_zone: str | datetime.tzinfo) -> tuple[int, ...]Several cardinalities over one range with one zone conversion.
The result follows buckets order. This is the cardinality form for a dashboard or retention planner that needs several calendar resolutions of the same interval; no Instant spine is materialised.
to_timestamp#
to_timestamp(value: datetime.datetime) -> Timestampvalue as a google.protobuf.Timestamp.
A naive value is refused rather than assumed UTC, for the same reason Instant refuses one: guessing is the bug this module exists to prevent.
nanos is normalised non-negative. For an instant before the epoch the specification requires seconds to carry the sign and nanos to stay in 0..999_999_999 -- so half a second before the epoch is seconds=-1, nanos=500_000_000, never seconds=0, nanos=-500_000_000. Python's timestamp() returns a signed float, and the obvious int() of it truncates toward zero, which produces exactly the illegal second form and puts a peer's reading one second out.
Raises:
TemporalError—valuehas no offset.
wall_clock#
wall_clock(value: datetime.datetime, tz: datetime.tzinfo) -> datetime.datetimevalue as a plain naive datetime on tz's wall clock.
Public because reading an instant on somebody's clock is the first step of every calendar calculation in the codebase, and each caller that reinvents it reinvents the trap below with it. Bucket uses it to truncate, and wreath.series to step a comparison period back a month.
Built component-wise rather than with replace(tzinfo=None) because replace preserves the subclass, and an Instant refuses to exist without an offset -- correctly, since that is the bug it is here to prevent.
fold is dropped, and nothing is lost by dropping it: both passes of an ambiguous hour read the same on a wall clock, which is exactly what value AT TIME ZONE zone returns for them too. Putting a local time back on the timeline is where the choice actually happens -- see from_wall_clock.
weeks#
weeks(value: float) -> Durationvalue weeks, as a fixed seven days.
zone#
zone(name: str) -> ZoneInfoThe named IANA time zone, or a TemporalError naming it.
ZoneInfoNotFoundError is accurate but arrives without the name in the common formatting, and a bad zone is nearly always a typo in configuration.
wreath.series#
Chart data as a declaration, not a hand-rolled query.
Almost every web application ends up with the same paragraph of code: fetch a few thousand rows, loop over them in Python, bucket them by day, and hand the result to a chart. It is written that way not because anyone wants to, but because the query layer could not say count these per day, in the reader's timezone, for the last quarter, with the quiet days showing as zero — so the rows came into the process and the arithmetic happened in a loop.
That loop is where the interesting bugs live. It buckets by UTC and the days come out shifted for everyone east of Greenwich. It skips the empty days, so the line joins across a gap that was really a zero. It fills the empty days with zero for every measure, so an average collapses to the floor on the quietest day of the week. And it re-reads the whole history on every page load, because there is nowhere to put the answer.
A declaration says the same thing once, and the database does the work:
from wreath.series import Series, count, sum_
from wreath.temporal import Day, zone
activity = (
Series(Trek, at=Trek.started_at, bucket=Day, stored_in=zone("Pacific/Auckland"))
.where(Trek.herd_id == Param("herd"))
.measure(started=count(), distance=sum_(Trek.distance_km, unit="km"))
.by(Trek.paddock_id, top=7)
)
@app.get("/herds/{herd_id}/activity")
@cached(ttl=300, invalidate_on=activity.sources)
async def herd_activity(request, herd_id: int, session: Session):
return await activity.run(
session, herd=herd_id, range=Range(start, end), zone=request.zone,
)Declaration enforces four properties:
- Every bucket in the range exists, because the range generates a spine and the aggregate is joined onto it. An empty Tuesday is a zero, not an absence.
- Fill is per measure. A count of nothing is zero; an average of nothing is undefined, and stays
Noneso the renderer draws a gap. - Every series has a stable key, taken from the grouping value rather than from its position, so a filter change cannot repaint the legend.
- Mistakes move to import time. Bucketing by a column that holds no time, averaging text, grouping by a collection — those fail when the declaration is written, not on the request that first draws an empty chart.
What this is not. It takes one source model, declared measures, and a bounded result. If you cannot name the model, name each measure and its unit, or state the largest the result can get, then what you have is a query rather than a chart, and session.raw() is the honest tool for it. This module refuses to become a way to express everything, which is SQL with worse syntax.
Reference: /reference/series.
Aggregate#
Grouped totals with no time axis — a bar chart, a KPI, or a scatter.
The shared core, usable on its own:
by_paddock = (
Aggregate(Trek)
.where(Trek.started_at >= Param("since"))
.measure(treks=count(), distance=sum_(Trek.distance_km))
.by(Trek.paddock_id)
)Unlike a series it does not fold a long tail into a remainder: the bars are the answer, so a result past the ceiling refuses rather than drawing a chart that is quietly wrong.
Aggregate(model: type, *, _state: _Declaration | None = None, _limit: int = 50) -> Noneby#
by(column: Any, *, limit: int = 50) -> AggregateGroup by column, refusing beyond limit groups.
The ceiling is declared rather than passed per request, so it lives where it is reviewed instead of in a query parameter a client can set to a million.
limit (property)#
limit: intrun#
async run(session: Any, **values: Any) -> AggregateResultRun this declaration on session and assemble the result.
archive (inherited from _Builder)#
archive(*_args: Any, **_kwargs: Any) -> Anycell (property, inherited from _Builder)#
cell: AnyThe spatial axis, or None on a declaration that has no place.
declared_columns (property, inherited from _Builder)#
declared_columns: tuple[tuple[str, ColumnExpr], ...]Every column this view names, tagged with what it does to it.
("time", …) is the bucketing column, ("aggregate", …) a column summed or averaged, ("group", …) the grouping key. Filters are predicates instead, because for a filter the operator decides whether it is safe and a bare column would throw that away.
This exists for a reader that does not exist yet, and the reason is worth writing down. Design 24 (deferred data migrations) refuses SUM, AVG, MIN, MAX, GROUP BY and joins on a column that is mid-conversion — a backfill rewriting values underneath a running application. Those are not exotic operations here; they are most of what a calculated view is made of, and a grouped chart over a half-converted column shows one category forking into two with nothing raising.
The check belongs to the migrations side, which is the only half that knows a conversion is running — this module deliberately has no notion of a converting column and should not grow one. What it owes is inspectability: a declaration is a value written at import time, so a scan can read this instead of parsing handlers. That is the contract, and it is why this is a property rather than a note in a design document.
drop (inherited from _Builder)#
drop(*_args: Any, **_kwargs: Any) -> Anyfill (inherited from _Builder)#
fill(**values: Any) -> AnyOverride what an empty bucket reads as, per measure.
Only needed to disagree with the measure's own identity — a count and a sum already fill with zero. Filling an average with a number is allowed and has to be written here, at the call site, where a reviewer can see that the flat line on a quiet day was a decision.
fills (property, inherited from _Builder)#
fills: dict[str, Any]Per-measure overrides for what an empty bucket or cell reads as.
group (property, inherited from _Builder)#
group: Anymeasure (inherited from _Builder)#
measure(**measures: Measure) -> AnyDeclare one or more named measures.
Named rather than positional because the name is the series key in the result and the field name in a generated client; positional measures produce value_0, which nobody wants in a component.
Two measures return two separate named series, each with its own unit and kind. They are never merged into one plottable line: two quantities with different units on one pair of axes is a dual-axis chart, whose alignment is arbitrary and invents a correlation that is not in the data.
measures (property, inherited from _Builder)#
measures: tuple[tuple[str, Measure], ...]model (property, inherited from _Builder)#
model: typepredicates (property, inherited from _Builder)#
predicates: tuple[Predicate, ...]The declared filters, for a reader that wants to inspect them.
Public for the same reason sources is: a declaration is a value, and a tool that has to know what this view touches should read it here rather than re-deriving it from source.
retain (inherited from _Builder)#
retain(*_args: Any, **_kwargs: Any) -> Anyseal (inherited from _Builder)#
seal(*_args: Any, **_kwargs: Any) -> Anysources (property, inherited from _Builder)#
sources: tuple[type, ...]Every model this declaration reads, for invalidate_on.
Derived from the query rather than restated beside it, so a predicate that starts filtering through a relationship cannot leave a cache serving a stale chart.
where (inherited from _Builder)#
where(*predicates: Predicate) -> AnyNarrow this view; predicates combine with AND.
Takes the same predicates a Select does — not a parallel filter language, the same one, compiled by the same code. A Param stands where a value would and is supplied per call to run.
AggregateResult#
Grouped totals with no time axis — a bar chart, a KPI, a scatter.
AggregateResult(rows: tuple[AggregateRow, ...], measures: tuple[str, ...]) -> Noneas_dict#
as_dict() -> dict[str, Any]The JSON body, matching the generated AggregateResult<M>.
AggregateRow#
AggregateRow(key: 'Any', label: 'str', values: 'dict[str, Any]')
AggregateRow(key: Any, label: str, values: dict[str, Any]) -> Noneas_dict#
as_dict() -> dict[str, Any]Cell#
One cell of a heatmap: where it is, and what was in it.
Cell(row: int, column: int, bounds: Any, centre: Any, values: dict[str, Any]) -> Noneas_dict#
as_dict() -> dict[str, Any]Cells#
A quantity per cell over an extent — a heatmap as a declaration.
The spatial sibling of Series, and it exists for the same reason. Every cell in the extent is present whether or not anything happened in it, and fill is decided per measure, so a quiet cell is a zero count and an undefined average rather than a hole in the map or a plunge to the floor.
heat = (
Cells(Sighting)
.where(Sighting.species == Param("species"))
.measure(seen=count(), mean_weight=avg(Sighting.weight_kg))
.over(Sighting.lat, Sighting.lon, metres=10_000, extent=reserve)
)The lattice is a declaration-time fact, so the number of cells a request will produce is knowable before it runs — which is what lets the ceiling be enforced where a reviewer reads it rather than after the database has done the work.
Cells(model: type, *, _state: _Declaration | None = None) -> Nonegrid (property)#
grid: AnyThe lattice this declaration buckets onto, or None before over.
over#
over(lat: Any, lon: Any, *, metres: float, extent: Any, limit: int = 10000) -> CellsBucket onto a lattice of metres cells covering extent.
The ceiling is declared rather than passed per request, for the same reason by(limit=) is: it lives where it is reviewed instead of in a query parameter a client can set to a million. It is checked here, not at run time, because rows * columns is arithmetic on the extent.
run#
async run(session: Any, **values: Any) -> CellsResultRun this declaration on session and assemble every cell.
archive (inherited from _Builder)#
archive(*_args: Any, **_kwargs: Any) -> Anycell (property, inherited from _Builder)#
cell: AnyThe spatial axis, or None on a declaration that has no place.
declared_columns (property, inherited from _Builder)#
declared_columns: tuple[tuple[str, ColumnExpr], ...]Every column this view names, tagged with what it does to it.
("time", …) is the bucketing column, ("aggregate", …) a column summed or averaged, ("group", …) the grouping key. Filters are predicates instead, because for a filter the operator decides whether it is safe and a bare column would throw that away.
This exists for a reader that does not exist yet, and the reason is worth writing down. Design 24 (deferred data migrations) refuses SUM, AVG, MIN, MAX, GROUP BY and joins on a column that is mid-conversion — a backfill rewriting values underneath a running application. Those are not exotic operations here; they are most of what a calculated view is made of, and a grouped chart over a half-converted column shows one category forking into two with nothing raising.
The check belongs to the migrations side, which is the only half that knows a conversion is running — this module deliberately has no notion of a converting column and should not grow one. What it owes is inspectability: a declaration is a value written at import time, so a scan can read this instead of parsing handlers. That is the contract, and it is why this is a property rather than a note in a design document.
drop (inherited from _Builder)#
drop(*_args: Any, **_kwargs: Any) -> Anyfill (inherited from _Builder)#
fill(**values: Any) -> AnyOverride what an empty bucket reads as, per measure.
Only needed to disagree with the measure's own identity — a count and a sum already fill with zero. Filling an average with a number is allowed and has to be written here, at the call site, where a reviewer can see that the flat line on a quiet day was a decision.
fills (property, inherited from _Builder)#
fills: dict[str, Any]Per-measure overrides for what an empty bucket or cell reads as.
group (property, inherited from _Builder)#
group: Anymeasure (inherited from _Builder)#
measure(**measures: Measure) -> AnyDeclare one or more named measures.
Named rather than positional because the name is the series key in the result and the field name in a generated client; positional measures produce value_0, which nobody wants in a component.
Two measures return two separate named series, each with its own unit and kind. They are never merged into one plottable line: two quantities with different units on one pair of axes is a dual-axis chart, whose alignment is arbitrary and invents a correlation that is not in the data.
measures (property, inherited from _Builder)#
measures: tuple[tuple[str, Measure], ...]model (property, inherited from _Builder)#
model: typepredicates (property, inherited from _Builder)#
predicates: tuple[Predicate, ...]The declared filters, for a reader that wants to inspect them.
Public for the same reason sources is: a declaration is a value, and a tool that has to know what this view touches should read it here rather than re-deriving it from source.
retain (inherited from _Builder)#
retain(*_args: Any, **_kwargs: Any) -> Anyseal (inherited from _Builder)#
seal(*_args: Any, **_kwargs: Any) -> Anysources (property, inherited from _Builder)#
sources: tuple[type, ...]Every model this declaration reads, for invalidate_on.
Derived from the query rather than restated beside it, so a predicate that starts filtering through a relationship cannot leave a cache serving a stale chart.
where (inherited from _Builder)#
where(*predicates: Predicate) -> AnyNarrow this view; predicates combine with AND.
Takes the same predicates a Select does — not a parallel filter language, the same one, compiled by the same code. A Param stands where a value would and is supplied per call to run.
CellsResult#
Every cell of the lattice, in row-major order.
CellsResult(cells: tuple[Cell, ...], measures: tuple[str, ...], grid: Any) -> Noneas_dict#
as_dict() -> dict[str, Any]ChartData#
An immutable numerical chart dataset prepared from plain iterables.
Construction is the Python boundary: buckets, sparse readings and fills are validated and copied into owned numeric storage once. Repeated projections then retain dense cells, presence bits, LTTB selections and path buffers in that storage until the final paths and axes materialise.
The ordinary project_chart and project_chart_spine calls remain useful for one-shot data. Use this value when the same in-memory or database-derived snapshot serves more than one projection.
ChartData(buckets: Any, sparse: dict[tuple[Any, bool], dict[Any, dict[str, Any]]], fills: dict[str, Any]) -> Nonefrom_rows (classmethod)#
from_rows(buckets: Any, series: Iterable[tuple[tuple[Any, bool], Iterable[tuple[Any, dict[str, Any]]]]], fills: dict[str, Any]) -> ChartDataPrepare a chart without materializing a nested sparse mapping.
Each series item is a stable (key, other) identity paired with a one-pass iterable of (bucket, measure_dict) readings. Series retain their input order; every bucket must belong to buckets and may occur only once within its series.
project_chart#
project_chart(*, downsample_rows: Any, full_rows: Any = (), threshold: int = 128, tick_target: int = 9, cache: bool = True) -> tuple[int, tuple[tuple[Any, bool], ...], tuple[str, ...], tuple[tuple[float, ...], ...]]Project this snapshot to final SVG paths and tick axes.
project_chart_text#
project_chart_text(*, downsample_rows: Any, full_rows: Any = (), threshold: int = 128, tick_target: int = 9, cache: bool = True) -> tuple[int, tuple[tuple[Any, bool], ...], tuple[str, ...], str, int]Serialize tick axes at their final text egress boundary.
project_chart_text_joined#
project_chart_text_joined(*, downsample_rows: Any, full_rows: Any = (), threshold: int = 128, tick_target: int = 9, cache: bool = True) -> tuple[int, tuple[tuple[Any, bool], ...], str, int, str, int]Write all SVG paths contiguously and serialize the tick axes.
Measure#
One aggregate, its unit, and how it behaves where there is no data.
Built by count, sum_, avg, min_ and max_ rather than directly, because the identity element and the rollup rule are properties of the function rather than choices.
Measure(function: str, column: ColumnExpr | None, kind: str, unit: str | None = None, identity: Any = None, has_identity: bool = False, rollup_safe: bool = True) -> NoneRange#
A half-open span of time — start <= t < end.
Half-open, stated once, and used for the filter, the spine bounds, and every boundary derived from them. The off-by-one in a chart comes from writing the boundary twice with two different intentions; there is only one here.
Range(start: Any, end: Any) -> NoneSeries#
A quantity per interval over a range, in the reader's timezone.
Every bucket in the range exists whether or not anything happened in it, which is what makes the line honest: a quiet Tuesday is a zero rather than a segment joining Monday to Wednesday.
Series(model: type, *, at: Any, bucket: Bucket, stored_in: Any = None, _state: _Declaration | None = None, _top: int = 7, _compare: Bucket | None = None, _events: _Events | None = None, _seal: Seal | None = None, _tiers: Ladder | None = None) -> Noneat (property)#
at: ColumnExprbucket (property)#
bucket: Bucketby#
by(column: Any, *, top: int = 7) -> SeriesSplit into one series per value of column, keeping the top top.
Everything past the cut folds into a single remainder carrying the reserved key None and other=True. Folding is meaningful where refusing would not be: the remainder preserves the total, which is what a part-to-whole chart is for.
The survivors are ranked over the whole range by the first declared measure, so a series does not appear and vanish as the reader pans. The fold happens before aggregation, so the remainder's average is a true average of the tail's rows rather than an average of averages.
compare#
compare(*, previous: Bucket) -> SeriesAlso compute the same range one previous earlier.
previous=Month answers "and what did this look like last month?". The shift is a bucket width from wreath.temporal rather than a duration, because the useful comparisons are calendar ones: a month is 28 to 31 days depending on when you ask, and "the same days last month" is what a reader means even though no fixed number of hours expresses it.
It stays one statement. Two statements are how the periods end up misaligned by a bucket, and the alignment is the entire value of the feature — anyone can run the query twice.
The comparison period must not overlap the primary one. A shift shorter than the range would put some rows in both periods, and there is no honest way to draw that: counting them twice inflates the comparison, counting them once silently drops them from one side.
events#
events(model: type, *, at: Any, label: Any, where: Any = (), limit: int = 25) -> SeriesAnnotate the chart with markers — deploys, incidents, releases.
The markers come from their own model and are read over the range the series already has, which is the whole reason this belongs on the declaration rather than beside it. Two hand-written queries drift: one clips its range differently, or buckets in a different zone, and a marker lands a column away from the event it describes.
Each marker carries both its exact instant and the bucket it falls in, computed by the same date_trunc in the same zone as the series, so it can sit at its true x-position while still knowing what it annotates.
This is a second statement on the same session rather than a tagged UNION ALL of buckets and markers. The union would force two different row shapes into one, half the columns null in every row, with a discriminator the client has to switch on — a worse envelope and worse generated types, in exchange for a round trip the driver describes itself as pipelining anyway. Whether it really costs one round trip or two is a question for a real server; the alignment does not depend on the answer.
With compare, markers cover the primary period only. An annotation layer answers "what happened during this", and drawing last month's deploys over this month's chart would need a second axis to be readable at all.
reconcile#
async reconcile(session: Any, *, range: Range, zone: Any = None, now: Any = None, **values: Any) -> tuple[Any, ...]Find sealed buckets whose rows have changed, and record the difference.
This is how a late write is noticed. Nothing notices one on the write path, and that is deliberate rather than missing: the ORM's write events are model-grained by design — they publish which models a session touched, not which rows — so they cannot say which bucket a late trek belongs to or what it contributes. Making them row-grained to serve a chart would put per-row bookkeeping on every write in the application.
So the application runs this: from a scheduled job, after an import, after a backfill. It settles the sealed part of range first — since reading stores nothing, this is where a bucket becomes stored at all — then recomputes it from the source rows, compares each bucket to what was settled, and writes a delta where they disagree, or replaces the settled value outright if the view declared on_late="reopen".
Returns the bucket starts it corrected, so a caller can log or alert on late data arriving rather than discovering it in a discrepancy later. A bucket settled by this same call is not a correction and is not in it; settle returns those.
retain#
retain(**windows: Any) -> SeriesHow long each grain stays warm, finest to coarsest.
.seal(after="2h").retain(raw="3 days", day="1 year", month=None)Read as: keep the source rows answering for three days, keep daily buckets a year, keep monthly buckets forever. None means indefinitely.
Nothing here deletes anything, and this stage adds no way to. retain is a promise about what stays warm, not an instruction to expire. What it changes today is which tier a read prefers: a range older than raw's window is answered from the coarsest tier that still covers it, even though raw happens to still be present. This keeps the query shape stable when retention enforcement begins.
A tier coarser than raw requires seal, because a tier stores a value on the understanding that it is final and only the seal says when a value is final. A measure that cannot be recombined from parts -- an average -- is refused against a bounded raw window, because a coarse bucket built from averages is an average of averages.
rollup#
async rollup(session: Any, *, range: Range, zone: Any = None, now: Any = None, **values: Any) -> dict[str, tuple[Any, ...]]Materialise every coarser tier over the sealed part of range.
The durable-job body §7.3 describes, written as an ordinary method so the scheduling stays in wreath.jobs where it already lives — jobs.schedule with a dedup key of declaration, tier and bucket makes a re-run a no-op, and the insert refuses to overwrite anyway, so at-least-once delivery costs nothing here.
Two steps, in this order:
reconcilethe range first, so a late write that landed behind the watermark is folded in before anything coarser is built from it. Running rollup without this would carve a stale number into a coarser grain, where it is harder to notice and no longer traceable to the row that caused it.- Compute each coarser grain from the source rows, not from the finer tier. Nothing is ever removed at this stage, so raw is always there, and recomputing from it is both correct and immune to the average-of-averages trap. Building a tier from a tier becomes necessary only when retention starts genuinely removing rows.
Returns the buckets written per tier, so a job can log what it did rather than reporting that it ran.
run#
async run(session: Any, *, range: Range, zone: Any = None, now: Any = None, allow_coarsening: bool = False, **values: Any) -> SeriesResultRun this declaration for one range and one reader's zone.
The range is a runtime argument because it changes per request. The zone is too, unless the view is sealed — a materialised Auckland day cannot be re-cut into a London day after the fact, so a sealed view files its settled buckets under the zone they were computed in and reading it in another zone settles separately rather than lying.
now decides where the watermark falls and defaults to the present. Passing one reads the range as of that instant, which is what makes a sealing test deterministic and a "what did this look like on Friday" question answerable.
allow_coarsening accepts a coarser grain for the part of a range no tier stores at the grain asked for. Off by default: returning monthly numbers labelled as days is a lie that survives review, so the honest default is to refuse and name the coarsest grain available. SeriesResult.segments always reports the grain actually used.
seal#
seal(*, after: Any, on_late: str = 'correct') -> SeriesDeclare when a bucket stops being able to change.
A bucket is sealed once after has elapsed since it closed, and a sealed bucket is computed once and then read. Before that it is open and every run recomputes it, because it can still move.
Args:
after— the lateness allowance --"2h","30m", or a number of- seconds. How long you are willing to wait for a straggler.
on_late— what areconciledoes when it finds a sealed bucket- whose rows have since changed.
"correct"keeps the settled - value immutable and records the difference beside it.
"reopen"replaces it, which is only sound while the rows are- still there to recompute from -- so it is never the default.
A settled bucket is not a cache. It has no TTL, it is never evicted, and nothing here deletes the rows it was computed from.
sealed_after (property)#
sealed_after: float | NoneThe declared lateness allowance in seconds, or None if open.
settle#
async settle(session: Any, *, range: Range, zone: Any = None, now: Any = None, **values: Any) -> tuple[Any, ...]Store every sealed bucket in range that nobody has settled yet.
The write half of .seal(), and the only one there is. Reading a sealed view never writes: a GET runs on a read-workload session, on a replica, or under a role with no INSERT, and settling as a side effect of a read answers cannot execute INSERT in a read-only transaction from inside the series machinery, on a route that wrote nothing the application can see. So the store is filled here, from a scheduled job, beside the reconcile that keeps it honest — reconcile runs this first, so an application already scheduling one needs no second job.
Idempotent by construction: the insert is ON CONFLICT DO NOTHING, and two workers settling the same bucket compute the same number from the same rows, so the loser has nothing to add.
A run() over a range this has never covered returns exactly the same numbers — it computes the sealed part and does not keep it. What settling buys is that the next reader does not have to.
Returns:
- The bucket starts it stored, so a job can log what it did.
Raises:
SeriesError— this view declares noseal().
sources (property)#
sources: tuple[type, ...]Every model this view reads, the annotation layer included.
A new deploy changes the chart just as a new trek does, so a cache keyed on sources has to know about both. Leaving the events model out is the kind of omission that shows up as a marker missing for five minutes and gets blamed on the browser.
tiers (property)#
tiers: tuple[Tier, ...]The declared retention ladder, finest first. Empty when undeclared.
top (property)#
top: intarchive (inherited from _Builder)#
archive(*_args: Any, **_kwargs: Any) -> Anycell (property, inherited from _Builder)#
cell: AnyThe spatial axis, or None on a declaration that has no place.
declared_columns (property, inherited from _Builder)#
declared_columns: tuple[tuple[str, ColumnExpr], ...]Every column this view names, tagged with what it does to it.
("time", …) is the bucketing column, ("aggregate", …) a column summed or averaged, ("group", …) the grouping key. Filters are predicates instead, because for a filter the operator decides whether it is safe and a bare column would throw that away.
This exists for a reader that does not exist yet, and the reason is worth writing down. Design 24 (deferred data migrations) refuses SUM, AVG, MIN, MAX, GROUP BY and joins on a column that is mid-conversion — a backfill rewriting values underneath a running application. Those are not exotic operations here; they are most of what a calculated view is made of, and a grouped chart over a half-converted column shows one category forking into two with nothing raising.
The check belongs to the migrations side, which is the only half that knows a conversion is running — this module deliberately has no notion of a converting column and should not grow one. What it owes is inspectability: a declaration is a value written at import time, so a scan can read this instead of parsing handlers. That is the contract, and it is why this is a property rather than a note in a design document.
drop (inherited from _Builder)#
drop(*_args: Any, **_kwargs: Any) -> Anyfill (inherited from _Builder)#
fill(**values: Any) -> AnyOverride what an empty bucket reads as, per measure.
Only needed to disagree with the measure's own identity — a count and a sum already fill with zero. Filling an average with a number is allowed and has to be written here, at the call site, where a reviewer can see that the flat line on a quiet day was a decision.
fills (property, inherited from _Builder)#
fills: dict[str, Any]Per-measure overrides for what an empty bucket or cell reads as.
group (property, inherited from _Builder)#
group: Anymeasure (inherited from _Builder)#
measure(**measures: Measure) -> AnyDeclare one or more named measures.
Named rather than positional because the name is the series key in the result and the field name in a generated client; positional measures produce value_0, which nobody wants in a component.
Two measures return two separate named series, each with its own unit and kind. They are never merged into one plottable line: two quantities with different units on one pair of axes is a dual-axis chart, whose alignment is arbitrary and invents a correlation that is not in the data.
measures (property, inherited from _Builder)#
measures: tuple[tuple[str, Measure], ...]model (property, inherited from _Builder)#
model: typepredicates (property, inherited from _Builder)#
predicates: tuple[Predicate, ...]The declared filters, for a reader that wants to inspect them.
Public for the same reason sources is: a declaration is a value, and a tool that has to know what this view touches should read it here rather than re-deriving it from source.
where (inherited from _Builder)#
where(*predicates: Predicate) -> AnyNarrow this view; predicates combine with AND.
Takes the same predicates a Select does — not a parallel filter language, the same one, compiled by the same code. A Param stands where a value would and is supplied per call to run.
SeriesComparison#
The same declaration over the period before it.
Its own bucket run rather than values slotted into the primary one, because the two periods are legitimately different lengths: February against March is 28 buckets against 31. Padding one to match the other would invent data; lining them up by index is a decision for whoever draws the chart, and this payload gives them what they need to make it rather than making it for them.
SeriesComparison(previous: str, buckets: tuple[Any, ...], series: tuple[SeriesData, ...]) -> Noneas_dict#
as_dict() -> dict[str, Any]SeriesData#
One plottable line: a stable identity, its unit, and its values.
key is the grouping value, never a rank — a series keeps its identity when its neighbours come and go. other marks the folded remainder, which also carries a None key, and is what tells it apart from a group whose value genuinely is NULL.
SeriesData(measure: str, key: Any, label: str, unit: str | None, kind: str, values: tuple[Any, ...], other: bool = False) -> Noneas_dict#
as_dict() -> dict[str, Any]SeriesError#
A declaration cannot mean what it says, or its result will not fit.
A DeclarationError because that is what the ORM already raises when a query is malformed at the point it is written, and a calculated view is a query. Cardinality refusals raise it too: a result too large to draw is a fact about the declaration, even when only the run discovers it.
SeriesEvent#
One marker: when it happened, which bucket that was, and what to call it.
Both times are carried because both are wanted and neither can be derived from the other on the client. at puts the marker at its true x-position; bucket says which column it annotates, computed by the same date_trunc in the same zone as the series it sits over, so a marker cannot land a bucket away from the bar it describes.
SeriesEvent(at: Any, bucket: Any, label: Any) -> Noneas_dict#
as_dict() -> dict[str, Any]SeriesResult#
A dense run of buckets and one named series per measure per group.
SeriesResult(range: Range, zone: str, bucket: str, buckets: tuple[Any, ...], series: tuple[SeriesData, ...], comparison: SeriesComparison | None = None, events: tuple[SeriesEvent, ...] = (), state: Any = None, segments: tuple[Any, ...] = ()) -> Noneas_dict#
as_dict() -> dict[str, Any]The JSON body, with the field names the generated TypeScript expects.
Written out rather than derived from the dataclass, because these keys are a wire contract shared with wreath typegen and a field rename should have to be made in both places on purpose.
Needed because the JSON encoder does not know dataclasses: returning the result object itself raises TypeError on the first request. Teaching temporal.jsonable a hook would let the object go back directly and is the better long-term shape; it belongs to that module rather than here.
SettledStore#
What a sealed Series stores, as something an application can hold.
A Series is a declaration, not a registered subsystem: it is built where it is used and the application never sees it, so app.schema_components() had nothing to ask and the settled-bucket tables were created by nothing. An application declaring a sealed view had to reach into wreath._series.settle past a leading underscore and run the DDL itself, or discover the absence at the first settle().
app.series(database=...) registers one of these instead. It holds no state and does no work; it exists so the claim has an owner in the same place every other wreath-owned table's claim has one, and so the lifespan that creates the job ledger creates these too.
SettledStore(schema: str = 'wreath') -> Nonecomponent#
component() -> AnyThis store's claim on the wreath schema.
schema_sql#
schema_sql() -> strThe DDL, semicolon-joined. A derivation of component().
Tier#
One rung of the ladder: a grain, and how long it is guaranteed warm.
Args:
grain— the bucket width this tier stores, orNonefor the raw rows- themselves. Raw is a tier because retention is a ladder and the
- source rows are its bottom rung.
keep— seconds this tier is guaranteed queryable, orNonefor- "indefinitely". Never a deletion instruction -- see the module
- docstring.
Tier(grain: Bucket | None, keep: float | None) -> Nonecovers#
covers(instant: Any, *, now: Any) -> boolWhether this tier still promises to answer for instant.
is_raw (property)#
is_raw: boolname (property)#
name: stravg#
avg(column: Any, *, unit: str | None = None) -> MeasureThe mean of column. Empty buckets read as None, never 0.
An average of no rows is undefined. Rendering it as zero draws a line plunging to the floor on every quiet day, which reads as a collapse in the thing being measured rather than as an absence of it.
count#
count() -> MeasureHow many rows fall in each bucket. Empty buckets read as 0.
max_#
max_(column: Any, *, unit: str | None = None) -> MeasureThe largest value of column. Empty buckets read as None.
min_#
min_(column: Any, *, unit: str | None = None) -> MeasureThe smallest value of column. Empty buckets read as None.
nice_ticks#
nice_ticks(minimum: float, maximum: float, target: int = 6) -> tuple[float, ...]Readable 1/2/5 × 10ⁿ ticks covering an inclusive numeric extent.
project_chart#
project_chart(buckets: Any, sparse: dict[tuple[Any, bool], dict[Any, dict[str, Any]]], fills: dict[str, Any], *, downsample_rows: Any, full_rows: Any = (), threshold: int = 128, tick_target: int = 9) -> tuple[int, tuple[tuple[Any, bool], ...], tuple[str, ...], tuple[tuple[float, ...], ...]]Project sparse series data directly to compact chart outputs.
Row indices address the stable series-major, measure-minor order produced by reconcile. The operation retains dense cells, LTTB selections and path buffers for the call; only identities, final SVG paths and tick axes cross into Python.
project_chart_spine#
project_chart_spine(start: Any, end: Any, *, bucket: Bucket, in_zone: Any, sparse: dict[tuple[Any, bool], dict[Any, dict[str, Any]]], fills: dict[str, Any], downsample_rows: Any, full_rows: Any = (), threshold: int = 128, tick_target: int = 9) -> tuple[int, tuple[tuple[Any, bool], ...], tuple[str, ...], tuple[tuple[float, ...], ...]]Project a local-wall-clock dense range without materialising its spine.
Sparse bucket keys are mapped directly to ordinal positions in the native dense run. The range, unit, zone, sparse values and fill values are all data: no series declaration, model, connection, ORM type or SQL enters the operation. Use project_chart when the dense run already exists as an iterable, and this form when the run is defined by a temporal range.
reconcile#
reconcile(buckets: Any, sparse: dict[tuple[Any, bool], dict[Any, dict[str, Any]]], fills: dict[str, Any]) -> tuple[tuple[tuple[Any, bool], str, tuple[Any, ...]], ...]Reconcile sparse measured values against a dense bucket run.
This is the storage-neutral series kernel. buckets may be any ordered iterable; sparse maps a stable (key, other) series identity to its measured buckets; and fills gives each measure's absent value in output order. It accepts no declaration, model, connection, ORM type or SQL, so an in-memory producer and the PostgreSQL envelope use the same operation.
series_path#
series_path(x: Any, y: Any) -> strAn SVG line path whose None values begin a new segment.
Missing measurements are gaps, never zeroes and never bridges. The output therefore emits M after every gap and L only within a contiguous run of finite numeric values.
sum_#
sum_(column: Any, *, unit: str | None = None) -> MeasureThe total of column. Empty buckets read as 0.
lttb#
lttb(x: Any, y: Any, threshold: int) -> tuple[int, ...]Indices selected by Largest-Triangle-Three-Buckets downsampling.
Inputs are finite numeric arrays of equal length. Returning indices keeps the operation independent of the caller's point type and lets one selection address parallel arrays such as labels, confidence bounds and tooltips.
wreath.geospatial#
Coordinates, distances, and bounded proximity queries.
wreath.temporal refuses a naive datetime rather than assuming UTC, because the assumption is wrong twice a year and silent every other day. This module makes the same refusal for place: a Coordinate is built with keywords and never from a bare pair.
from wreath.geospatial import Coordinate, distance
depot = Coordinate(lat=-27.4698, lon=153.0251)
site = Coordinate(lat=-33.8688, lon=151.2093)
metres = distance(depot, site)Coordinate(-27.4698, 153.0251) raises. GeoJSON writes [lon, lat], every mapping UI writes "lat, lon", and a library that accepts a positional pair has picked one silently -- which is the single most common defect in geospatial code and the hardest to see in review, because both orders look plausible. Wreath owns both sides of its wire and its storage, so the ambiguity exists in exactly one place and is refused there.
What this is not#
Tier 1 -- everything here -- needs no PostgreSQL extension. It answers "how far apart", "which rows are within N metres", and "which are nearest", which is what fleet tracking, delivery, field service and store-locator work actually ask.
It does not answer "which rows fall inside this polygon", and it has no projections beyond WGS84. Those need PostGIS, which wreath supports as an opt-in client half in the same shape it supports pgvector -- see the guide.
The accuracy this promises#
Distances are great-circle on a sphere of mean radius 6 371 008.8 m. That is within about 0.5% of an ellipsoidal (Vincenty/Karney) distance, worst near the poles and best near 45 degrees. Good enough to route a van and to sort a list of nearby sites; not good enough to bill by the kilometre without saying which model produced the number.
BoundingBox#
A degree-aligned rectangle, in the order PostgreSQL's box wants.
Longitudes are always within [-180, 180]; a circle that crosses the antimeridian yields two of these rather than one box with a wrapped edge, because no comparison operator understands a wrapped edge.
BoundingBox(lat_min: float, lat_max: float, lon_min: float, lon_max: float) -> Nonecontains#
contains(point: Coordinate) -> boolWhether point falls in this box. The cheap half of within.
Coordinate#
One WGS84 position in degrees. Immutable, hashable, keyword-only.
The bounds are inclusive: a pole (lat=90) and the antimeridian (lon=180) are real places and real coordinates. It is only beyond them that the value cannot mean anything.
Coordinate(*args: Any, lat: Any = None, lon: Any = None) -> NoneGeospatialError#
A coordinate or a bound that cannot mean what it says.
A ValueError so the binding layer renders it as a 422 the way TemporalError already does, rather than as a 500.
Grid#
A lattice of approximately-square cells tiling an extent.
This is the spatial analogue of a bucket width, and it exists for the same reason: a heatmap needs somewhere to put the cells that had nothing in them. rows and columns are known before any query runs, so the number of cells a declaration will produce is a declaration-time fact -- which is what lets a ceiling be enforced where it can be read rather than after the database has already done the work.
The cells are approximately square, not exactly. Latitude steps are constant; longitude steps are computed once, at the extent's middle latitude, because the metres in a degree of longitude shrink towards the poles. Across a modest extent that is a fraction of a per cent. Across a tall one it is the difference between a 10 km cell and a 5 km cell with one legend covering both, so grid refuses past MAX_CELL_DISTORTION and distortion reports where a given lattice actually sits.
Cells are indexed from the extent's south-west corner, (0, 0), and the lattice always reaches past the extent's far edge rather than dropping a partial cell -- narrowing the region the reader asked for is the one thing a dense axis must not do.
Grid(extent: BoundingBox, metres: float, lat_step: float, lon_step: float, rows: int, columns: int, distortion: float) -> Nonecell#
cell(row: int, column: int) -> BoundingBoxThe bounds of one cell, clamped to the valid coordinate range.
centre#
centre(row: int, column: int) -> CoordinateThe middle of one cell — what a renderer pins a marker to.
count (property)#
count: intHow many cells this lattice has. Known without running anything.
index_of#
index_of(point: Coordinate) -> tuple[int, int] | None(row, column) for point, or None when it is off the extent.
None rather than a raise: asking where a point falls is a question with a legitimate negative answer, and the caller filtering a mixed set should not have to guard every call.
Polygon#
A closed ring of Coordinates — a region with a shape.
This is what tier 1 structurally cannot answer and the reason PostGIS exists in wreath at all: a BoundingBox is a rectangle in degrees, and "which subjects crossed this catchment" is not a rectangle. Pass one to covered_by() on a Geography column.
from wreath.geospatial import Coordinate, Polygon
catchment = Polygon([
Coordinate(lat=-34.0, lon=150.0),
Coordinate(lat=-34.0, lon=152.0),
Coordinate(lat=-32.0, lon=151.0),
])
Beacon.select().where(Beacon.at.covered_by(catchment))Built from Coordinates and nothing else. Not (lon, lat) pairs, and not a WKT string: both are the transposition this module exists to refuse, wearing a different hat. A ring of bare pairs is the same ambiguity as one bare pair repeated, and WKT writes longitude first — so a caller who typed POLYGON((-34 150, ...)) from a map UI has written a valid document describing the wrong hemisphere, and no exception would ever tell them.
The ring is closed for you: the first vertex is repeated as the last if the caller did not, because a WKT ring must close and forgetting to is a database error rather than a mistake anyone can see. It never edits the shape otherwise — no winding correction, no simplification, no self-intersection check. PostGIS owns those questions and answers them against the real spheroid; a second opinion here would be a different one.
Polygon(vertices: Iterable[Coordinate]) -> Nonewkt (property)#
wkt: strThe EWKT this region travels to the database as.
SRID=4326;POLYGON((lon lat, ...)) — longitude first, which is what WKT, GeoJSON and PostGIS all want and the opposite of what people say. This is the one place that order is transcribed for a region, exactly as orm.types holds the one place it is transcribed for a point.
The SRID is written rather than defaulted: ST_GeogFromText assumes 4326 for a bare POLYGON(...), and an assumption that is right is still an assumption when the next projection lands.
Trajectory#
An ordered path: (Instant, Coordinate) pairs and what they imply.
This is where wreath.temporal and this module compose, and it is the thing every fleet, delivery and tracking application writes by hand. It is only expressible cleanly because both halves are declared types rather than loose floats and naive datetimes -- a naive timestamp here would make duration wrong across a DST boundary and speed wrong with it.
Deliberately small: derived measures over an ordered sequence. Route matching, map matching and corridor inference are not here and need a real spatial engine.
Trajectory(fixes: Iterable[tuple[Any, Coordinate]]) -> Nonebetween#
between(start: Any, end: Any) -> TrajectoryThe path travelled during [start, end), anchored to where it began.
The anchor is the whole point, and it is what makes a windowed distance add up. The fixes inside the window describe where the subject was, but the distance it covered during the window includes the leg it was already on when the window opened. Taking the last fix at or before start as an anchor means the windows of a path partition its distance exactly: sum the days and you get the trip.
Dropping the anchor instead -- the obvious reading of "the fixes in this window" -- silently loses every leg that crosses a boundary, so a fleet billing on daily distance under-counts by one leg per vehicle per day and the error is invisible because each day looks plausible.
Half-open, like every other window in wreath: a fix exactly on end belongs to the next one.
distance (property)#
distance: floatMetres travelled along the path, summed leg by leg.
The sum of the legs, never the straight line from first to last: an animal that returns to where it started travelled a long way, and a van that did a loop is owed its fuel.
duration (property)#
duration: floatSeconds between the first and last fix, or 0.0 for fewer than two.
fixes (property)#
fixes: Sequence[tuple[Any, Coordinate]]The fixes, oldest first.
grid_summary#
grid_summary(start: Any, end: Any, lattice: Grid) -> tuple[tuple[tuple[int, int], ...], float | None]Occupied cells and mean speed for [start, end) in one data pass.
The operation owns its distance accumulator and occupancy bitset for the call. Only compact cell identities and the final scalar cross back into Python.
speed (property)#
speed: float | NoneMean metres per second over the whole path.
None rather than zero when the path spans no time: a division that cannot be performed has no answer, and returning 0.0 would read as "stationary", which is a different claim.
bounding_boxes#
bounding_boxes(centre: Coordinate, metres: float) -> tuple[BoundingBox, ...]The degree-aligned boxes that contain every point within metres.
This is the half of a proximity search an index can answer. The exact great-circle filter still runs over what the boxes return, because a box is a superset of the circle -- but the box is what stops the query reading the whole table.
Returns two boxes when the circle crosses the antimeridian, and one spanning every longitude when it reaches a pole. Both are the correct answer rather than a refusal: a fleet operating across the date line and a research station near a pole are ordinary, and a library that raised on them would simply be unusable there.
distance#
distance(a: Coordinate, b: Coordinate) -> floatGreat-circle metres between two coordinates. Symmetric.
See the module docstring for the accuracy this promises; it is a sphere, not an ellipsoid, and the difference matters if you are invoicing.
grid#
grid(extent: BoundingBox, *, metres: float) -> GridTile extent with approximately-square cells metres on a side.
Refuses rather than guesses in three cases, each of which produces a chart that is wrong rather than absent:
- an inverted extent, which describes no region at all;
- an extent crossing the antimeridian, where a lattice generated over a wrapped longitude range runs backwards --
bounding_boxesalready returns two boxes for that case, so the caller has the tool; - an extent too tall for one longitude step to stay square across.
wreath.sync#
Subscribe to a query, and be told when its answer moves.
A client that renders a list has two bad options and no good one. It can poll, which is a request per interval per client whether or not anything changed; or it can hold a websocket and re-fetch the whole list whenever anything at all is written, which is the same poll with extra steps. What it wants is this query's answer changed, here is the difference -- and that needs something that can see both the query and the write.
The local-first products all converged on the same architecture and all of them sit beside the database: Zero replicates query results, Electric pushes shapes, PowerSync evaluates sync rules per connected user. Because they are outside the application, each one re-implements authorization, and the drift between "what the sync service thinks you may see" and "what the route thinks you may see" is the leak the whole category has fought. Wreath is inside it. A shape is an ordinary Select built from the principal, evaluated by the ordinary ORM, under the ordinary policies.
from wreath.sync import Sync
photos = Sync(Photo, key=lambda row: row.id)
@photos.shape("mine")
def mine(principal):
return (
Photo.select()
.where(Photo.owner_id == principal.sub)
.order_by(Photo.taken_at.desc())
.limit(500)
)Read-only, deliberately. There is no client write path here, no CRDT and no conflict resolution. Writes go through the ordinary route, where validation, Cedar and the ORM already live. That one restriction removes the hardest correctness problem in the category and keeps the claim honest: this is subscribe to a query, not a distributed database. The moment two clients can write one field, Wreath owns merge semantics forever.
The three properties that decide whether this is correct#
A shape must be bounded, and an unbounded one is refused when it is declared. limit is not a courtesy here -- it is what makes the next property tractable, and a shape without one is refused at import rather than discovered in production against a table that has since grown.
Authorization is evaluated on the change, not on the subscription. The shape is a closure over the principal, and it is re-run against current data every time something moves. A subscription therefore cannot outlive the permission that opened it: the moment a row stops matching -- because the policy changed, because the row's owner changed, because somebody was removed from a team -- the next evaluation simply does not return it.
A row that leaves the shape produces a tombstone. This is the property every product in this category has struggled with, and the bound is what makes it easy. Because the answer is at most limit rows, the subscription can hold the key set it last sent and compare: keys that arrived are upserts, keys that left are removals. Revocation is not a special search, it is the ordinary diff -- so the hard case and the easy case run the same code, and there is no revocation path that can be correct in tests and wrong in production because nobody exercised it.
What the doorbell is, and what it is not#
wreath._livedoc supplies the wake-up: a bounded per-principal subscription registry, cleanup on disconnect, and cross-worker fan-out over the message bus, all of it already listening to wreath._orm_events. This module does not re-implement any of that. The signal it delivers is model-grained and means only something moved, go and look -- it is never the delta. The delta is the shape, re-evaluated.
That distinction is what keeps this honest under a write that arrives on another worker, out of order, or twice: none of those can produce a wrong answer, because the answer is always recomputed rather than patched.
The limit this ships with, stated plainly#
A reconnecting client is sent a fresh snapshot, not a resumed delta. The held key set lives in the subscription, and a subscription ends when the connection does, so a server that has forgotten you cannot tell you what you missed. Sending the snapshot again is correct -- it carries the authoritative key set, so the client drops whatever is no longer in it, which is the tombstone rule applied wholesale -- and it costs one bounded query.
Resuming from a durable cursor instead would need a row-grained change feed appended inside the writing transaction, which is wreath.audit_log's hook rather than one this module may add on its own.
Delta#
What moved between two evaluations of one shape.
removed is the tombstone half and it is the half that matters. A delta that carries only upserted describes a world where nothing is ever revoked, which is the world every sync product's happy-path test is written in.
Delta(upserted: tuple[Mapping[str, Any], ...], removed: tuple[str, ...]) -> Noneas_dict#
as_dict() -> dict[str, Any]Shape#
One named, bounded query over a synced model.
build(principal) returns the Select. It is called again on every evaluation rather than cached, which is what makes authorization current: a shape closing over a role, a team membership or a plan re-reads all three each time it runs.
Shape(name: str, build: Callable[[Any], Any], limit: int) -> Noneevaluate#
evaluate(principal: Any) -> AnyThe Select for this principal, re-derived now.
Snapshot#
The whole current answer, plus the key set that makes it authoritative.
keys is not merely [row.key for row in rows] in disguise: it is the statement these and no others, which is what lets a client drop what it holds and is no longer sent. A client that applies rows and ignores keys has kept every row it was ever revoked.
Snapshot(rows: tuple[Mapping[str, Any], ...], keys: tuple[str, ...]) -> Noneas_dict#
as_dict() -> dict[str, Any]Sync#
The shapes declared over one model, and the doorbell that wakes them.
watch names models besides this one whose writes can move an answer -- a membership table that decides which photos are visible, a role table that decides who may see them. They are watched for the same reason the model itself is, and forgetting one is the bug where a revocation is only noticed the next time the row itself happens to change.
bus is optional and its absence is supported: a single-worker deployment or a test wants the local half without a message bus behind it. With one, a write taken on any worker wakes the subscriptions held on every worker.
keepalive is how long an idle stream waits before emitting an SSE comment. That comment is what stops a proxy closing the stream and how a vanished client is discovered, so it is a liveness setting rather than a cosmetic one; lower it in a test that cannot wait fifteen seconds for a tick.
Sync(model: type, *, key: Callable[[Any], Any] | None = None, watch: Iterable[Any] = (), bus: Any = None, channel: str | None = None, max_rows: int = 5000, max_subscribers: int = 1024, max_per_principal: int = 4, keepalive: float = 15.0) -> Noneadd_shape#
add_shape(name: str, build: Callable[[Any], Any], *, limit: int | None = None) -> ShapeRegister a shape, refusing an unbounded or over-large one.
limit is normally read off the Select the function returns, which is checked once here so the error lands at declaration. Pass it explicitly for a shape that cannot be built without a live principal -- the bound is then a promise the caller makes. Every evaluation verifies that its query carries an equal or tighter limit before issuing database I/O.
close_all#
close_all() -> NoneEnd every open stream. For shutdown, and for tests.
document (property)#
document: LiveDocumentThe doorbell. Exposed for close_all() at shutdown, and for tests.
evaluate#
async evaluate(session: Any, name: str, principal: Any) -> SnapshotRun a shape now and return the whole current answer.
The rebuilt query is refused before database I/O if it drops or widens the declared limit. The returned sequence is still truncated in case a custom session violates the query contract.
get#
get(name: str) -> ShapeThe named shape, or SyncError naming what is declared.
max_rows (property)#
max_rows: intThe largest limit a shape here may declare.
model (property)#
model: typeThe model these shapes select from.
notify_all#
notify_all(reason: str = 'write') -> NoneWake every subscription here, on this worker and the others.
For a change no ORM write announces -- a policy set replaced, a feature flag flipped, a tenant reconfigured. The shape is re-evaluated and the diff is empty when nothing actually moved, so calling this unnecessarily costs one bounded query per open subscription and can never produce a wrong answer.
shape#
shape(name: str) -> Callable[[Callable[[Any], Any]], Callable[[Any], Any]]Declare a named shape. The function is returned unchanged.
The bound is checked here, by building the shape once with a None principal. A shape whose Select cannot be built without a real principal may pass limit explicitly instead; see add_shape.
shapes (property)#
shapes: Mapping[str, Shape]The declared shapes, by name.
stale_evaluations#
stale_evaluations() -> intEvaluations that raised and left the client holding its last answer.
Counted rather than swallowed silently. A shape that has been raising since deploy -- a renamed column, a policy that now refuses -- is otherwise indistinguishable from a shape whose answer never changes, and the client sees the same thing in both cases: nothing.
subscribe#
subscribe(principal: Any, name: str) -> SyncSubscription | NoneA subscription over one shape, or None when the registry is full.
Refused rather than evicted, for _livedoc's reason: evicting somebody else's tab invites a reconnect that evicts the next one.
subscribers (property)#
subscribers: intHow many streams are open right now, across every principal.
SyncError#
A shape or a subscription that cannot be honoured.
SyncSubscription#
One open subscription: a shape, a principal, and the keys last sent.
The held state is exactly two things -- the key set and a version per key -- both bounded by the shape's limit. Nothing durable, and nothing that has to be cleaned up beyond the _livedoc slot, which the stream releases in its finally.
SyncSubscription(sync: Sync, shape: Shape, principal: Any, slot: Subscription) -> Noneclose#
close() -> NoneRelease the _livedoc slot. Idempotent.
closed (property)#
closed: boolheld (property)#
held: frozenset[str]The keys this client is believed to hold. Bounded by the shape.
keepalive (property)#
keepalive: floatThe idle tick this subscription's stream uses, from the doorbell.
poll#
async poll(session: Any) -> DeltaRe-evaluate, and return what moved since the last evaluation.
The whole of revocation is the removed line below. A key that is no longer in the answer is no longer visible to this principal, whatever the reason -- the row was deleted, its owner changed, the policy changed, or it fell out of the ordered window because something newer arrived. All four are the same event to a client, and all four are correct to send.
shape (property)#
shape: Shapesnapshot#
async snapshot(session: Any) -> SnapshotEvaluate the shape and adopt the result as what the client holds.
sync (property)#
sync: SyncThe registry this subscription belongs to.
wait#
async wait() -> boolBlock until something may have moved. False once closed.
UnboundedShape#
A shape with no limit, refused where it was declared.
Not a runtime failure on purpose. An unbounded shape works perfectly against a table with fifty rows in it and stops working, silently and much later, against the same table with five million -- by which time the declaration that caused it is a year old. Refusing at declaration puts the error next to the line that has to change.
sync_events#
sync_events(subscription: SyncSubscription, session_for: Callable[[], Any], *, keepalive: float | None = None) -> AsyncGenerator[ServerSentEvent]Stream a shape: one snapshot, then a delta whenever it moves.
session_for() supplies a fresh ORM session per evaluation, as an async context manager. A long-lived stream must not hold a database connection while it is idle -- that is how a hundred open tabs exhaust a pool of twenty -- and it must not hold a snapshot either, because a session that opened before the write it is about to be told about would re-read the same stale rows out of its identity map and report that nothing changed.
An evaluation that raises does not end the stream. The client keeps the last answer it was given, which is the same position it is in between two unrelated writes, and the failure is counted on the Sync (stale_evaluations()) so a shape that has been broken since deploy is visible as a number rather than as silence.
sync_stream#
sync_stream(subscription: SyncSubscription, session_for: Callable[[], Any], *, keepalive: float | None = None) -> SSEResponseAn SSE response over sync_events.
wreath.passes#
Backfills, rollups, reindexes: a durable, resumable, paced walk over a big table.
Sooner or later a table gets big enough that you cannot change all of it at once. Ten million rows need a new column filled in. A month of raw events needs folding into a daily summary. Every row needs re-encrypting under a new key. The shape is always the same, and so is the script people write for it: a while True loop with OFFSET, one transaction around the whole thing, a sleep somebody tuned once on a laptop, a print for progress, and an except: continue that turns a failed chunk into a silent hole. It runs in a terminal on a jump host and it is nobody's job to watch it.
A ChunkedPass is that script, written once and correctly:
purge_replays = ChunkedPass(
"idempotency_purge",
over=Table("wreath_idempotency"),
units=Rows(
key=(
Key("expires", "timestamptz", indexed=True),
Key("key", "text", unique=True),
),
limit=1_000,
within="2s",
),
frontier=Sealed(),
work=Purge(),
pace=DutyCycle(0.25),
)
jobs.drive(purge_replays, cron="*/5 * * * *")What that buys, and every item is a specific thing the hand-rolled loop gets wrong:
- Keyset ranges, never
OFFSET. The walk stays the same speed at row nine million as at row nine. Seewreath._passes.keysetfor the arithmetic. - One transaction per chunk, with the cursor advanced inside it. The position and the data are two rows in one database, so they commit together; a chunk is either wholly applied or wholly not, and a crash resumes where it stopped.
- A compare-and-swap as each chunk's first statement, which makes a chunk exactly-once with respect to everything inside the database, with no cooperation from the caller and no lock to hold.
- Bounded shifts. Work is done in stretches shorter than the job lease, so the runner never reclaims a handler that is still running, and a redeploy costs at most one chunk.
- Pacing, from the first release. A walk that goes as fast as it can is the one that takes the site down while its own dashboard stays green.
- Refusals where you declared it, not at three in the morning: a key that cannot be proven unique, a leading key column with no index, a fixed ceiling over a key that is not assigned in order, a shift longer than the lease.
The guide is Chunked passes.
What a pass deliberately does not know. It has no opinion on what a chunk means, and no clock of its own -- scheduling belongs to wreath.jobs, which already deduplicates a cron tick fleet-wide. Its whole vocabulary is "a half-open range over one ordered domain"; row counts are reported and never structural, which is what keeps the door open for a range source that counts no rows at all.
Apply#
Hand each chunk to a callback, for work no declared shape covers.
The callback is awaited as callback(tx, chunk, binds) inside the chunk transaction, so anything it writes commits with the cursor. It returns the number of rows it touched, for the report.
idempotent= is required: see Declared.
Apply(callback: Any, idempotent: Declared | None = None) -> Noneapply#
async apply(tx: Any, chunk: Chunk, binds: Any) -> intAwait the callback inside the chunk transaction.
None and False count as zero, so a callback with nothing useful to report may return nothing. The number reaches the report only; it does not affect whether the chunk committed.
Returns:
- Whatever the callback returned, as an int.
writes (property, inherited from _Work)#
writes: tuple[str, ...]Columns this work assigns. A pass refuses to walk by one of them.
Buckets#
The next range is the next calendar bucket, computed rather than queried.
The recurring shape: fold yesterday into a daily rollup, settle last month into a monthly one, expire one day of raw at a time. The walk moves one bucket per chunk (or per_chunk of them), and it never looks at the table to decide where to go.
Args:
on— the timestamp column that assigns a row to a bucket. A model column- or a
Keyfor a table the ORM does not own. step— the bucket width --wreath.temporal.Day, or its name.zone— the wall clock the buckets are cut on. Not a runtime argument:- a materialised Auckland day cannot be re-cut into a London day
- afterwards, so it is part of the declaration.
since— where the first cycle starts. Omit it and the anchor is read from- the earliest row -- one query per cycle, not per chunk.
per_chunk— how many buckets one chunk covers.within— the chunk's time budget, as forRows.
Buckets(on: Any, step: Any = 'day', zone: str = 'UTC', since: Any = None, per_chunk: int = 1, within: Any = '5s') -> Noneadvance#
advance(start: datetime.datetime) -> datetime.datetimeper_chunk buckets past a boundary, on the declared wall clock.
anchor_sql#
anchor_sql(table: str) -> strThe one query this source ever makes: where the first bucket starts.
chunk_where#
chunk_where(binds: Any, *, cursor_from: tuple[Any, ...] | None, cursor_to: tuple[Any, ...], frontier: str | None) -> strcol >= start AND col < end, half-open, stated once.
Closed at the bottom and open at the top -- the opposite anchoring to a keyset chunk, which is open at the bottom so the row it resumed from is not seen twice. Both are half-open ranges over one ordered domain, which is the whole vocabulary the primitive has; only the end that is included differs, and it differs because a bucket boundary is a value the arithmetic produced rather than a row that exists.
floor#
floor(value: datetime.datetime) -> datetime.datetimelimit (property)#
limit: intReported as the ledger's chunk size. Buckets, not rows.
next_range#
async next_range(executor: Any, *, walk: Any, cursor: tuple[Any, ...] | None, ceiling: Any, frontier_sql: Any) -> tuple[tuple[Any, ...] | None, tuple[Any, ...]] | NoneThe next bucket, or None when the frontier has been reached.
Computed, never queried: given a boundary, the next one is arithmetic on a calendar whether or not a single row falls between them.
The frontier test is on the range's end, not its start, and that is the sealing rule rather than an off-by-one: a bucket cannot be settled before the moment it stops accepting rows, and that moment is its exclusive end. Folding in a day that is not over yet would settle a number that is still moving.
The start is the cursor itself and is included -- the opposite anchoring to a keyset chunk, because a bucket boundary is a value the arithmetic produced rather than a row that has already been walked.
refuse#
refuse(*, table: str) -> Nonereproduce#
reproduce(*, table: str, cursor_from: tuple[Any, ...] | None, cursor_to: tuple[Any, ...]) -> strThe statement an operator pastes into psql to see the real error.
tz (property)#
tz: datetime.tzinfoCeiling#
A fixed frontier, captured once, so "runs to completion" means something.
Without a ceiling, a table written to faster than the walk moves never terminates: the pass reports ninety-six percent forever while doing real work. Rows written past the ceiling are not this pass's problem, and the reason they are not is the precondition every pass is declared under:
A pass converts the past. The application writes the future in the shape the pass is converting to.
That is the primitive's rule rather than the caller's choice, which is why it is written here and not in one caller's guide.
Ceiling(monotone: str | None = None) -> Noneat_launch (classmethod)#
at_launch(*, monotone: str | None = None) -> CeilingCapture the largest key in the table when the walk starts.
Sound only when a row inserted afterwards cannot land beneath the ceiling. An identity primary key or a now() default gives that; gen_random_uuid() does not, and a row that lands behind the cursor is one the pass will never see. So this is refused on a key the declaration cannot prove is assigned in order, and monotone is the way past it -- a sentence a reviewer reads, not a flag, because ULIDs and UUIDv7 really are monotone and nothing in a column declaration can see that when the application assigns the value.
derive#
async derive(executor: Any, *, table: str, keys: tuple[Key, ...]) -> AnyCapture the ceiling: the largest key in the table, encoded for the ledger.
One index descent -- ORDER BY key DESC LIMIT 1 against the same index the walk uses. Runs once, when the walk starts, and the value is durable from then on. None when the table is empty, which predicate turns into a walk with nothing to do.
predicate#
predicate(keys: tuple[Key, ...], ceiling: Any, binds: Any) -> strThe WHERE fragment meaning "not past the ceiling".
A row comparison against the captured key, in whichever direction the key is ordered. FALSE when no ceiling was captured, so an empty table's walk completes immediately.
refuse#
refuse(keys: tuple[Key, ...], *, table: str) -> NoneRefuse a fixed ceiling over a key not assigned in increasing order.
Called once, when the pass is declared. A row that can land beneath a captured ceiling is a row behind the cursor, which this pass will never see; monotone= is the written escape for a key the declaration cannot prove but the caller knows.
Chunk#
One chunk of a pass: a half-open range over the pass's ordered domain.
The range is (cursor_from, cursor_to] -- open at the low end so the row the last chunk finished on is not seen twice, closed at the high end so the cursor is always a key that really exists. Row counts belong to the report, never to the range.
Chunk(table: str, where: str, cursor_from: tuple[Any, ...] | None, cursor_to: tuple[Any, ...], model: Any = None, alias: str = '') -> NoneChunkedPass#
A durable, resumable, paced walk over one table.
Declared once, validated at declaration time, and driven by the job runner with wreath.jobs.JobRunner.drive. See the module docstring for what the machinery buys and the guide for how to reach for it.
Args:
name— this pass's identity in the ledger, and how the CLI names it.over— a model class, or aTablefor a table the ORM does not own.units— where the next range comes from.Rowstoday.frontier—Ceiling.at_launch()for a pass that finishes,Sealedto recur.work— what to do to a chunk.gate— the terminal verification, and the fact it publishes when it passes.pace— how much of the machine the pass may be. There is no "off".progress— what the percentage is measured against.Estimated()by default.on_chunk_failure—"halt"to stop at a hole,"skip"to walk past it.chunk_retries— attempts a chunk gets before it is dead-lettered. At least 1.shift— how long one stretch of work runs. Shorter than the lease;drivechecks.tenant— the ledger row's tenant, for a fleet that keeps them apart.schema— where the ledger table lives; match the job runner's.workload— must be"write"; a read pool opens read-only and a pass writes.rewrites— the column this pass overwrites in place, so a downgrade can refuse.
ChunkedPass(name: str, *, over: Any, units: Rows | Buckets, frontier: Ceiling | Sealed, work: _Work, gate: Gate | None = None, pace: DutyCycle | None = None, progress: Denominator | None = None, on_chunk_failure: str = 'halt', chunk_retries: int = 3, shift: Any = '10s', tenant: str = '', schema: str = 'wreath', workload: str = 'write', rewrites: str | None = None, query_budget: int | None = None) -> Nonealias (property)#
alias: strThe bare table name, used to qualify columns in a model predicate.
chunk_retries (property)#
chunk_retries: intAttempts a chunk gets before it becomes a hole. At least 1.
frontier (property)#
frontier: Ceiling | SealedHow far the walk goes: a fixed ceiling, or one re-derived each cycle.
gate (property)#
gate: Gate | NoneThe terminal verification, or None when this pass publishes no fact.
guards (property)#
guards: str | NoneThe fact this pass claims, or None if its gate publishes none.
Seeded into the ledger so a migration can ask what is still in flight.
holes#
async holes(database: Any) -> list[Hole]Chunks this pass gave up on, each with the statement that reproduces it.
ledger (property)#
ledger: AnyThe durable state this pass reads and advances. Owned by the driver.
model (property)#
model: AnyThe model class walked, or None when over= named a Table.
None is what makes a model predicate impossible for that pass: there is no class to check the columns against, so the filter has to be an explicit Sql fragment.
name (property)#
name: strThis pass's identity: its ledger row's key, and how the CLI names it.
on_chunk_failure (property)#
on_chunk_failure: str"halt" or "skip".
halt parks the cursor before the failing chunk and blocks the pass, so nothing after the hole runs. skip walks past it and bars the terminal gate until the hole is cleared. Both are recoverable only through retry.
pace (property)#
pace: DutyCycleHow much of the machine this pass may be. Never absent -- there is no "off".
progress (property)#
progress: DenominatorWhat the reported percentage is measured against.
Estimated unless another was declared, because a denominator that costs a count of the whole table is not a sensible default for a walk that exists because the table is large.
query_budget (property)#
query_budget: int | NoneQueries to one model allowed in a shift, or None to observe only.
Scoped to the shift rather than the chunk deliberately: an N+1 inside one chunk of a hundred rows is invisible against a per-chunk ceiling anyone would set, and it is the product -- queries per chunk times chunks per shift -- that turns a backfill into a six-hour outage.
recurring (property)#
recurring: boolWhether cycles repeat rather than the pass completing.
True for Sealed, whose frontier moves each cycle, and false for a fixed Ceiling. A whole-pass gate needs a completion to fire at, so it is refused on a recurring pass.
requeue#
async requeue(database: Any, unit: Any, *, after: Any = None) -> boolWalk one range again, out of order. The cursor never rewinds.
Two callers reach for this from opposite directions and get the same mechanism: a rollup folding a late correction into a bucket its cursor is already past, and an operator clearing a dead-lettered chunk. Rewinding the cursor instead would redo months of correct work to redo one range.
unit is the range's inclusive upper key and after its exclusive lower one, in the same shape key= was declared -- one value, or a tuple. Returns False when the unit is already queued, which makes calling it twice harmless.
retry#
async retry(database: Any) -> intSchedule every hole to be walked again. Returns how many.
Clearing the holes is the only thing that un-bars a terminal gate, and the clearing happens when the chunk succeeds rather than when it is queued -- so this schedules the work, it does not declare the problem solved.
It also lifts a blocked phase, and that half is not optional. halt parks the cursor before its hole and stops the pass; every later shift then sees a phase that is not walking and declines to run, so without lifting it the chunk is never re-attempted, the hole is never cleared, and the gate it bars is unreachable. halt would be a trap rather than a policy. skip never blocks, so there the queueing is the whole of it.
A halted pass therefore walks its parked chunk twice -- once as the queued unit and once as the range the cursor is still pointing at. That is deliberate rather than unnoticed: every declared work shape re-runs as a no-op, which is the property the primitive is built on, and paying for it once per operator retry is cheaper than teaching this method to compare encoded cursors.
A pass stopped at a failed verification is deliberately not restarted. That is not a transient failure to retry past; it means the walk's logic is wrong and the check will answer no again at the same row.
rewrites (property)#
rewrites: str | NoneThe column whose values this pass overwrites in place, if any.
Seeded into the ledger so a downgrade can refuse forever after. A pass that only reads, or that fills a column it added, leaves this None.
run#
async run(database: Any, *, stopping: Any = None, sleep: Any = None) -> ShiftResultDrive this pass to the end of its current cycle, in this process.
The chunking, the per-chunk transaction, and the pacing all still apply, so this is not the long transaction a pass exists to avoid -- but it does occupy the caller for as long as the walk takes. For anything that might run for minutes, hand it to wreath.jobs.JobRunner.drive instead and let it run in bounded shifts that survive a redeploy.
run_shift#
async run_shift(database: Any, *, stopping: Any = None, budget: float | None = None, sleep: Any = None) -> ShiftResultRun chunks until the shift budget, a stop signal, or the end of the walk.
schema (property)#
schema: strThe schema holding the ledger table. Must match the job runner's.
schema_sql#
schema_sql() -> strDDL for the shared ledger table. Apply it as a migration.
Nothing in Wreath runs this, for the same reason nothing runs wreath.jobs.JobRunner.schema_sql: a table that appears because a process started is a schema change with no history and no review.
shift (property)#
shift: floatSeconds of work per run_shift, as a float.
Declared as a duration string or a number and normalised here. Shorter than the job runner's lease, which is what keeps the runner from reclaiming a handler that is still working.
status#
async status(database: Any) -> PassStatus | NoneThis pass's durable status, or None when it has never run.
table (property)#
table: strThe table being walked, as it is written in a statement.
Already quoted and qualified where that was resolvable. A model on a logical (central or tenant) schema resolves to a bare quoted name and reaches its schema through search_path, because the schema mode is not known when a pass is declared.
tenant (property)#
tenant: strThe ledger row's tenant. "" for a fleet that does not separate them.
units (property)#
units: Rows | BucketsWhere the next range comes from.
work (property)#
work: _WorkWhat is done to each chunk.
workload (property)#
workload: strAlways "write". A read pool opens read-only, so a pass on one fails.
Constraint#
Verified by asking the database, in the terms it will hold you to.
NOT VALID is instant and checks nothing; VALIDATE CONSTRAINT scans under SHARE UPDATE EXCLUSIVE, which blocks neither reads nor writes, and names the offending row when it fails.
This is the form §10.3's concern cannot arise in. The verification and the thing that will enforce the invariant afterwards are the same predicate, so there is no way for the check to agree with a walk that was wrong -- and it is available only because the same tool emits the DDL. A bolt-on backfill library has to hand-write a SELECT and hope it matches the constraint somebody adds later.
The constraint is left in place on success, which is the point: the table goes on refusing what the pass just finished ruling out.
Constraint(name: str, check_: str) -> Nonecheck#
async check(executor: Any, *, walk: Any, scope: str | None = None) -> Verificationexplain#
explain(walk: Any) -> strsignature (property)#
signature: strDeclared#
A written reason that a callback is safe to run twice.
There is no default and no strict=False. Job delivery is at-least-once, so the question cannot be avoided -- it can only be answered, and being wrong on purpose should at least be legible to whoever reviews it:
Apply(reencrypt, idempotent=Declared(
"re-wrapping a key is idempotent: the row records the wrapping key id "
"and rows already carrying the new id are excluded by `where`"
))Declared(reason: str) -> NoneDenominator#
Where the number under the percentage comes from.
measure#
async measure(executor: Any, *, table: str, keys: tuple[Key, ...]) -> int | Nonerefuse#
refuse(keys: tuple[Key, ...], *, table: str) -> NoneReject a key this denominator cannot measure, where it was declared.
DutyCycle#
Let the pass have fraction of wall time, and sleep for the rest.
After a chunk that took d seconds, sleep d * (1/fraction - 1). At the default 0.25 that is three seconds of rest per second of work.
It needs no measurement to justify, which is the point of choosing it first: the bound is arithmetic rather than empirical, it degrades in the right direction (a chunk made slower by the pressure the pass is causing sleeps longer, so the pass's own share shrinks), and its worst case is that the walk takes four times as long -- a cost paid in a dimension nobody is holding a connection open for.
DutyCycle(fraction: float = 0.25) -> Nonereason (property)#
reason: strWhat the ledger records while this policy is holding the pass back.
rest_after#
rest_after(elapsed: float) -> floatSeconds to sleep after a chunk that took elapsed seconds.
Estimated#
pg_class.reltuples -- free, and stale exactly as often as ANALYZE is.
The default, and the right default: a pass exists because the table is big, and counting a big table before starting to walk it spends minutes making a progress bar prettier while the operator waits for the work.
Estimated() -> Nonemeasure#
async measure(executor: Any, *, table: str, keys: tuple[Key, ...]) -> int | Nonerefuse (inherited from Denominator)#
refuse(keys: tuple[Key, ...], *, table: str) -> NoneReject a key this denominator cannot measure, where it was declared.
Exact#
SELECT count(*) once, at launch. Never wrong, and never free.
Exact() -> Nonemeasure#
async measure(executor: Any, *, table: str, keys: tuple[Key, ...]) -> int | Nonerefuse (inherited from Denominator)#
refuse(keys: tuple[Key, ...], *, table: str) -> NoneReject a key this denominator cannot measure, where it was declared.
Gate#
Verify, publish the fact, and only then run whatever is irreversible.
Args:
verify—NoRowsMatch,ReconcileorConstraint.publishes— the name of the fact this pass establishes, written into the- ledger once verification passes. A later migration reads it -- see
published_facts-- which is how a deferred migration's- terminal step becomes permission for someone else's future step
- rather than a statement this pass runs.
then— an optional async callable run once, after the fact is published,- under a phase compare-and-swap. Called as ``then(executor, walk,
- unit)
, where *unit* isNone`` for a whole-pass gate and the - chunk's
(from, to)range for a per-unit one. scope—"pass"verifies the whole table once the walk completes;"unit"verifies each chunk as the walk passes it, for a- recurring pass where one bad bucket must not freeze the ladder.
scope is the one place in this design where a flag changes control flow rather than a value, and it is flagged in the design as the judgement call most wanting review. The defence is that the sequence, the verification grades, the publish and the opt-in-ness are identical in both; only the loop the sequence sits inside differs.
Gate(verify: Any, publishes: str | None = None, then: Any = None, scope: str = 'pass') -> NoneHole#
One chunk that failed often enough to be given up on.
Hole(name: str, tenant: str, cursor_from: Any, cursor_to: Any, attempts: int, error: str, predicate: str, failed_at: Any) -> Noneas_dict#
as_dict() -> dict[str, Any]Key#
One column of a pass's ordering key, with the facts the refusals need.
The ORM fills these in from the model's own declaration. For a table the ORM does not own -- one of Wreath's own store tables, or a legacy table -- the caller states them, which is the honest arrangement: the pass cannot read a catalog it has not been given, and a rule you can only ask for should still be asked for out loud.
Args:
name— the column. Interpolated into SQL, so it must be a plain identifier.type— its SQL type, used to decode a cursor read back out of the ledger.indexed— an index leads with this column. Only the first key column is- asked, because that is the one the range scan descends.
unique— the key columns up to and including this one identify a row.- For a single-column key that is the column alone; for
(stamp, id)it is set onid, because the pair is what the- boundary carries. A table whose primary key is itself composite
- sets it on the last of those columns -- `(retention_until,
- source, message_id)
marksmessage_id, because(source, - message_id)` is the key and the walk's boundary is the whole
- tuple. Setting it on a column that does not complete a unique
- constraint is the one declaration a pass cannot check and cannot
survive— the boundary silently skips the row's siblings.monotone— values are assigned in increasing order, so a row inserted- after a fixed ceiling was captured cannot land beneath it.
descending— the walk runs from high to low on this column.
Key(name: str, type: str = 'text', indexed: bool = False, unique: bool = False, monotone: bool = False, descending: bool = False) -> Noneis_clock (property)#
is_clock: boolWhether this column holds a database timestamp.
Keyspace#
How far the cursor has travelled between the smallest key and the ceiling.
Free, and the only one of the three that measures the walk rather than the table -- so it is unmoved by rows the work deletes. It lies when the key is sparse or unevenly distributed, which is most keys some of the time: half the id range is not half the rows unless the ids were handed out evenly.
It reports no ETA, and that is not an omission. The rate window counts rows, and rows are not the unit this measures, so remaining / rate would be a ratio of two different things dressed up as a time. §9.2's rule is that the field is absent and the state says why.
Keyspace() -> Nonemeasure#
async measure(executor: Any, *, table: str, keys: tuple[Key, ...]) -> int | Nonerefuse#
refuse(keys: tuple[Key, ...], *, table: str) -> NoneReject a key this denominator cannot measure, where it was declared.
NoRowsMatch#
Verified when this predicate matches no rows.
The plain form of "ask the table the question the irreversible step depends on": NoRowsMatch("grade_text IS NULL") before a migration narrows the column. It must not restate the walk's own where -- see refuse_reused_predicate -- because a walk whose predicate was subtly wrong would then verify its own bug and report success.
NoRowsMatch(where: str) -> Nonecheck#
async check(executor: Any, *, walk: Any, scope: str | None = None) -> Verificationexplain#
explain(walk: Any) -> strsignature (property)#
signature: strPassDeclarationError#
A pass that cannot be made correct, refused where it was declared.
Every rule this module enforces is a data-loss or a never-terminates bug in disguise, and every one of them is visible from the declaration. Raising here -- at import time, with the column name and the fix in the message -- costs a failed start; raising at 3am costs a table.
PassStatus#
One pass's durable status, read straight out of the ledger.
The ledger row is the durable status; a progress registry is live commentary. This is what the CLI, and anything else that has to decide something, reads -- an in-process registry that is bounded and TTL'd cannot answer a question about a pass that has been running for two hours.
progress carries the percentage with its provenance, the trailing rate, and either an ETA or the reason there is not one. A pass emits no phase records and never appears in the Flight Recorder -- attribution is bound per request through a ContextVar and a supervised background task has no request -- so this row is the whole picture, deliberately.
PassStatus(name: str, tenant: str, phase: str, cursor: Any, ceiling: Any, units_done: int, rows_done: int, chunk_limit: int, paced_reason: str | None, started_at: Any, last_advance: Any, cycle_started: Any, driven_at: Any, last_drive_error: str | None, verified_at: Any, verified_fact: str | None, last_error: str | None, progress: Progress, holes_open: int, pending: int, guards: str | None = None, trace_context: str | None = None) -> Noneas_dict#
as_dict() -> dict[str, Any]This status as JSON-safe primitives, for an API or the CLI.
Timestamps become strings and the Progress record is flattened in alongside the rest, so the result is one flat object rather than a nested one. gate_barred and the progress fields are derived here, so a consumer of this dictionary never has to recompute what gate_barred means.
gate_barred (property)#
gate_barred: boolWhether an irreversible terminal step may run for this pass.
Skipping a chunk buys throughput and never the irreversible step, so a pass with an open hole is barred until the hole is cleared -- which is what wreath passes retry does. The terminal gate itself is a later stage; this is the fact it will read, and it is true as soon as there is a hole to read it from.
state (property)#
state: strwalking | slow | stalled | blocked | done.
trace_id (property)#
trace_id: str | NoneThe 32-hex trace id out of the stored traceparent, or None.
What an operator pastes into a tracing UI, and what wreath doctor trace matches on. A malformed value reads as absent rather than raising: this is a forensic surface, and it must survive whatever is in the row.
PendingFact#
A fact some pass claims and has not yet established.
PendingFact(name: str, tenant: str, fact: str, phase: str, holes_open: int) -> NoneProgress#
How far along, how fast, how much longer -- and what is not knowable.
percent and denominator_kind are handed out together or not at all. eta_seconds is None whenever it cannot be computed honestly, and eta_absent then carries the sentence explaining which input was missing.
Progress(percent: float | None, denominator: int | None, denominator_kind: str | None, rows_per_second: float | None, eta_seconds: float | None, eta_absent: str | None, state: str, state_reason: str | None) -> Noneas_dict#
as_dict() -> dict[str, Any]PublishedFact#
Something a pass has verified to be true, and when.
The gate's durable output, and the reason a deferred migration does not have to trust a percentage: it reads this table and refuses to narrow a column whose pass has published nothing.
PublishedFact(name: str, tenant: str, fact: str, verified_at: Any) -> NonePurge#
Delete every row in the chunk. Idempotent by nature: a delete re-run is a no-op.
Purge(where: Any = None) -> Noneapply#
async apply(tx: Any, chunk: Chunk, binds: Any) -> intDelete one chunk's rows inside the chunk transaction.
Args:
tx— the chunk's transaction, which the cursor also advances in.chunk— the range being walked.binds— the statement's bind collector.
Returns:
- Rows deleted, read from the command tag, for the report.
writes (property, inherited from _Work)#
writes: tuple[str, ...]Columns this work assigns. A pass refuses to walk by one of them.
Reconcile#
Verified when two independent counts agree.
A real reconciliation of a materialised tier against a recount of the source -- cheap at coarse grain, and possible only before the source is removed, which is the only moment it will ever be possible.
Both arguments are scalar SQL. They are interpolated, so they are the declaration's own text and never a request value.
Reconcile(source: str, against: str) -> Nonecheck#
async check(executor: Any, *, walk: Any, scope: str | None = None) -> Verificationexplain#
explain(walk: Any) -> strsignature (property)#
signature: strRewrite#
Update every row in the chunk that still needs it.
Re-running the chunk is a no-op because where excludes the rows already converted -- the second run matches nothing. That same predicate is what a later stage's verification is deliberately not allowed to reuse.
Args:
set_—column -> SQL expression. Model columns or plain names, either way.where— which rows still need converting.
Rewrite(set_: Any, where: Any = None) -> Noneapply#
async apply(tx: Any, chunk: Chunk, binds: Any) -> intUpdate one chunk's rows inside the chunk transaction.
On a re-run the count is zero, because where no longer matches the rows already converted -- which is the property that makes the shape safe to repeat.
Returns:
- Rows actually updated, read from the command tag.
writes (property)#
writes: tuple[str, ...]The columns set_ assigns.
A pass refuses to walk by one of these: a key the work itself changes moves rows past the cursor, so they are processed twice or never.
Rows#
The next chunk is the next limit rows after the cursor, by key.
All key columns must be ordered the same way: a row comparison has no mixed-direction form, and expanding one into ORs costs the single index scan that makes a keyset walk cheap.
The chunk transaction sets its own statement_timeout from within, so a chunk that hits a lock wait dies as a chunk failure rather than as a transaction nobody notices.
Args:
key— one column or an ordered tuple. Model columns, orKeydeclarations.limit— how many rows one chunk covers.within— the chunk's time budget, as a duration string or seconds.
Rows(key: Any, limit: int = 1000, within: Any = '5s') -> Nonechunk_where#
chunk_where(binds: Any, *, cursor_from: tuple[Any, ...] | None, cursor_to: tuple[Any, ...], frontier: str | None) -> strThe WHERE clause selecting exactly one chunk's rows.
Open at the bottom and closed at the top, so consecutive chunks neither overlap nor leave a gap, and emitted as a single row comparison rather than the equivalent chain of ORs -- only the row comparison is reliably one index scan.
Args:
binds— the statement's bind collector. Values are appended, not interpolated.cursor_from— the exclusive lower key, orNonefor the first chunk.cursor_to— the inclusive upper key.frontier— the frontier predicate to AND in, when there is one.
next_range#
async next_range(executor: Any, *, walk: Any, cursor: tuple[Any, ...] | None, ceiling: Any, frontier_sql: Any) -> tuple[tuple[Any, ...] | None, tuple[Any, ...]] | None(start, end) for the next chunk, or None when the walk is done.
Two probes, and the second one is the point. The first asks for the limit-th key past the cursor; when fewer than a full chunk remain it answers nothing -- which is not the end of the walk, because a chunk is short whenever rows in its range were deleted or filtered out and the next key can sit well beyond it. So the second probe walks the same index from the far end. Completion is a probe, never an inference from a count.
The start is the cursor itself: a keyset chunk is open at the bottom, so the row the last chunk finished on is not seen twice.
refuse#
refuse(*, table: str) -> NoneRefuse a key a keyset walk cannot follow correctly.
Called once, when the pass is declared. A keyset walk needs a key that is unique, indexed on its leading column, and ordered one way throughout; each of those is a silent wrong answer rather than an error if it is missing. wreath._passes.keyset carries the arithmetic for why.
reproduce#
reproduce(*, table: str, cursor_from: tuple[Any, ...] | None, cursor_to: tuple[Any, ...]) -> strThe statement an operator pastes into psql to see the real error.
Recorded on a hole, with the keys as literals rather than binds, so diagnosing a dead-lettered chunk needs nothing but the ledger row.
Sealed#
A frontier re-derived every cycle: everything the clock has already passed.
A recurring pass has no completion -- a cycle completes, the frontier moves, and the next cycle starts from the beginning of the domain. That rewind is what makes this sound where a fixed ceiling would need the key to be assigned in order: a row that landed behind the cursor while a cycle ran is found by the next one.
after holds the frontier back from the present, for a caller that must not touch a row until it has settled. The default of zero -- everything already past -- is what an expiry purge wants.
The leading key column must be a timestamp, because that is the domain the database clock is measured in. Stage four of the design extends this same object to bucketed range sources; the arithmetic does not change.
Sealed(after: Any = 0.0) -> Nonederive#
async derive(executor: Any, *, table: str, keys: tuple[Key, ...]) -> AnyRead the frontier for this cycle: clock_timestamp() - after.
The database's clock, not the caller's, so workers on disagreeing wall clocks agree on where a cycle stops. Read once per cycle and bound for the whole of it -- see the comment below for why it cannot be inline.
predicate#
predicate(keys: tuple[Key, ...], ceiling: Any, binds: Any) -> strThe WHERE fragment meaning "the clock has already passed this row".
A comparison on the leading key column alone, not a row comparison: the frontier is a point on the clock, and later key columns exist to break ties within it rather than to bound it.
refuse#
refuse(keys: tuple[Key, ...], *, table: str) -> NoneRefuse a clock-derived frontier over a key that is not a timestamp.
Called once, when the pass is declared. clock_timestamp() produces a timestamp, so the leading key column has to be measured in the same domain for the comparison to mean anything.
ShiftResult#
What one shift did, and why it stopped.
ShiftResult(chunks: int = 0, rows: int = 0, complete: bool = False, stopped: str = 'complete', error: str | None = None, holes: int = 0) -> Noneshould_continue (property)#
should_continue: boolWhether another shift is worth enqueuing straight away.
Sql#
A SQL fragment and its bind values, for a table the ORM does not own.
Placeholders are written ? and renumbered when the fragment is spliced into a statement, because a fragment cannot know which $n it will land on:
Purge(where=Sql("state = ANY(?)", [["delivered", "failed"]]))Sql(text: str, values: tuple[Any, ...] = ()) -> NoneTable#
A table to walk that the ORM does not own.
Wreath's own store tables are the first callers here -- the idempotency ledger, the rate-limit buckets, the session rows, the webhook inbox and outbox -- and none of them is a model. Naming the table directly is honest about that, and it means a legacy table nobody has mapped is still walkable.
The facts the refusals need travel with the Key, not with the table, because that is where a reader is looking when they ask "is this key unique?".
Table(name: str, schema: str | None = None) -> Nonesql (property)#
sql: strThis table as it is written in a statement, quoted when qualified.
Both parts are checked as plain identifiers at construction, because the name is interpolated into statement text rather than bound -- a table name cannot be a parameter.
Verification#
What a verification answered, and whether the answer was final.
Verification(ok: bool, detail: str = '', transient: bool = False) -> Nonecolumn_fact#
column_fact(schema: str, table: str, column: str) -> strThe canonical name for "this column has finished converting".
One spelling, used by both sides of a contract that would otherwise be a convention: a pass declares Gate(publishes=column_fact(...)), and wreath.migrations refuses to narrow or drop that column until the fact is published. A free-form string on each side would agree right up until someone wrote public.treks.grade where the other expected treks.grade, and the failure would be a migration that sails through instead of one that refuses.
The column named is the one a later migration will narrow, not the one being filled. For a retype that drains grade into grade_next, the fact is about grade: that is the column whose drop has to wait.
pending_facts#
async pending_facts(database: Any, *, facts: tuple[str, ...], schema: str = 'wreath', workload: str = 'write') -> list[PendingFact]Which of facts a pass claims and has not yet established.
The half published_facts cannot answer. A migration about to narrow a column needs to tell "nothing guards this" from "something guards it and is still working", and an absent published fact means both. This reads the claim a pass records when its ledger row is seeded.
published_facts#
async published_facts(database: Any, *, schema: str = 'wreath', fact: str | None = None, workload: str = 'write') -> list[PublishedFact]Every fact a gate has verified in one ledger, or the passes claiming one.
The gate's durable output, readable with nothing but a connection and a schema. That is the point of it: the consumer is a migration deciding whether it may narrow a column, and it has no pass declaration in hand.
read_holes#
async read_holes(database: Any, *, schema: str = 'wreath', name: str | None = None, workload: str = 'write') -> list[Hole]Every dead-lettered chunk in one ledger, or one pass's.
read_status#
async read_status(database: Any, *, schema: str = 'wreath', name: str | None = None, workload: str = 'write') -> list[PassStatus]Every pass in one ledger, or one of them by name.
Reads the ledger without holding any declaration, which is what the CLI has: a database and a schema. Everything a reader needs is in the row.
schema_sql#
schema_sql(schema: str = 'wreath') -> strDDL for the shared pass ledger in schema. Apply it as a migration.