A tour of the core¶
Everything in pipekit, group by group: what it is, when to reach for it,
and what it does today.
The core is pure Python with no dependencies. Its public surface is about fifty names in thirteen modules, one module per job, so you can learn it a group at a time. For why it is shaped this way (operators as classes, the carrier left open), read the background; for the shapes real pipelines take, read Patterns.
Every output on this page is from a real run of pipekit 0.0.2.
The map¶
| Group | Module | Names | Reach for it when |
|---|---|---|---|
| Foundations | _base/ |
Operator, ConfigMixin, Sequential, Graph, Input, Node |
always: writing a step, chaining steps, wiring a graph |
| Composition | compose |
pipe, compose, compose_left, juxt, complement |
you think in toolz |
| Building blocks | blocks |
Identity, Const, Lambda, Sink |
a no-op, a constant, a quick function, a write |
| Control flow | control |
Branch, Switch, Try, Coalesce, Retry |
a decision or a failure inside the pipeline |
| Observers | observe |
Tap, Snapshot, ShapeTrace, Profile, Histogram |
looking without changing the flow |
| Combination | combine |
Fanout |
one input, several named outputs |
| Caching | cache |
Cache, Memoize |
the same expensive call twice |
| Quality control | qc |
Quarantine, AssertShape, AssertDType, AssertHasAttribute, AssertCallable, QCError |
a guard that fails loudly, or sets bad inputs aside |
| Shape inference | signature |
Signature, compute_output_signature |
catching a shape mismatch before running |
| Parallelism | parallel |
ThreadMap, ProcessMap, AsyncMap, BatchedMap, check_pickleable |
many items, one operator |
| Serialisation | serial |
dumps, loads, loads_sandboxed, from_state_sandboxed, register, unregister, allowed |
configuration as data; untrusted pipelines |
| Hashing | hashing |
pipeline_hash |
one key for "the same pipeline again": a registry, a cache, an orchestrator task |
| State | state |
StatefulOperator, CarryState |
state carried across calls: time steps, training, streams |
| Protocols | protocols |
Predictor, FittableTransformer |
accepting a model without importing its library |
The sister packages build on this core: time stepping and data assimilation
in pipekit-cycle, training in pipekit-train, the model registry in
pipekit-experiment, benchmarks in pipekit-evaluate, Array API dispatch in
pipekit-array, JAX models in pipekit-jax.
The examples share one small operator:
from pipekit import Operator
class Scale(Operator):
"""x ↦ a · x."""
def __init__(self, factor: float) -> None:
self.factor = factor # stored under its argument's name → its config
def _apply(self, x):
return x * self.factor
Foundations¶
Operator¶
The class every step subclasses. You write two things: a constructor that
stores its arguments under the same names, and _apply. The base class
supplies the rest:
- configuration:
get_config()is derived from the constructor's signature (ConfigMixin), andstateis a JSON-safe record of the class and its config; - rebuilding:
Operator.from_state(state)finds the class among every imported subclass and calls it with the config; - the pipe:
a | bis aSequential, flattened; - two modes in one
__call__: on data it runs_apply; on a graph placeholder (InputorNode) it records a node instead; - class flags:
forbid_in_yaml(holds a closure, so its config cannot be rebuilt) and_terminal(may only end aSequential).
import pipekit as pk
Scale(2.0).state
# → {'module': '__main__', 'class': 'Scale', 'config': {'factor': 2.0}}
pk.loads(pk.dumps(Scale(2.0))) # → Scale(factor=2.0)
Sequential¶
Apply operators left to right. It checks at construction that every item is
an operator and that a _terminal one comes last; | flattens, so
(a | b) | c and a | (b | c) are the same three steps. It has len,
indexing and slicing, a Keras-style summary(signature) and a describe()
tree, and the empty Sequential is the identity.
from pipekit import Sequential, Signature
pipe = Scale(0.5) | Scale(2.0) | Scale(3.0)
pipe(10.0) # → 30.0
len(pipe) # → 3
pipe[1:] # → Sequential([Scale(factor=2.0), Scale(factor=3.0)])
Sequential([])(7) # → 7
print(pipe.summary(Signature(dims=(("y", 4), ("x", 4)), dtype="float32")))
# Operator Input sig Output sig
# -----------------------------------------------------------------------
# [0] Scale Signature((y=4, x=4) float32) Signature((y=4, x=4) float32)
# [1] Scale Signature((y=4, x=4) float32) Signature((y=4, x=4) float32)
# [2] Scale Signature((y=4, x=4) float32) Signature((y=4, x=4) float32)
Graph, Input and Node¶
For flows that branch and merge, with several inputs and named outputs.
Calling operators on Inputs (and on what those calls return) records the
graph; Graph(inputs=…, outputs=…) wraps it, rejects cycles, and evaluates
in topological order. Inputs bind by keyword or by position; outputs come
back as a dict.
from pipekit import Graph, Input, Lambda
a, b = Input("a"), Input("b")
s = Scale(2.0)(a) # s = 2a
total = Lambda(lambda u, v: u + v, name="add")(s, b) # s + b
g = Graph(inputs={"a": a, "b": b}, outputs={"s": s, "sum": total})
g(a=1.0, b=10.0) # → {'s': 2.0, 'sum': 12.0}
g(1.0, 10.0) # → the same; positional arguments bind in declaration order
A single-input graph composes into a Sequential
((Scale(2.0) | g1)(1.0) → {'y': 6.0} for a graph g1 with output
y = 3x). The bigger-graph tutorial builds a
full one.
Composition¶
Free functions that carry the toolz surface. They are not operators
themselves (except what they return). Below, inc = Lambda(lambda v: v + 1).
| Function | toolz ancestor |
What it does | Example |
|---|---|---|---|
pipe(value, *ops) |
toolz.pipe |
Sequential(ops)(value) |
|
compose(*ops) |
toolz.compose |
right to left: compose(f, g, h) is Sequential([h, g, f]) |
compose(Scale(2.0), inc)(3.0) → 8.0 |
compose_left(*ops) |
toolz.compose_left |
left to right, like Sequential |
compose_left(Scale(2.0), inc)(3.0) → 7.0 |
juxt(*ops) |
toolz.juxt |
each operator on the same input; a tuple | juxt(Identity(), Scale(2.0))(5.0) → (5.0, 10.0) |
complement(pred) |
toolz.complement |
negates a predicate | complement(str.isdigit)("a") → True |
juxt returns a plain function, so it cannot sit inside a Sequential;
Fanout is the operator-shaped version.
Building blocks¶
| Operator | Purpose | Holds a closure |
|---|---|---|
Identity() |
an explicit no-op; the default branch of Branch and Switch |
no |
Const(value) |
return a fixed value, whatever the input | no |
Lambda(fn, name=…) |
wrap any callable; the escape hatch | yes |
Sink(write_fn, name=…) |
write as a side effect, return the input unchanged | yes |
Sink returns its input, so a pipeline can continue past it. An operator that
really ends a pipeline (writes and returns nothing) sets _terminal = True.
Control flow¶
Decisions and failures as steps inside the pipeline, rather than if and
try around it.
| Operator | Purpose |
|---|---|
Branch(predicate, if_true, if_false=Identity()) |
a binary choice |
Switch(key, cases, default=Identity()) |
dispatch on key(carrier) |
Try(primary, fallback, on) |
run fallback when primary raises one of on |
Coalesce(sources, is_ok, on=()) |
the first source whose output passes is_ok |
Retry(op, attempts, base_delay, on) |
retry on on; attempt i waits base_delay · 2^(i−1) |
from pipekit import Coalesce, Const, Lambda, Retry, Try
def down(_):
raise IOError("down")
Try(Lambda(down), Const(0.0), on=(IOError,))(1.0) # → 0.0
Coalesce(
[Lambda(down), Const(-1), Const(5)],
is_ok=lambda r: r > 0, # -1 fails the check
on=(IOError,), # down() falls through
)(0) # → 5
Retry(flaky, attempts=5, base_delay=0.0, on=(ConnectionError,))(42)
# → 42, where flaky is an operator that raises ConnectionError twice, then succeeds
Try and Retry require their exception list, so neither is ever a silent
catch-all; when every source fails, Coalesce raises RuntimeError. The
ETL tutorial nests all three around a reader.
Observers¶
The carrier flows through unchanged; something useful happens on the side.
| Name | Purpose |
|---|---|
Tap(fn, name=…) |
call fn on the carrier; return the carrier |
Snapshot() + .at(key) |
capture intermediates by name in snapshot.captures |
ShapeTrace(printer=print, mode="every" \| "diff_only", label=None, extra=None) |
log shape and dtype at each step; extra(carrier) adds a line (a coordinate system, units) |
Profile() + .wrap(op) |
time each call; profile.report() gives calls, total and mean per operator |
Histogram(bins=10, to_array=list) + .at(key) |
capture distributions; to_array turns the carrier into numbers |
Snapshot, Profile and Histogram are controllers, not operators: the
operators you place in the pipeline come from .at() and .wrap(), and the
data they collect lives on the controller.
from pipekit import Histogram, Lambda, Profile, Snapshot
snap, prof, hist = Snapshot(), Profile(), Histogram(bins=2)
pipe = Lambda(lambda v: [v, 2 * v], name="pair") | snap.at("mid") | hist.at("out")
pipe(1.0) # → [1.0, 2.0]
snap.captures # → {'mid': [1.0, 2.0]}
prof.wrap(Scale(3.0))(2.0) # → 6.0; prof.report() → {'Scale': {'calls': 1.0, ...}}
Combination¶
Fanout runs several operators on the same input and returns a dict of
their outputs:
from pipekit import Fanout, Lambda
Fanout({"n": Lambda(len), "u": Lambda(str.upper)})("ab") # → {'n': 2, 'u': 'AB'}
Caching¶
Cache(inner) (alias Memoize) memoises an operator in memory, keyed by a
SHA-256 of the input and the inner operator's configuration. Arrays are keyed
by their bytes, dtype and shape, so two large arrays never collide, and the
bookkeeping is thread-safe, so one Cache can be shared by ThreadMap
workers.
from pipekit import Cache, Memoize
c = Cache(Scale(2.0))
c(1.0); c(1.0); c(2.0)
c.hits, c.misses # → (1, 2)
Memoize is Cache # → True
Quality control¶
Assertions as pass-through operators: a research-time "this won't break" becomes a permanent guard.
| Operator | Purpose | Holds a closure |
|---|---|---|
Quarantine(check, sentinel, on_quarantine=None) |
on failure call on_quarantine and return sentinel; never raises |
yes |
AssertShape(expected_shape) |
raise QCError on a shape mismatch; None matches any size |
no |
AssertDType(expected_dtype) |
raise QCError on a dtype mismatch |
no |
AssertHasAttribute(name, value=…) |
raise QCError if an attribute is missing or wrong |
no |
AssertCallable(predicate, message=…) |
raise QCError if a predicate fails |
yes |
AssertShape((2, 4))(x) # x.shape == (2, 3)
# QCError: AssertShape: axis 1 expected 4, got 3 (expected=(2, 4), actual=(2, 3)).
The checks read shape, dtype and attributes and never look inside the
data, so they work on any carrier. QCError subclasses AssertionError.
Quarantine is the batch-friendly one: the
inference and ETL
tutorials use it to set bad records aside and keep going.
Shape inference¶
Signature is an immutable record of named dimensions and a dtype; a size of
None marks a dimension dynamic. An operator declares how it changes a
signature by overriding compute_output_signature; returning None means
"this operator does not track shape", and Sequential.summary shows ? for
it.
from pipekit import Signature
sig = Signature(dims=(("time", 12), ("y", 256), ("x", 256)), dtype="float32")
sig.format() # → '(time=12, y=256, x=256) float32'
sig.drop_dims(("time",)) # → Signature((y=256, x=256) float32)
sig.replace_dims({"x": 128}) # → Signature((time=12, y=256, x=128) float32)
Containers report what they return:
- Cache, Retry and Profile.wrap report their inner operator's signature.
- Try, Branch, Switch and Coalesce report the signature their
branches agree on, else None.
- Fanout and the parallel maps report None, because they return a dict
or a list.
A Graph propagates signatures node by node: output_signatures gives
each output's, and summary prints the table. A node with several inputs
gets its operator's signature only when the inputs agree; otherwise it shows
?:
from pipekit import Graph, Input, Operator
class MeanOver(Operator):
"""Mean over one named dimension: drops it from the signature."""
def __init__(self, dim: str) -> None:
self.dim = dim
def _apply(self, x):
return x.mean(axis=0)
def compute_output_signature(self, signature):
return None if signature is None else signature.drop_dims((self.dim,))
x = Input("x")
g = Graph(inputs={"x": x}, outputs={"climatology": MeanOver("time")(x)})
print(g.summary(sig))
# Node Input sig Output sig
# ----------------------------------------------------------------
# [0] MeanOver(x) (time=12, y=256, x=256) float32 (y=256, x=256) float32
# → climatology = [0] (y=256, x=256) float32
The payoff grows as the operators you write (and domain libraries) declare theirs.
Parallelism¶
Four shapes, one wrapper each. Every wrapper takes an iterable and returns a list in input order.
| Shape | When | Constraint | Operator |
|---|---|---|---|
| Threads | I/O-bound: object-store reads, downloads | the I/O must release the GIL | ThreadMap(op, n_workers=8) |
| Processes | CPU-bound work | every step must pickle | ProcessMap(op, n_workers=8, on_error="raise" \| "log_and_continue") |
| Async | I/O on one thread: web services | async def _apply, or a sync operator run in a thread |
AsyncMap(op, semaphore=8) |
| Batches | GPU inference, vectorised code | the inner operator takes a list of items | BatchedMap(op, batch_size=8, flatten=True) |
from pipekit import AsyncMap, BatchedMap, Lambda, ThreadMap
ThreadMap(Scale(2.0), n_workers=2)([1, 2, 3]) # → [2.0, 4.0, 6.0]
add_up = Lambda(lambda b: [sum(b)], name="sum")
BatchedMap(add_up, batch_size=2)([1, 2, 3, 4, 5]) # → [3, 7, 5]
AsyncMap(Scale(2.0), semaphore=2)([1, 2]) # → [2.0, 4.0]
Pickling. Process pools need every operator to pickle, and closures
(Lambda, Tap, Sink, a Branch predicate), open handles and captured
random generators do not. Operators that hold a closure carry
forbid_in_yaml = True, and check_pickleable(op) walks a whole pipeline,
however deeply nested, and returns the flagged operators before you fan out:
from pipekit import Branch, Cache, Identity, Tap, ThreadMap, check_pickleable
pipe = ThreadMap(Cache(Scale(2.0) | Tap(print) | Branch(bool, Identity())))
[type(op).__name__ for op in check_pickleable(pipe)] # → ['Tap', 'Branch']
Async. AsyncMap(...)(items) drives its own event loop, so it works in
scripts. Inside a running loop (a FastAPI or LitServe handler) it raises a
RuntimeError that says to await its run method instead:
Deliberately left out. No automatic parallelism (Sequential is
sequential; Graph evaluates in one process), no backend abstraction (the
wrappers are concurrent.futures and asyncio), no fault tolerance beyond
on_error="log_and_continue", and no distributed execution: Dask, Ray, Beam
and orchestrators schedule pipekit operators; pipekit keeps them pickleable so
they can.
Serialisation¶
| Name | What it does |
|---|---|
dumps(op) |
JSON-encode op.state, keys sorted, nested operators included |
loads(s) |
rebuild the whole tree through Operator.from_state; the defining modules must be imported |
register(cls) |
add a class to the sandbox allowlist (also a class decorator) |
unregister(cls), allowed() |
remove from, and list, the allowlist |
loads_sandboxed(s) |
loads, but PermissionError for any class in the tree that is not registered |
from_state_sandboxed(state) |
the same check on a record already in memory |
The sandboxed loader is for pipelines you did not write (uploaded by users, read from a queue): only classes you registered can be built, at any depth, and every class is checked before anything is constructed. Nothing is allowed by default, pipekit's own containers included.
import pipekit as pk
pk.dumps(pk.Const(3))
# → '{"class": "Const", "config": {"value": 3}, "module": "pipekit.blocks"}'
pk.loads(pk.dumps(Scale(2.0))) # → Scale(factor=2.0)
pk.loads_sandboxed(pk.dumps(Scale(2.0))) # PermissionError: not registered
pk.register(Scale)
pk.loads_sandboxed(pk.dumps(Scale(2.0))) # → Scale(factor=2.0)
pk.loads(pk.dumps(pk.Lambda(abs))) # RuntimeError: marked forbid_in_yaml
Nested pipelines round-trip too. A child operator appears in its parent's
config as its own state record, a Graph records every node and its
wiring, and an exception class (Try.on, Retry.on) is written as
{"exception": "builtins.KeyError"}. pipeline_hash hashes that whole
record, so two pipelines share a hash exactly when they are configured the
same:
from pipekit import Cache, Retry, Sequential, ThreadMap, pipeline_hash
pipe = ThreadMap(Cache(Retry(Scale(2.0), attempts=3, base_delay=0.5, on=(OSError,))))
back = pk.loads(pk.dumps(pipe))
back.state == pipe.state # → True
pipeline_hash(back) == pipeline_hash(pipe) # → True
pk.register(Sequential)
pk.loads_sandboxed(pk.dumps(Sequential([Scale(2.0), pk.Identity()])))
# PermissionError: '__main__.Scale' is not in the allowlist (checked first)
pipekit-experiment's HydraConfigLoader does the same with _target_
YAML: to_yaml(op) writes any pipeline, from_yaml loads it through
Hydra, and from_yaml_sandboxed loads untrusted YAML through the allowlist
without ever calling Hydra's instantiate.
State¶
Most operators are stateless: _apply(carrier) → carrier. Some work carries
state from call to call: a data-assimilation cycle (forecast, analysis,
forecast…), a training loop (optimiser state, step, metrics), a streaming
statistic, a recurrent model. A StatefulOperator implements
_apply(carrier, state) → (carrier, state), and a CarryState is the base
for that state, with to_dict / from_dict for checkpointing. A
Sequential that contains a stateful step threads the state through and
returns (carrier, state); stateless steps pass it along untouched.
A streaming mean: after values x₁, …, xₙ the state is the running sum Sₙ = ∑ᵢ xᵢ = Sₙ₋₁ + xₙ and the count n, and the step emits x̄ₙ = Sₙ / n.
from pipekit import CarryState, StatefulOperator
class MeanState(CarryState):
def __init__(self, total: float, n: int) -> None:
self.total = total # Sₙ = ∑ᵢ xᵢ
self.n = n # n
class StreamMean(StatefulOperator):
def _apply(self, x, state):
st = MeanState(state.total + x, state.n + 1) # Sₙ = Sₙ₋₁ + xₙ; n ← n + 1
return st.total / st.n, st # x̄ₙ = Sₙ / n
pipe = Scale(1.0) | StreamMean() # a stateless step, then a stateful one
x_bar, st = pipe(4.0, state=MeanState(0.0, 0)) # x̄₁ = 4.0
x_bar, st = pipe(8.0, state=st) # x̄₂ = 6.0
st # → MeanState(total=12.0, n=2)
MeanState.from_dict(st.to_dict()) == st # → True
pipe(4.0) # TypeError: ... called without a `state` argument
A Graph threads state the same way. Call it with state= and it returns
(outputs, state); the state passes through its stateful nodes in
topological order (the order describe() lists):
from pipekit import Graph, Input
x = Input("x")
g = Graph(inputs={"x": x}, outputs={"mean": StreamMean()(Scale(1.0)(x))})
out, st = g(x=4.0, state=MeanState(0.0, 0)) # out == {"mean": 4.0}
out, st = g(x=8.0, state=st) # out == {"mean": 6.0}
pipekit-cycle's Cycle, DACycle and EnsembleDACycle and
pipekit-train's TrainingLoop are built on these two classes; see the
data-assimilation and
training tutorials.
Protocols¶
Model wrappers in the sister libraries rely on two duck-typed seams, written
down once as runtime_checkable Protocols: Predictor (anything with
predict(x)) and FittableTransformer (anything with fit(x) and
transform(x)). isinstance(model, Predictor) checks that the methods exist,
not their signatures, so a scikit-learn estimator, a JAX model wrapper or your
own class all qualify without importing pipekit. The cycle and experiment
packages define more such seams (ForwardModel, AnalysisStep,
ModelRegistry, …); see Protocols.
Limits today¶
The limits this page used to list have been fixed:
- nested pipelines round-trip;
- a Graph's state records every node, threads carry state and has a
summary;
- containers report the signature of what they return;
- CarryState is consistently unhashable;
- ProcessMap reports failures through logging;
- AsyncMap explains itself inside a running loop;
- ShapeTrace takes an extra line.
The design questions still open are tracked in the GeoStack design reports and pipekit's issues.