Skip to main content

Type-safe composable primitives.

Project description

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

capability-1.0.0.tar.gz (17.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

capability-1.0.0-py3-none-any.whl (16.5 kB view details)

Uploaded Python 3

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

Hashes for capability-1.0.0.tar.gz
Algorithm Hash digest
SHA256 e1c89ae5a17207d1102f98a5249a3961648e27a1701827e950ab17b163de6bcf
MD5 8bda6ab235831c818a7373c9a3bfc673
BLAKE2b-256 fafc44f1d59179cf9ed2fe0a62b19ddfbc8732c5c118008266cb7828c1dceafa

See more details on using hashes here.

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

Hashes for capability-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 853f27fd678f18d034744ef0eac2944df66d90f173ad1cb3be46253291c02b4c
MD5 347e702e2a5cf242930e5b00bf7de7dc
BLAKE2b-256 76dd5edd27ca4afd32e0e80097aa155789ae516cae2922d6ea7eea166050ba73

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page