Quick Start¶
Build a working rypipe adapter in 15 minutes. By the end, you will have
a complete adapter for a newline-delimited key=value log format.
Prerequisites¶
- Rust toolchain (1.78+)
- Python 3.10+
rypipeinstalled (pip install rypipe)
Step 1: Create the package¶
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"
Step 2: Implement the Splitter¶
The Splitter finds row boundaries. For newline-delimited formats, split on
\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)
}
}
Step 3: Implement the RecordParser¶
The RecordParser extracts field values from each 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 × 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(())
}
}
/// 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:
src/lib.rs (Python bindings)¶
use pyo3::prelude::*;
use rypipe_core::{ExecutionPlan, Pipeline};
use rypipe_python::record_batches_to_pyarrow_table;
// Read a log file and return a pyarrow.Table.
#[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(())
}
Step 5: Create the Python wrapper¶
Follow the crxml formula: a Source subclass, a thin adapter, and repacked stages.
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__
rypipe_log/source.py¶
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:
plan = self._build_plan_kwargs()
if plan_overrides:
plan.update(plan_overrides)
return _rypipe_log.read_log(str(self._path), **plan)
rypipe_log/rypipe_adapter.py¶
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 _register() -> None:
try:
import rypipe
except Exception: # pragma: no cover — rypipe is optional
return
rypipe.register_adapter("log", LogAdapter(), extensions=[".log"])
_register()
For the full stage implementations (CastTypes, FilterRows, etc.), see
Python Wiring.
Step 6: Build and test¶
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>
# ----
# name: ["Alice", "Bob"]
# age: ["30", "25"]
# active: ["true", "false"]
# 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"]
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¶
- Python Wiring — Source, adapter, registration, stages, streaming
- Rust Creation — deep dive into Splitter, RecordParser, and ColumnarSink
- Schema — declare columns for maximum performance
- Techniques — performance optimizations
- Examples — worked CSV, JSONL, and TSV adapters
Recap¶
- An adapter is a Rust crate (Splitter + RecordParser) and a Python package (Source + Adapter + stages).
- 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.