# Building Custom Steps This guide covers everything you need to create your own pipeline steps — from the minimal contract to advanced patterns like dependency injection, async execution, and testing. --- ## The step contract Any Python object with `requires`, `provides`, and `__call__` is a valid step. No base class needed. ```python from types import MappingProxyType from pipeline import StepContext class MyStep: requires = frozenset({"input_field"}) provides = frozenset({"output_field"}) def __call__(self, ctx: StepContext) -> StepContext: result = process(ctx.metadata["input_field"]) return ctx.replace( metadata=MappingProxyType({**ctx.metadata, "output_field": result}) ) ``` Rules: - `requires` and `provides` can be `set` or `frozenset` — the pipeline normalizes to `frozenset` - `__call__` receives a `StepContext` and must return a `StepContext` - Never mutate the incoming context — always use `.replace()` --- ## Sync vs async steps === "Sync" ```python class ComputeStep: requires = frozenset({"data"}) provides = frozenset({"result"}) def __call__(self, ctx: StepContext) -> StepContext: result = expensive_computation(ctx.metadata["data"]) return ctx.replace( metadata=MappingProxyType({**ctx.metadata, "result": result}) ) ``` === "Async" ```python class FetchStep: requires = frozenset({"url"}) provides = frozenset({"response"}) async def __call__(self, ctx: StepContext) -> StepContext: async with aiohttp.ClientSession() as session: resp = await session.get(ctx.metadata["url"]) data = await resp.json() return ctx.replace( metadata=MappingProxyType({**ctx.metadata, "response": data}) ) ``` Use async steps for I/O-bound work (HTTP requests, API calls, file I/O). The pipeline detects and handles both transparently. --- ## Dependency injection Steps that need external collaborators receive them via `__init__`. The `__call__` method stays stateless — it only uses `self.*` for injected dependencies and `ctx` for data. ```python class ScoringStep: requires = frozenset({"predictions"}) provides = frozenset({"scores"}) def __init__(self, scorer, threshold: float = 0.5): self.scorer = scorer self.threshold = threshold def __call__(self, ctx: StepContext) -> StepContext: raw_scores = self.scorer.evaluate(ctx.metadata["predictions"]) filtered = {k: v for k, v in raw_scores.items() if v >= self.threshold} return ctx.replace( metadata=MappingProxyType({**ctx.metadata, "scores": filtered}) ) ``` This makes testing easy — inject mocks: ```python pipe = Pipeline().then(ScoringStep(scorer=mock_scorer, threshold=0.8)) ``` --- ## Declaring concurrency Two optional class attributes control how a step participates in concurrent execution: ### `async_boundary` Marks the foreground/background split point. Everything from this step onward runs in a background thread: ```python class AnalyzeStep: requires = frozenset({"data"}) provides = frozenset({"analysis"}) async_boundary = True # background from here def __call__(self, ctx: StepContext) -> StepContext: ... ``` See [Execution Model — Async Boundary](execution.md#async-boundary-fire-and-forget-background) for details. ### `max_workers` Controls the per-step-class thread pool size for background execution: ```python class ParallelAnalyzeStep: requires = frozenset({"data"}) provides = frozenset({"analysis"}) async_boundary = True max_workers = 4 # up to 4 concurrent analyses def __call__(self, ctx: StepContext) -> StepContext: ... ``` Default is `max_workers = 1` (serialized). !!! warning Steps that write shared state (e.g. updating an external database or accumulating results into a shared object) must use `max_workers = 1` to avoid race conditions. --- ## Subclassing StepContext When `metadata` becomes unwieldy, subclass `StepContext` to add named fields: ```python from dataclasses import dataclass @dataclass(frozen=True) class MLContext(StepContext): predictions: list | None = None scores: dict | None = None report: str | None = None ``` Steps write to named fields using `.replace()`: ```python class PredictStep: requires = frozenset() provides = frozenset({"predictions"}) def __init__(self, model): self.model = model def __call__(self, ctx: MLContext) -> MLContext: preds = self.model.predict(ctx.sample) return ctx.replace(predictions=preds) ``` !!! tip "When to subclass" - **Named fields**: Data shared across multiple steps that benefits from type checking - **Metadata**: Step-specific or integration-specific transient data (e.g. `metadata["cache_key"]`) The `requires`/`provides` validation works on attribute names, so it's subclass-agnostic. A step declaring `requires = {"predictions"}` works with any context subclass that has a `predictions` attribute. --- ## Testing steps ### Unit test — step in isolation ```python from types import MappingProxyType from pipeline import StepContext def test_tokenize_splits_words(): step = Tokenize() ctx = StepContext(sample="hello world") result = step(ctx) assert result.metadata["tokens"] == ["hello", "world"] assert result.metadata["word_count"] == 2 def test_uppercase_transforms_tokens(): step = Uppercase() ctx = StepContext( metadata=MappingProxyType({"tokens": ["hello", "world"]}) ) result = step(ctx) assert result.metadata["upper_tokens"] == ["HELLO", "WORLD"] ``` ### Protocol compliance ```python from pipeline import StepProtocol def test_step_satisfies_protocol(): step = Tokenize() assert isinstance(step, StepProtocol) assert hasattr(step, "requires") assert hasattr(step, "provides") assert callable(step) ``` ### Pipeline integration test ```python from pipeline import Pipeline, StepContext def test_full_pipeline(): pipe = Pipeline().then(Tokenize()).then(Uppercase()) results = pipe.run([StepContext(sample="hello world")]) assert len(results) == 1 assert results[0].error is None assert results[0].output.metadata["upper_tokens"] == ["HELLO", "WORLD"] ``` --- ## Common patterns ### Map-reduce step A step that internally fans out to multiple sub-inputs: ```python class MultiSearchStep: requires = frozenset() provides = frozenset({"search_results"}) def __call__(self, ctx: StepContext) -> StepContext: queries = generate_queries(ctx.sample) # 1 → N sub_ctxs = [StepContext(sample=q) for q in queries] sub_pipe = Pipeline().then(FetchStep()) results = sub_pipe.run(sub_ctxs, workers=len(queries)) # parallel merged = merge_results(results) # N → 1 return ctx.replace( metadata=MappingProxyType({**ctx.metadata, "search_results": merged}) ) ``` From the outer pipeline's perspective, this is a black box that takes one context and returns one. ### Logging / observability step A pass-through step that logs without modifying data: ```python class LogStep: requires = frozenset() provides = frozenset() def __init__(self, logger): self.logger = logger def __call__(self, ctx: StepContext) -> StepContext: self.logger.info(f"Processing sample: {ctx.sample}") self.logger.debug(f"Metadata keys: {list(ctx.metadata.keys())}") return ctx # pass through unchanged ``` ### Retry wrapper A step that wraps another step with retry logic: ```python import time class RetryStep: def __init__(self, inner, max_retries: int = 3, delay: float = 1.0): self.inner = inner self.max_retries = max_retries self.delay = delay self.requires = inner.requires self.provides = inner.provides def __call__(self, ctx: StepContext) -> StepContext: for attempt in range(self.max_retries): try: return self.inner(ctx) except Exception: if attempt == self.max_retries - 1: raise time.sleep(self.delay * (attempt + 1)) ``` Usage: ```python pipe = Pipeline().then(RetryStep(FlakyAPIStep(), max_retries=3)) ```