Building an Adapter¶
This page shows how to build a rypipe adapter for a newline-delimited
key=value log format. By the end, you will have a complete adapter that
works with the pipeline API.
The format¶
Your format is newline-delimited key=value pairs:
Each line is a row. Fields are comma-separated key=value pairs.
Prerequisites¶
- Rust toolchain (1.78+)
- Python 3.10+
rypipeinstalled (pip install rypipe)
Step 1: Create the package¶
An adapter is a separate package that depends on rypipe-core. Create the
package structure and Cargo.toml:
The Cargo.toml defines the Rust crate that will be compiled into a Python
extension module. We depend on rypipe-core for the Splitter and
RecordParser traits, and pyo3 for Python bindings:
Cargo.toml¶
[package]
name = "rypipe-log"
version = "0.1.0"
edition = "2021"
[lib]
name = "_rypipe_log"
crate-type = ["cdylib"]
[dependencies]
rypipe-core = "2"
pyo3 = { version = "0.29", features = ["extension-module", "abi3-py310"] }
memchr = "2"
simdutf8 = "0.1"
The cdylib crate type produces a shared library that Python can import.
The abi3 feature enables stable ABI, so one wheel works across Python
versions.
Step 2: Implement the Splitter¶
The Splitter tells the engine where each row starts. The engine calls
next_record_start repeatedly to split the file into chunks for parallel
parsing. For newline-delimited formats, the next row starts after the next
\n:
src/lib.rs (Splitter)¶
use rypipe_core::{Splitter, RecordParser, ColumnarSink, Value, Result};
// The Splitter tells the engine where each row starts.
// For newline-delimited formats, the next row starts after the next '\n'.
#[derive(Clone, Default)]
pub struct LogSplitter;
impl Splitter for LogSplitter {
// Find the byte position of the next record start after `from`.
// Return None when we reach the end of the input.
fn next_record_start(&self, bytes: &[u8], from: usize) -> Option<usize> {
memchr::memchr(b'\n', &bytes[from..]).map(|r| from + r + 1)
}
// Estimate bytes per row from a sample. The engine uses this to size
// chunks and memory budgets. Count newlines and divide.
fn estimate_bytes_per_row(&self, sample: &[u8]) -> usize {
let n = sample.iter().filter(|&&b| b == b'\n').count().max(1);
(sample.len() / n).max(1)
}
}
The Splitter has two methods:
next_record_start: called repeatedly to find chunk boundaries. The engine splits the file at these positions for parallel parsing. We usememchrfor fast newline scanning.estimate_bytes_per_row: tells the engine how many rows to expect per chunk, so it can size memory budgets. We count newlines in a sample and divide.
Step 3: Implement the RecordParser¶
The RecordParser extracts field values from each row. This is the hot path:
it is called once per chunk, so it must be fast. For each row, we call
sink.begin_row(), then sink.put_field() for each field, then
sink.end_row():
src/lib.rs (RecordParser)¶
// The RecordParser turns raw bytes into field/value events.
// parse_chunk is called once per chunk: this is the hot path.
#[derive(Clone, Default)]
pub struct LogParser;
impl RecordParser for LogParser {
// Validate UTF-8 before parsing. Called once per chunk.
fn validate(&self, bytes: &[u8]) -> Result<()> {
simdutf8::basic::from_utf8(bytes)
.map_err(|e| rypipe_core::Error::Utf8(e))?;
Ok(())
}
// Parse a chunk of bytes into field/value events.
// For each row: begin_row -> put_field x N -> end_row.
fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()> {
let text = std::str::from_utf8(bytes)
.map_err(|e| rypipe_core::Error::Plan(e.to_string()))?;
for line in text.lines() {
if line.is_empty() { continue; }
// Signal the start of a new row
sink.begin_row();
// Parse comma-separated key=value pairs
for part in line.split(',') {
if let Some((key, value)) = part.split_once('=') {
// sink.wants() returns false if the engine doesn't need
// this field (projection pushdown). Skip it entirely.
if sink.wants(key) {
// Borrow the string from the input bytes (zero allocation)
sink.put_field(key, Value::Str(std::borrow::Cow::Borrowed(value)));
}
}
}
// Signal the end of the row
sink.end_row();
}
Ok(())
}
}
The RecordParser has two methods:
validate: called once per chunk to check that the bytes are valid UTF-8. We usesimdutf8for fast validation.parse_chunk: the hot path. For each row, callsink.begin_row(), thensink.put_field()for each field, thensink.end_row(). We useCow::Borrowedto borrow the string from the input bytes without allocation.
Tip
Always check sink.wants(key) before parsing a field's value. When the user
drops a column, wants() returns false and you skip all work for that
field: no scanning, no decoding.
Step 4: Expose to Python¶
Add PyO3 bindings to expose your parser to Python. The read_log function
is an internal function that LogSource._read_arrow() calls. Users never
call it directly, they use LogSource instead:
src/lib.rs (Python bindings)¶
use pyo3::prelude::*;
use rypipe_core::{ExecutionPlan, Pipeline};
use rypipe_python::record_batches_to_pyarrow_table;
// Internal function: called by LogSource._read_arrow().
// Users never call this directly.
#[pyfunction]
fn read_log(path: String) -> PyResult<PyObject> {
let plan = ExecutionPlan::new();
let batches = Pipeline::new(LogSplitter, LogParser)
.with_plan(plan)
.read_path(&path, false, false)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
Python::with_gil(|py| {
record_batches_to_pyarrow_table(py, &[batches])
.map(|obj| obj.into())
})
}
// Python module definition
#[pymodule]
fn _rypipe_log(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(read_log, m)?)?;
Ok(())
}
This creates a Python module _rypipe_log with an internal read_log
function. The user-facing API is LogSource, which calls read_log
internally via _read_arrow().
Step 5: Create the Python wrapper¶
Follow the crxml formula: a Source subclass, a thin adapter, and repacked
stages. The Source subclass gives users the pipeline | operator and
caching. The thin adapter enables rypipe.read(). The repacked stages
make the adapter self-contained.
rypipe_log/__init__.py¶
import importlib
# 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__
The __init__.py uses lazy loading: modules are only imported when accessed.
This avoids loading the Rust extension until it is actually needed.
rypipe_log/source.py¶
The Source subclass is the pipeline-capable entry point. It implements
_read_arrow() and forwards plan kwargs from fused stages:
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_log(str(self._path), **plan)
Warning
The _read_arrow method must forward plan_overrides to the
Rust reader. If you ignore them, fused pipeline stages silently fall back
to Python execution (10-50x slower than the Rust path).
rypipe_log/rypipe_adapter.py¶
The adapter inherits from rypipe.Adapter, which handles plan forwarding
automatically. Subclasses only implement read():
from typing import Any
from rypipe import Adapter
class LogAdapter(Adapter):
"""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 _rypipe_log.read_log(path, **kwargs)
def _register() -> None:
try:
import rypipe
except Exception: # pragma: no cover, rypipe is optional
return
rypipe.register_adapter("log", LogAdapter(), extensions=[".log"])
_register()
Note
Complex adapters (like crxml) override
_read_arrow() instead of read() to control engine selection and
streaming. See Adapter design patterns
for details. The read() override is simpler and sufficient for most
adapters.
For the full stage implementations (CastTypes, FilterRows, etc.), see
Stages.
Step 6: Build and test¶
Build the Rust extension with maturin, then test your adapter:
Build¶
Test it¶
import rypipe
import rypipe_log # registers the adapter
# Create a test file
with open("test.log", "w") as f:
f.write("name=Alice,age=30,active=true\n")
f.write("name=Bob,age=25,active=false\n")
# Pattern 1: one-liner via rypipe (extension auto-detected)
table = rypipe.read("test.log")
print(table)
# pyarrow.Table<name: string, age: string, active: string>
# Pattern 2: pipeline via LogSource + repacked stages
from rypipe_log import LogSource, CastTypes, FilterRows
src = LogSource("test.log")
result = (
src
| CastTypes({"age": int})
| FilterRows(field="active", op="==", value="true")
).to_arrow()
print(result)
# pyarrow.Table<name: string, age: int64, active: string>
# name: ["Alice"]
# age: [30]
# active: ["true"]
Pattern 2 (using the Source directly) is the recommended approach. It gives
you the pipeline | operator, caching, and streaming. Pattern 1
(rypipe.read()) is a convenience for one-liner reads but does not support
pipelines.
What just happened¶
- Splitter found newline boundaries in the file.
- RecordParser parsed each chunk, calling
sink.put_fieldfor each field in each row. - Engine accumulated values into Arrow columns.
- Export produced a
pyarrow.Tablewith zero-copy.
Next steps¶
- Pipeline, how the
|operator and plan forwarding work - Stages, implement
CastTypes,FilterRows, etc. - Sinks, implement
collect,to_pandas,to_csv - Rust Creation, deep dive into Splitter, RecordParser, and ColumnarSink
- Schema, declare columns for maximum performance
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.
- Users import everything from the adapter package, never from rypipe directly.
rypipe.read("file.log")works via the registered adapter.LogSource("file.log") | CastTypes(...) | FilterRows(...)works via the Source pipeline.