Building an Adapter¶
This track teaches you how to write a rypipe adapter, a package that lets rypipe read your custom format.
Tip
If you just want to use an existing adapter, see the Tutorial instead. This track is for adapter authors.
How this track works¶
Read it in order:
- Walkthrough, build a complete working adapter
(
rypipe_log) from scratch, step by step. - Python wiring, the full wiring reference: Source, adapter class, registration, stage and sink re-exports, adapter kwargs, engine selection, streaming.
- Deep dives, one page per component: Splitter, RecordParser, Sink, Schema, scan primitives, and more.
The rest of this page is the map: what you will build, the API contract, and how the engine works.
What you will build¶
A complete adapter package that:
- Parses a newline-delimited
key=valuelog format. - Registers with rypipe so
rypipe.read("file.log")works. - Supports the full pipeline API (
|operator, fusion, streaming).
The crxml formula¶
The reference adapter (crxml) defines the standard pattern. Every adapter should follow this structure:
Rust layer¶
Two traits that define your format's parsing logic:
| Trait | Purpose | Required methods |
|---|---|---|
| Splitter | Find row boundaries in the byte stream | next_record_start, estimate_bytes_per_row |
| RecordParser | Extract field values from each row | validate, parse_chunk |
The engine provides TableBuilder as the production
ColumnarSink. You rarely implement it yourself.
Python layer¶
| Component | Purpose |
|---|---|
LogSource(Source) |
Pipeline-capable source with _read_arrow() and plan forwarding |
rypipe_log.stages/ |
Re-exports of CastTypes, FilterRows, RenameFields, DropFields from rypipe.stages |
| Registration | Adapter registered at import time via side-effect import |
Note
Adapters re-export the API: they re-export pipeline stage classes
(CastTypes, FilterRows, RenameFields, DropFields) from
rypipe.stages and sink functions (collect, to_arrow, to_pandas,
to_polars, to_parquet, to_csv) so users never import from
rypipe directly. This makes the adapter self-contained.
Adapter API contract¶
Every adapter must expose these APIs:
Source class¶
from rypipe import Source
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)
The Source class gives users the pipeline | operator, caching, and all
sinks (.to_arrow(), .to_pandas(), .to_polars(), .to_parquet()).
Stages¶
Re-export these stage classes from rypipe.stages:
CastTypes, cast column typesFilterRows, filter rows by predicateRenameFields, rename columnsDropFields, remove columns
See Python Wiring for the re-export pattern.
Sinks¶
Re-export or reimplement these sink functions:
collect(pipeline), collect to list of dictsto_arrow(pipeline), materialize to pyarrow.Tableto_pandas(pipeline), convert to pandas DataFrameto_polars(pipeline), convert to Polars DataFrameto_parquet(pipeline, path), write to Parquetto_csv(pipeline, path), write to CSV
Registration¶
Register the adapter at import time so rypipe.read("file.ext") works:
def _register():
try:
import rypipe
except Exception:
return
rypipe.register_adapter("log", LogAdapter(), extensions=[".log"])
_register()
What NOT to implement¶
Do not implement a read() convenience function. The Source class IS
the primary API. Users write:
Not:
User API¶
End users should only import from the adapter package. Here is what a user of your adapter sees:
from rypipe_log import LogSource, CastTypes, FilterRows
source = LogSource("data.log")
# One-liner
table = source.to_arrow()
# Pipeline
result = (
source
| CastTypes({"age": int})
| FilterRows(field="active", op="==", value="true")
).to_arrow()
Users never write from rypipe import CastTypes: they write
from rypipe_log import CastTypes. This is the crxml formula.
How the engine works¶
Your adapter provides the parsing logic. The engine handles everything else:
Input bytes (file or mmap)
│
▼
Splitter::next_record_start (find safe chunk boundaries)
│
▼ [one chunk]
RecordParser::parse_chunk (per-chunk, feeds ColumnarSink)
│ calls: begin_row → put_field × N → end_row
▼
ColumnarSink (TableBuilder) (accumulates typed columns)
│
▼
Arrow RecordBatch (zero-copy export)
│
▼
pyarrow.Table (Python API)
rypipe handles:
- Parallel execution: split the file, parse chunks concurrently on multiple threads.
- Bounded-memory streaming: process one chunk at a time, keeping only the current chunk in memory.
- Pushdown plans: rename, drop, filter, type coercion, dictionary encoding, all pushed into the Rust parse loop.
- Zero-copy Arrow export: column buffers move directly into Arrow arrays.
- Schema discovery: find field names from a sample of the file.
Guide contents¶
| Page | What you learn |
|---|---|
| Walkthrough | Build a complete working adapter, step by step |
| Python Wiring | Source, adapter, registration, stages, sinks, kwargs, streaming |
| Rust Creation | Splitter, RecordParser, ColumnarSink |
| Splitter | Finding row boundaries |
| Parser | Extracting field values |
| Sink | The ColumnarSink contract |
| Schema | Schema declaration for maximum performance |
| Scan primitives | Fast byte-scanning helpers |
| Skip regions | Skipping quoted/escaped regions |
| Chunk planning | How input is split into chunks |
| Techniques | Performance optimizations |
| Anti-patterns | Common mistakes to avoid |
| Examples | Worked CSV, JSONL, and TSV adapters |
Performance model¶
The hot path is:
Each put_field call goes through:
- Scan: find the field's byte extent in the input (your parser does this).
- Resolve: map raw name to output column name (engine does this).
- Push: write the value into the column builder (engine does this).
- Filter: check if the row passes the predicate (engine does this).
The engine optimizes steps 2-4. Your parser's job is to make step 1 fast.
For a 533 MB file on a Ryzen 5800X:
| Phase | Budget | Your responsibility |
|---|---|---|
| Splitting | ~5% | next_record_start must be fast |
| Parsing | ~70% | parse_chunk is the hot path |
| Column building | ~20% | Engine handles this |
| Export | ~5% | Zero-copy, engine handles this |
Build and test¶
Every adapter in this guide builds and tests the same way: compile the
Rust extension into your environment with
uv run --with maturin maturin develop --release, then run the Rust unit
tests with cargo test. The walkthrough
shows both commands end to end with real output.
Recap¶
- An adapter is a Rust crate (Splitter + RecordParser) and a Python package (Source + stages + sinks).
- The engine handles parallel execution, memory management, and Arrow export.
- Your parser's job is to make
parse_chunkfast. - Follow the crxml formula for a consistent user experience.