Stage Protocol¶
This page explains the stage protocol: how stages integrate with the rypipe engine, why re-exporting works, and when to re-implement.
The stage protocol¶
Any Python class with these three methods is a valid stage:
class MyStage:
def apply(self, record: dict) -> dict | None:
"""Transform a single record. Return None to drop the row."""
...
def __call__(self, stream):
"""Transform an iterable of records (unfused path)."""
return map(self.apply, stream)
def _plan_kwargs(self) -> dict | None:
"""Return pushdown kwargs for the Rust engine, or None if not fusable."""
...
The pipeline calls these methods automatically. You never call them directly.
| Method | When called | Purpose |
|---|---|---|
_plan_kwargs() |
Once, when the pipeline is materialized | Collects pushdown kwargs for the Rust engine |
apply(record) |
Per-row, in the Rust parse loop (if fused) | Transform a single dict during parsing |
__call__(stream) |
Per-row, in Python (if not fused) | Transform an iterable of dicts outside the engine |
The protocol is the contract¶
The engine does not check class names, inheritance, or module origins. It
calls _plan_kwargs() on every stage in the pipeline. If the return value
is a dict, the engine merges it into the ExecutionPlan. If it is None,
the stage runs in Python.
This is why re-exporting works: the engine sees the same _plan_kwargs()
method regardless of whether the class was imported from rypipe.stages
or from my_adapter.stages. The protocol is the contract, not the import
path.
How fusion works¶
When a pipeline reaches .to_arrow() (or any sink), the engine:
- Walks the stage chain left to right.
- Calls
_plan_kwargs()on each stage. - Merges the results into a single
ExecutionPlan. - Passes the plan to the Rust parser.
result = (
source
| RenameFields({"old_name": "new_name"}) # _plan_kwargs → {"field_mapping": ...}
| DropFields(["internal_id"]) # _plan_kwargs → {"drop_fields": ...}
| FilterRows(field="status", op="==", value="active") # _plan_kwargs → {"filter": ...}
| CastTypes({"amount": float}) # _plan_kwargs → {"field_types": ...}
).to_arrow()
The Rust parser then applies all four operations during the parse loop: renames arrive, dropped fields are skipped, rows are filtered, and types are cast, all without materializing a full table in Python.
See Pushdown Fusion for the full flow diagram and optimization details.
What happens when fusion is not possible¶
If any stage returns None from _plan_kwargs(), the pipeline splits:
- The stages before the non-fusable stage are fused into a plan.
- The Rust parser runs with that plan, producing a table.
- The non-fusable stage runs in Python over the materialized table.
- The remaining stages run in Python (or fuse a second plan if possible).
This fallback is correct but 10–50× slower. Only return None from
_plan_kwargs() when fusion is genuinely impossible.
Why re-exporting works¶
Stage classes are stateless Python objects. The engine interacts with them
through the protocol (_plan_kwargs()), not through their identity or
location in memory.
# This works
from rypipe.stages import FilterRows
# This also works
from my_adapter.stages import FilterRows # re-exported from rypipe
# Same object, same protocol, same fusion
assert FilterRows is FilterRows
Re-exporting gives adapter authors:
- Zero maintenance burden: stage improvements in rypipe propagate automatically.
- Identical fusion: the engine sees the same
_plan_kwargs()method. - User-facing isolation: users import from the adapter, never from rypipe directly.
When to re-implement¶
Re-export the standard stages. Only re-implement when you need behavior that the standard stages cannot express.
Legitimate reasons to re-implement¶
| Reason | Example |
|---|---|
| Format-specific validation | Normalize field values before filtering |
| Custom logging | Log warnings for unexpected values |
| Additional state | Track statistics during pipeline execution |
| Non-fusable logic | Apply a side effect that cannot run in Rust |
Example: logging filter¶
A filter that logs every rejected row for debugging:
from rypipe.stages import FilterRows
import logging
logger = logging.getLogger(__name__)
class LoggingFilterRows(FilterRows):
"""FilterRows that logs rejected rows."""
def apply(self, record: dict) -> dict | None:
result = super().apply(record)
if result is None:
logger.debug("dropped row: %s", record)
return result
This works because apply() is called per-row. The _plan_kwargs() method
is inherited, so fusion still applies for the keyword form.
Example: non-fusable stage¶
A stage that writes to an external service cannot be fused:
class SendToWebhook:
"""Send each record to a webhook. Never fusable."""
def __init__(self, url: str):
self._url = url
def apply(self, record: dict) -> dict | None:
requests.post(self._url, json=record)
return record # pass through
def __call__(self, stream):
return map(self.apply, stream)
def _plan_kwargs(self) -> dict | None:
return None # cannot fuse: side effect
Returning None forces the pipeline to materialize a table first, then
run this stage in Python. This is correct but slower.
Stage reference¶
| Stage | _plan_kwargs() key |
Fusable | Description |
|---|---|---|---|
RenameFields |
field_mapping |
Yes | Rename columns |
DropFields |
drop_fields |
Yes | Remove columns |
CastTypes |
field_types |
Yes (for int, float, bool, date, datetime, Decimal) |
Cast column types |
FilterRows |
filter |
Yes (keyword form or compiled lambda) | Filter rows by predicate |
FilterRowsAny |
(composed) | Yes | Logical OR of filters |
FilterRowsAll |
(composed) | Yes | Logical AND of filters |
FilterRowsNot |
(composed) | Yes | Negate a filter |
_plan_kwargs keys¶
These keys are merged into the ExecutionPlan passed to the Rust parser:
| Key | Type | Purpose |
|---|---|---|
field_mapping |
dict[str, str] |
Rename mapping: raw name → output name |
drop_fields |
list[str] |
Fields to skip entirely |
field_types |
dict[str, str] |
Type overrides (e.g., "int64", "float64") |
filter |
dict |
Predicate spec: {"field", "op", "value"} or {"field_a", "op", "field_b"} |
See Execution Plan for the full plan structure.