Skip to content

prostomarkeloff/capability

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

About

Type-safe composable primitives.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages