Type-safe composable primitives.
Project description
Every time you turn a structure into outputs — validate it, document it, compile it to SQL, lint
it — you hand-write the same machinery: an if isinstance(...) ladder, a visitor class, a registry
keyed by type. It is bespoke each time. It does not compose — you can't add a case from outside, or
run two passes in one traversal, without rewiring it. And the type checker can't see through the
dispatch, so a missing case is a runtime surprise, not a red squiggle.
A stateless coding agent makes it sharper: it re-derives that machinery from a few thousand lines of context, gets one case wrong, and the dispatch hides the gap.
capability is the handful of primitives that machinery reduces to: a fold over
self-describing values, typed end to end. The values carry their own behaviour; a fold runs
them; and they compose — a new value, a new pass, a new target are each an addition, never an edit
to the core.
uv add capability # zero dependencies, stdlib only
The primitives
A capability is a frozen value that carries its own contribution to a typed context. A fold
threads a context through a sequence of them, dispatching to each by an isinstance check against a
protocol — a value that doesn't speak a target's protocol is skipped. Three targets here: a
request-time validator, a database column, a block of help text.
from dataclasses import dataclass, replace
from typing import Protocol, runtime_checkable
from capability import Phase, by_protocol
@dataclass(frozen=True, slots=True)
class Rule: # a request-time validator
max_len: int | None = None
@runtime_checkable
class Validated(Protocol):
def validate(self, ctx: Rule) -> Rule: ...
@dataclass(frozen=True, slots=True)
class Ddl: # a database column
column: str = "TEXT"
@runtime_checkable
class Stored(Protocol):
def ddl(self, ctx: Ddl) -> Ddl: ...
@dataclass(frozen=True, slots=True)
class Doc: # help lines, accumulated
lines: tuple[str, ...] = ()
@runtime_checkable
class Documented(Protocol):
def doc(self, ctx: Doc) -> Doc: ...
# Each interpretation reads the context the previous fact left, and extends it.
@dataclass(frozen=True, slots=True)
class MaxLen:
n: int
def validate(self, ctx: Rule) -> Rule:
return replace(ctx, max_len=self.n)
def ddl(self, ctx: Ddl) -> Ddl:
return replace(ctx, column=f"VARCHAR({self.n})")
def doc(self, ctx: Doc) -> Doc:
return replace(ctx, lines=(*ctx.lines, f"at most {self.n} characters"))
@dataclass(frozen=True, slots=True)
class Unique:
def ddl(self, ctx: Ddl) -> Ddl:
return replace(ctx, column=f"{ctx.column} UNIQUE")
def doc(self, ctx: Doc) -> Doc:
return replace(ctx, lines=(*ctx.lines, "must be unique"))
validate = Phase(Rule, by_protocol(Validated, lambda c, ctx: c.validate(ctx)))
store = Phase(Ddl, by_protocol(Stored, lambda c, ctx: c.ddl(ctx)))
document = Phase(Doc, by_protocol(Documented, lambda c, ctx: c.doc(ctx)))
field = (MaxLen(255), Unique())
validate.run(field) # Rule(max_len=255) — Unique has no validate, skipped
store.run(field) # Ddl(column='VARCHAR(255) UNIQUE') — both facts fold in
document.run(field) # Doc(lines=('at most 255 characters', 'must be unique'))
Add a fact (MinLen, Indexed): it implements the protocols it has, the rest skip it. Add a
target: a new Phase, no fact changes. Every piece is a small typed value, and nothing in the core
enumerates them.
It does not remove the work — MaxLen still spells out validate, ddl, doc by hand. It removes
the machinery: no visitor, no registry, no dispatch ladder — and the typed apply keeps each call
honest under pyright --strict.
Compose them
Phases compose into a Compiler — a keyed set with set-like operators that folds every phase over
the facts in a single pass:
from capability import Compiler
schema = Compiler((validate, store, document)) # a composable set of phases
bundle = schema.run(field) # one traversal, every phase at once
bundle.get(store) # Ddl(column='VARCHAR(255) UNIQUE')
bundle.get(document) # Doc(lines=('at most 255 characters', 'must be unique'))
# compilers compose like sets: a + b (union) a - b (restrict) a & b (intersect) a | b (override)
The operators form an idempotent semilattice (A + A == A), so composing compilers is predictable;
running one fuses every phase into a single traversal (banana-split). Composition is the point —
primitives into phases, phases into compilers, compilers into each other. Bundle.get is typed: it
hands back exactly the phase's own context type.
The whole engine
The core primitive, with the typing stripped, is a left fold:
def fold(items, initial, step):
ctx = initial
for item in items:
ctx = step(item, ctx)
return ctx
fold names no target — the meaning is all in step, which you build with by_protocol /
by_table (you don't hand fold a bare lambda; a Step carries the policy and lets fold pick
the sync or async path). A new target is a new step, not an edit. The package is nine small
files, ~370 lines, zero dependencies.
The contexts, protocols, and targets are yours. The core ships fold and the dispatch
policies; what they compute to is your code.
Layers
Everything past fold + a Step is a layer you can ignore until you want it.
| Import | What it gives you |
|---|---|
fold, Step |
the primitive: a typed left fold + the dispatch-policy wrapper |
by_protocol |
open on the data axis — a new fact that implements the protocol is picked up |
by_table |
open on the interpreter axis — dispatch by exact type, a per-target handler table |
chain |
run several dispatch policies in one pass |
Phase |
a reified target — a context factory + its step; Phase.run(items) |
Compiler, Bundle |
compose phases (+ - & |) and run them in one pass; Bundle.get(phase) returns that phase's typed context |
fold_tree |
the catamorphism for recursive (tree) descriptions |
rfold, scan |
a right fold; a left scan that keeps every intermediate context |
traced_fold, explain |
record each step and render it — free, because the data is immutable |
from_annotated, from_sidecar |
read facts off Annotated[T, ...] metadata or a class sidecar |
afold |
the async overload — an async def apply makes fold return a coroutine |
capability.reflect.by_method |
opt-in name-driven dispatch |
You've met this before
capability is not a new idea — it's a known one, generalised and kept as small typed data:
functools.singledispatchregisters handlers externally, one function at a time, away from the data. Here the interpretations live on the value, and onefoldruns many at once.- Pydantic's
Annotatedmetadata is this move for a single target — validation.capabilityis the same, generalised to arbitrary phases with an algebra to compose them;from_annotatedreads exactly that metadata. - Object algebras / tagless-final are the typed-FP relatives. This is the data encoding, so
the program stays inspectable — you can print it, diff it,
explainit — which a closure encoding can't.
Correctness
- zero dependencies, stdlib only;
src/ispyright --strictclean; 100% branch coverage; theCompileralgebra and the fold laws are property-tested;- no
getattr/hasattrin the dispatch code. To be exact about whatby_protocoldoes at runtime:isinstanceagainst a@runtime_checkableProtocol, which matches on method-name presence, not signatures — the typedlambda c, ctx: c.validate(ctx)is what buys the call-site check. Name-driven dispatch is opt-in, incapability.reflect.
Lineage
A capability is Reynolds' (1972) defunctionalised closure — a decision turned from code into an
inspectable record. The hot path is a left fold; the catamorphism it instances (Meijer–Fokkinga–
Paterson 1991, after Malcolm 1990) is fold_tree / rfold, for recursive shapes. And it keeps
Wadler's Expression Problem open on both axes.
Small, typed, composable — the rest is yours.
Made with 🧬 by @prostomarkeloff
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file capability-1.0.0.tar.gz.
File metadata
- Download URL: capability-1.0.0.tar.gz
- Upload date:
- Size: 17.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e1c89ae5a17207d1102f98a5249a3961648e27a1701827e950ab17b163de6bcf
|
|
| MD5 |
8bda6ab235831c818a7373c9a3bfc673
|
|
| BLAKE2b-256 |
fafc44f1d59179cf9ed2fe0a62b19ddfbc8732c5c118008266cb7828c1dceafa
|
File details
Details for the file capability-1.0.0-py3-none-any.whl.
File metadata
- Download URL: capability-1.0.0-py3-none-any.whl
- Upload date:
- Size: 16.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
853f27fd678f18d034744ef0eac2944df66d90f173ad1cb3be46253291c02b4c
|
|
| MD5 |
347e702e2a5cf242930e5b00bf7de7dc
|
|
| BLAKE2b-256 |
76dd5edd27ca4afd32e0e80097aa155789ae516cae2922d6ea7eea166050ba73
|