Writing a Format Adapter¶
This guide 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 guide is for adapter authors.
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 follows 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 |
|---|---|
MySource(Source) |
Pipeline-capable source with _read_arrow() and plan forwarding |
my_adapter.stages/ |
Own copies of CastTypes, FilterRows, RenameFields, DropFields |
| Registration | Adapter registered at import time via side-effect import |
Note
Adapters repack the API: they include their own copies of the pipeline
stage classes (CastTypes, FilterRows, RenameFields, DropFields) and
sink functions (collect, to_arrow, to_pandas, to_polars,
to_parquet, to_pandas, 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 (required)¶
from rypipe import Source
class MySource(Source):
def _read_arrow(self, plan_overrides=None):
plan = self._build_plan_kwargs()
if plan_overrides:
plan.update(plan_overrides)
return _rypipe_myfmt.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 (required)¶
Repack or reimplement these stage classes:
CastTypes, cast column typesFilterRows, filter rows by predicateRenameFields, rename columnsDropFields, remove columns
Sinks (required)¶
Repack 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_pandas(pipeline), alias for to_pandasto_csv(pipeline, path), write to CSV
Registration (required)¶
Register the adapter at import time so rypipe.read("file.ext") works:
def _register():
try:
import rypipe
except Exception:
return
rypipe.register_adapter("myfmt", MyAdapter(), extensions=[".myfmt"])
_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 my_adapter import MySource, CastTypes, FilterRows
source = MySource("file.myfmt")
# 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 my_adapter 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 |
|---|---|
| Quick Start | Build a working adapter in 15 minutes |
| Python Wiring | Source, adapter, registration, stages |
| Rust Creation | Splitter, RecordParser, ColumnarSink |
| Schema | Schema declaration for maximum performance |
| 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 |
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.