Python Adapter Wiring¶
This page explains how to wire your Rust adapter to Python: the Source subclass, adapter class, registration, and repacked stages.
The crxml formula¶
The reference adapter (crxml) defines the standard pattern. Every adapter should follow this structure:
# Users import everything from the adapter package { #users-import-everything-from-the-adapter-package }
from rypipe_log import LogSource, CastTypes, FilterRows
Directory layout¶
rypipe_log/
├── __init__.py # re-exports, lazy loading
├── rypipe_adapter.py # LogAdapter + registration
├── source.py # LogSource(Source)
├── sinks.py # collect, to_pandas, to_csv (repacked)
└── stages/
├── __init__.py # lazy re-exports
├── cast.py # CastTypes
├── filter.py # FilterRows
├── rename.py # RenameFields
└── drop.py # DropFields
Important
Users only import from your adapter. They write
from rypipe_log import CastTypes, FilterRows: never
from rypipe import CastTypes. This is the crxml formula:
adapters repack the full pipeline API so end users never depend on
rypipe directly.
Source subclass¶
The Source subclass is the pipeline-capable entry point. It implements
_read_arrow() and forwards plan kwargs from fused stages:
# rypipe_log/source.py { #rypipe_logsourcepy }
from __future__ import annotations
from typing import Any
import _rypipe_log
from rypipe import Source
class LogSource(Source):
"""Pipeline-capable source for newline-delimited key=value logs."""
def _read_arrow(self, plan_overrides: dict[str, Any] | None = None) -> Any:
# Start with construction-time kwargs (field_mapping, drop_fields, etc.)
plan = self._build_plan_kwargs()
# Fused pipeline stages override construction-time kwargs
if plan_overrides:
plan.update(plan_overrides)
# Pass the merged plan to the Rust reader
return _rypipe_log.read(str(self._path), **plan)
How _read_arrow works¶
When a user writes src | RenameFields(...) | FilterRows(...), the pipeline
collects stages into a plan. When .to_arrow() is called, the pipeline calls
_read_arrow(plan_overrides=...) on your source.
plan_overrides contains the fused stage kwargs:
{
"field_mapping": {"old_name": "new_name"},
"drop_fields": ["internal_id"],
"filter": {"field": "status", "op": "==", "value": "active"},
"field_types": {"amount": "float64"},
}
You must merge these with your construction kwargs and pass them to your
Rust reader. If you ignore plan_overrides, fused stages silently fall back
to Python execution: 10-50× slower.
Warning
Never ignore plan_overrides. Fused stages silently fall back to Python
execution over a full table when plan kwargs are not forwarded, turning a
microsecond Rust path into a millisecond Python loop.
Adapter class¶
The adapter is a thin, stateless wrapper. It delegates to the Source for actual parsing:
# rypipe_log/rypipe_adapter.py { #rypipe_logrypipe_adapterpy }
from __future__ import annotations
from typing import Any
from .source import LogSource
class LogAdapter:
"""rypipe-compatible adapter for newline-delimited key=value logs."""
def read(self, path: str, **kwargs: Any) -> Any:
"""Parse ``path`` and return a ``pyarrow.Table``."""
return LogSource(path, **kwargs).to_arrow()
def iter_record_batches(
self, path: str, memory: str | int = "64MiB",
batch_size: int | None = None, **kwargs: Any,
):
"""Yield ``pyarrow.RecordBatch`` objects with constant memory."""
yield from LogSource(path, **kwargs).iter_record_batches(
memory=memory, batch_size=batch_size
)
Note
The adapter's read() method returns a pyarrow.Table, not a Source.
This is by design: rypipe.read() calls adapter.read() and expects a
table. Users who want pipelines use the Source directly.
Registration¶
Register the adapter at import time. Users get the adapter by importing your package:
# rypipe_log/rypipe_adapter.py (continued) { #rypipe_logrypipe_adapterpy }
def _register() -> None:
try:
import rypipe
except Exception: # pragma: no cover: rypipe is optional
return
rypipe.register_adapter("log", LogAdapter(), extensions=[".log"])
_register() # runs on import
init.py¶
The __init__.py triggers registration and lazily loads public names:
# rypipe_log/__init__.py { #rypipe_log__init__py }
import importlib
# Side-effect import: registers the adapter with rypipe on import { #side-effect-import-registers-the-adapter-with-rypipe-on-import }
from . import rypipe_adapter # noqa: F401
__all__ = [
"LogSource",
"LogAdapter",
"CastTypes",
"FilterRows",
"RenameFields",
"DropFields",
]
_modules = {
"LogSource": ".source",
"CastTypes": ".stages",
"FilterRows": ".stages",
"RenameFields": ".stages",
"DropFields": ".stages",
}
def __getattr__(name):
if name in _modules:
mod = importlib.import_module(_modules[name], __package__)
return getattr(mod, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
return __all__
After registration:
rypipe.read("data.log")auto-detects the.logextension.rypipe.read("data.log", format="log")works explicitly.rypipe.read("data.txt", format="log")works with explicit format.
Re-exporting stages¶
Adapters re-export the pipeline stage classes from rypipe. Users import everything from the adapter, never from rypipe directly:
The re-export pattern:
# rypipe_log/stages/__init__.py
from rypipe.stages import (
CastTypes,
FilterRows,
FilterRowsAny,
FilterRowsAll,
FilterRowsNot,
RenameFields,
DropFields,
)
__all__ = [
"CastTypes",
"FilterRows",
"FilterRowsAny",
"FilterRowsAll",
"FilterRowsNot",
"RenameFields",
"DropFields",
]
See Stages for what each stage does and Stage Protocol for why re-exporting works.
Tip
Re-exporting is zero-cost. The stage classes are the same objects; the engine fuses them identically whether they come from your package or from rypipe. Copying the implementations creates maintenance burden with no benefit.
When to re-implement a stage¶
Re-export the standard stages. Only re-implement when you need
format-specific behavior they cannot express. For example, suppose your
format has a status field that is always uppercase, but downstream
consumers expect lowercase. Subclass FilterRows to normalize before
filtering and log warnings for unexpected values:
from rypipe.stages import FilterRows
class ValidatingFilterRows(FilterRows):
"""FilterRows that normalizes status values and logs warnings."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._warnings = []
def apply(self, record: dict) -> dict | None:
if "status" in record:
record["status"] = record["status"].lower()
if record["status"] not in ("active", "inactive", "pending"):
self._warnings.append(f"unexpected status: {record['status']}")
return super().apply(record)
It fuses identically to the standard stage because _plan_kwargs() is
inherited. If you override _plan_kwargs() and return None, the stage
falls back to Python execution: only do this when fusion is impossible
(for example, the stage depends on external state). See
Pushdown Fusion for how the engine compiles stages
into an execution plan.
Re-exporting sinks¶
Adapters also repack the standalone sink functions, so users can materialize pipelines without importing rypipe:
# rypipe_log/sinks.py
from rypipe.sinks import to_pandas as _rypipe_to_pandas
from rypipe.sinks import to_csv as _rypipe_to_csv
from rypipe.sinks import collect as _rypipe_collect
from rypipe.sinks import to_arrow as _rypipe_to_arrow
from rypipe.sinks import to_polars as _rypipe_to_polars
from rypipe.sinks import to_parquet as _rypipe_to_parquet
# Re-export from rypipe with the adapter's namespace
collect = _rypipe_collect
to_pandas = _rypipe_to_pandas
to_arrow = _rypipe_to_arrow
to_polars = _rypipe_to_polars
to_parquet = _rypipe_to_parquet
to_csv = _rypipe_to_csv
Or reimplement them from scratch for full control. Users then write
from rypipe_log import collect, to_dataframe and never touch rypipe.
Adapter kwargs¶
You decide which kwargs your Source accepts and how to forward them to your Rust backend.
Common kwargs (recommended)¶
These are defined by rypipe and give users a consistent experience
across adapters. The base Source class stores them and
_build_plan_kwargs() forwards them:
| Kwarg | Rust type | Purpose |
|---|---|---|
field_mapping |
HashMap<String, String> |
Rename columns during parsing |
drop_fields |
Vec<String> |
Skip columns entirely |
filter |
HashMap<String, Value> |
Pushdown filter predicate |
field_types |
HashMap<String, String> |
Type hints for columns |
dictionary_columns |
Vec<String> |
Dictionary-encode columns |
schema |
Vec<String> |
Expected column names and order |
auto_dict |
bool |
Auto-dictionary low-cardinality strings |
Custom kwargs¶
Add adapter-specific kwargs to your Source constructor, store them as
instance attributes, and always call super().__init__() so the common
kwargs keep working:
class LogSource(Source):
def __init__(self, path, *, row_tag="Row", threads=0, **kwargs):
self._row_tag = row_tag
self._threads = threads
super().__init__(path, **kwargs) # handles the common kwargs
Engine selection¶
rypipe-core provides four execution modes: columnar, parallel,
stream, and parallel_streaming. Your adapter wires two entry points:
| Entry point | Modes | Purpose |
|---|---|---|
_read_arrow() |
columnar, parallel, stream | Full-table reads (to_arrow, to_pandas) |
iter_record_batches() |
stream, parallel_streaming | Bounded-memory reads |
rypipe.resolve_engine picks the optimal mode from file size, memory
budget, and threads. Use it to dispatch in _read_arrow:
from rypipe import Source, resolve_engine
class LogSource(Source):
def __init__(self, path, *, engine="auto", **kwargs):
self._engine = engine
super().__init__(path, **kwargs)
def _resolve_engine(self) -> str:
if self._engine != "auto":
return self._engine
return resolve_engine(
file_size=self._path.stat().st_size,
memory=self._memory,
threads=self._threads,
schema=self._schema or None,
has_parallel=_HAS_PARALLEL,
has_columnar=_HAS_COLUMNAR,
)
def _read_arrow(self, plan_overrides=None):
plan = self._build_plan_kwargs()
if plan_overrides:
plan.update(plan_overrides)
engine = self._resolve_engine()
if engine == "parallel":
return _rypipe_log.read_par(str(self._path), **plan)
elif engine == "stream":
return _rypipe_log.read_stream(str(self._path), **plan)
else: # columnar (default)
return _rypipe_log.read(str(self._path), **plan)
iter_record_batches always streams regardless of the engine mode, so it
needs no dispatch. A simple adapter can skip resolve_engine and call one
mode directly, as shown in Source subclass above; add
engine selection when you expose more than one Rust entry point.
Streaming¶
Most adapters get streaming almost for free: the engine provides Rust
streaming iterators that handle bounded memory, chunking, and parallelism.
Your side of the deal is overriding iter_record_batches on your Source
and forwarding the plan kwargs, the same way _read_arrow does:
class LogSource(Source):
def _read_arrow(self, plan_overrides=None):
plan = self._build_plan_kwargs()
if plan_overrides:
plan.update(plan_overrides)
return _rypipe_log.read(str(self._path), **plan)
def iter_record_batches(self, memory="64MiB", batch_size=None, **kwargs):
plan = self._build_plan_kwargs()
return _rypipe_log.iter_batches(
str(self._path), memory=memory, batch_size=batch_size, **plan
)
The adapter class delegates to the Source (see Adapter class above for the full definition):
class LogAdapter:
def iter_record_batches(self, path, memory="64MiB", batch_size=None, **kwargs):
yield from LogSource(path, **kwargs).iter_record_batches(
memory=memory, batch_size=batch_size
)
Users can then process large files with bounded memory:
from rypipe_log import LogSource
src = LogSource("huge_report.log")
# Streaming DataFrame (most common)
df = src.to_pandas(memory="256MiB")
# Streaming Parquet
src.to_parquet("output.parquet", memory="256MiB")
# Parallel streaming (higher throughput)
df = src.to_pandas(memory="256MiB", threads=16)
# Advanced: batch-level control
for batch in src.iter_record_batches(memory="256MiB"):
process(batch)
With this wiring:
source.to_pandas(memory="256MiB")works automatically.source.to_parquet(path, memory="256MiB")works automatically.pipeline.to_pandas(memory="256MiB")works automatically.- Fusable stages run in the parse loop (no Python overhead).
- Peak memory is bounded by the
memoryparameter.
Note
If your format has special chunking requirements (for example, row
boundaries span chunks), implement iter_batches in Rust and expose it
via PyO3. See Rust Creation for the traits and
Chunk planning for how the engine splits input.
Build and test¶
Rebuild the extension and smoke-test the wiring: registration, the Source, and the sinks should all work from the adapter package alone:
$ uv run --with maturin maturin develop --release
📦 Built wheel for abi3 Python ≥ 3.10 to /tmp/.../rypipe_log-0.1.0-cp310-abi3-linux_x86_64.whl
🛠 Installed rypipe-log-0.1.0
$ python wiring_smoke.py
2 rows via rypipe.read
2 rows via LogSource
# wiring_smoke.py
import rypipe
import rypipe_log # side-effect: register_adapter("log", ...)
print(rypipe.read("test.log").num_rows, "rows via rypipe.read")
from rypipe_log import LogSource
print(LogSource("test.log").to_arrow().num_rows, "rows via LogSource")
If the first line raises ValueError: no adapter registered, the
side-effect import in rypipe_log/__init__.py is missing. If the second
raises TypeError about unexpected kwargs, _read_arrow is not
forwarding the plan (see Adapter kwargs).
Recap¶
- Source: pipeline-capable, implements
_read_arrow()with plan forwarding. - Adapter: thin wrapper,
read()delegates toSource(...).to_arrow(). - Stages: re-exported from
rypipe.stages; subclass only for format-specific behavior. - Sinks: repacked from
rypipe.sinksso users never import rypipe. - Kwargs: accept the common kwargs via
super().__init__(), add custom ones for your format, useresolve_engineforengine="auto". - Streaming: override
iter_record_batches()and forward plan kwargs. - Registration: adapter registered at import time via side-effect import.
- Users import everything from the adapter package, never from rypipe.