Skip to main content

capability

Type-safe composable primitives.

Python 3.13+ Types: pyright strict Dependencies: 0 License: MIT

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.singledispatch registers handlers externally, one function at a time, away from the data. Here the interpretations live on the value, and one fold runs many at once.
  • Pydantic's Annotated metadata is this move for a single target — validation. capability is the same, generalised to arbitrary phases with an algebra to compose them; from_annotated reads 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, explain it — which a closure encoding can't.

Correctness

  • zero dependencies, stdlib only;
  • src/ is pyright --strict clean; 100% branch coverage; the Compiler algebra and the fold laws are property-tested;
  • no getattr / hasattr in the dispatch code. To be exact about what by_protocol does at runtime: isinstance against a @runtime_checkable Protocol, which matches on method-name presence, not signatures — the typed lambda c, ctx: c.validate(ctx) is what buys the call-site check. Name-driven dispatch is opt-in, in capability.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

Metadata

Release files for capability 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for capability 1.0.0
File Size Uploaded Provenance Actions
capability-1.0.0.tar.gz 17.5 kB Without provenance Details for capability-1.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for capability 1.0.0
File Interpreter ABI Platform Actions
capability-1.0.0-py3-none-any.whl Python 3 none any Details for capability-1.0.0-py3-none-any.whl

Total release size: 34.0 kB

Release files / capability-1.0.0.tar.gz

Download URL capability-1.0.0.tar.gz
Size 17.5 kB
Tags Source
SHA-256 checksum
How to use checksums
e1c89ae5a17207d1102f98a5249a3961648e27a1701827e950ab17b163de6bcf
BLAKE2b-256 checksum
How to use checksums
fafc44f1d59179cf9ed2fe0a62b19ddfbc8732c5c118008266cb7828c1dceafa
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.8.1

Release files / capability-1.0.0-py3-none-any.whl

Download URL capability-1.0.0-py3-none-any.whl
Size 16.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
853f27fd678f18d034744ef0eac2944df66d90f173ad1cb3be46253291c02b4c
BLAKE2b-256 checksum
How to use checksums
76dd5edd27ca4afd32e0e80097aa155789ae516cae2922d6ea7eea166050ba73
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.8.1

Security for capability 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page