Rust API Reference¶
This page is a reference for the rypipe-core Rust API. For a tutorial, see Building Adapters.
Crate structure¶
| Crate | Purpose |
|---|---|
rypipe-core |
Engine, traits, pipeline, Arrow export |
rypipe-python |
PyO3 bindings and helpers |
rypipe-test |
Property-based testing helpers and fixtures |
Core traits¶
Splitter¶
Finds row boundaries in the byte stream.
pub trait Splitter: Send + Sync {
/// Return the byte position of the next record start after `from`,
/// or None at end of input.
fn next_record_start(&self, bytes: &[u8], from: usize) -> Option<usize>;
/// Estimate average bytes per row from a sample.
fn estimate_bytes_per_row(&self, sample: &[u8]) -> usize;
/// Optional: regions where split points must be rejected.
fn skip_regions(&self) -> Option<&dyn SkipRegionFinder> {
None // default: no skip regions
}
/// Find safe split points. Default: nominal offsets → next_record_start
/// → skip-region rejection → dedup → sort. Override only with measured reason.
fn find_split_points(&self, bytes: &[u8], max_chunks: usize) -> Vec<usize> {
// default 5-step algorithm
}
}
RecordParser¶
Extracts field values from each row.
pub trait RecordParser: Send + Sync {
/// Validate that bytes are well-formed. Called once per chunk.
fn validate(&self, bytes: &[u8]) -> Result<()>;
/// Parse a chunk into field/value events via the sink.
fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()>;
/// Monomorphized version for inlining (default: delegates to parse_chunk).
fn parse_chunk_generic<S: ColumnarSink>(&self, bytes: &[u8], sink: &mut S) -> Result<()>
where
Self: Sized;
}
ColumnarSink¶
Accumulates values into Arrow columns. The engine provides TableBuilder
as the production implementation.
pub trait ColumnarSink {
// Required
fn begin_row(&mut self);
fn put_field(&mut self, name: &str, value: Value<'_>);
fn end_row(&mut self);
fn finish(&mut self) -> Result<RecordBatch>;
// Name resolution and field pushing
fn wants(&self, _name: &str) -> bool { true }
fn resolve<'a>(&'a self, name: &'a str) -> Option<&'a str> { Some(name) }
fn resolve_raw<'a>(&'a self, raw_name: &'a [u8]) -> Option<&'a str> { ... }
fn put_field_resolved(&mut self, resolved_name: &str, value: Value<'_>) { ... }
fn put_field_at(&mut self, _slot: u32, value: Value<'_>) { ... }
fn resolve_and_put(&mut self, name: &str, value: Value<'_>) { ... }
fn resolve_and_put_raw(&mut self, raw_name: &[u8], value: Value<'_>) { ... }
fn put_row(&mut self, fields: &[(&str, Value<'_>)]) { ... }
// Row state
fn row_rejected(&self) -> bool { false }
fn row_satisfied(&self) -> bool { false }
// Engine hints
fn needs_value(&self) -> bool { true }
fn needs_resolve(&self) -> bool { true }
fn wanted_mask(&self) -> u64 { 0 }
fn expect_slot(&self, _ordinal: u32) -> Option<(u32, &[u8])> { None }
}
SkipRegionFinder¶
Defines byte ranges that must not be split on.
pub trait SkipRegionFinder: Send + Sync {
fn openers(&self) -> &[&'static [u8]];
fn closer_for(&self, opener: &[u8]) -> &'static [u8];
fn window(&self) -> usize { 64 * 1024 }
}
From the Python side
Python never implements these traits. End users import the adapter, not rypipe; here with the reference adapter crxml:
import crxml
# engine="auto" picks columnar/parallel/stream from the file size
table = crxml.CrystalXMLSource("data.xml", row_tag="Details").to_arrow()
table = crxml.CrystalXMLSource(
"big.xml", row_tag="Details", engine="parallel"
).to_arrow() # Pipeline::read_path_par
Everything above (Splitter, RecordParser, ColumnarSink,
SkipRegionFinder) runs inside the adapter's Rust crate; the Python
call crosses the boundary exactly once, on entry.
RowObserver¶
Per-row hooks fired by TableBuilder during parsing. Attach via
ExecutionPlan::with_observer. All methods default to
no-ops; implement only the ones you need. Hooks fire from parse threads on
every engine (serial, parallel, bounded, streaming), so keep them cheap and
thread-safe.
pub trait RowObserver: Send + Sync {
fn on_begin_row(&self, row_index: usize) {}
fn on_put_field(&self, row_index: usize, resolved_name: &str,
slot: usize, value: &Value<'_>) {}
fn on_row_accepted(&self, row_index: usize) {}
fn on_row_rejected(&self, row_index: usize) {}
fn on_chunk_finished(&self, total: usize, accepted: usize, rejected: usize) {}
}
See Observer hooks for diagnostics for a complete counting example.
RowObserver is Rust-only: no Python bindings are involved at any
point, so a hook is a plain function call on the parse thread (a few
nanoseconds, runs fully parallel across chunks). The Python equivalent is
passing observer={"on_row_rejected": fn, ...} in plan kwargs, which wraps
the callables in a PyObserver: every event acquires the GIL and calls
into Python (~50 ns per call), serialized against other Python threads.
Both paths are fine per row; the difference matters when the hook fires per
field or at high row rates. For hot paths, prefer a Rust RowObserver.
Value enum¶
Represents a parsed field value.
pub enum Value<'a> {
Str(Cow<'a, str>), // UTF-8 string (borrowed or owned)
Int64(i64), // 64-bit signed integer
Float64(f64), // 64-bit float
Bool(bool), // Boolean
Date32(i32), // Days since Unix epoch
Timestamp(i64), // Raw integer (unit from field_types)
Null, // Explicit null
}
The Python-side type mapping (as seen in pyarrow tables and in observer
hook arguments):
Value |
Python | pyarrow |
|---|---|---|
Str |
str |
string/dictionary |
Int64 |
int |
int64 |
Float64 |
float |
float64 |
Bool |
bool |
bool |
Date32 |
int (days since epoch) |
date32 |
Timestamp |
int (unit from field_types) |
timestamp |
Null |
None |
null |
ExecutionPlan¶
Configuration for the parse loop.
pub struct ExecutionPlan {
pub field_map: HashMap<String, String>, // rename columns
pub drop_fields: HashSet<String>, // columns to skip
pub field_types: HashMap<String, FieldType>, // type overrides
pub dictionary_columns: HashSet<String>, // dict-encode columns
pub filter: Option<FilterPredicate>, // row filter
pub schema_order: Vec<String>, // output column order
pub auto_dict: bool, // auto-dictionary
pub dict_threshold: Option<f64>, // auto-dict threshold
pub dict_max_size: Option<usize>, // auto-dict max size
pub strict_types: bool, // reject malformed data
pub max_split_chunks: Option<usize>, // cap on streaming chunks
pub min_chunk_bytes: Option<usize>, // split chunk-size floor
pub observer: Option<Arc<dyn RowObserver>>, // per-row hooks
}
Builder methods:
ExecutionPlan::new()
.rename("old", "new")
.drop("field")
.drop_many(["a", "b"])
.type_as("col", FieldType::Float64)
.dictionary("col")
.filter_eq("status", "active")
.filter_ne("status", "inactive")
.filter_compare("price", CompareOp::Gt, "cost")
.schema_order(["a", "b", "c"])
.with_auto_dict(true)
.with_dict_threshold(0.05)
.with_dict_max_size(256)
.with_strict_types(true)
.with_max_split_chunks(1000)
.with_min_chunk_bytes(512 * 1024)
From the Python side
Each builder method corresponds to a keyword argument on
Source.__init__ (or a fusable pipeline stage whose _plan_kwargs()
produces the same key). Adapters re-export the stages, so users write
crxml.FilterRows, never rypipe.stages.FilterRows:
| Rust | Python (crxml) |
|---|---|
.rename(old, new) |
field_mapping={"old": "new"} or crxml.RenameFields |
.drop() / .drop_many() |
drop_fields=[...] or crxml.DropFields |
.type_as(col, ty) |
field_types={"col": "float64"} or crxml.CastTypes |
.dictionary(col) |
dictionary_columns=["col"] |
.filter_eq(...) / .filter_ne(...) / .filter_compare(...) |
filter={...} spec dict or crxml.FilterRows |
.schema_order([...]) |
schema=["a", "b", "c"] |
.with_auto_dict(true) |
auto_dict=True |
.with_strict_types(true) |
strict_types=True |
.with_max_split_chunks(n) |
max_split_chunks=n or chunks=n |
.with_min_chunk_bytes(n) |
min_chunk_bytes=n |
.with_observer(Arc::new(obs)) |
observer={"on_row_rejected": fn, ...} |
import crxml
table = (
crxml.CrystalXMLSource(
"data.xml", row_tag="Details", field_mapping={"usr": "user"}
)
| crxml.FilterRows(field="status", op="==", value="active")
| crxml.RenameFields({"old": "new"})
| crxml.to_arrow()
)
The pipeline's fusion layer merges stage _plan_kwargs() into these
source kwargs and passes them to the adapter's Rust read_* function
as plain kwargs, which builds this ExecutionPlan.
FieldType¶
pub enum FieldType {
String,
Int64,
Float64,
Boolean,
Dictionary,
Date32,
Timestamp(TimeUnit, Option<Box<str>>),
Decimal128(u8),
}
impl std::str::FromStr for FieldType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err>;
// Recognized: "string", "int64", "float64", "bool", "boolean",
// "dictionary", "date32", "timestamp", "timestamp[s]", "decimal128",
// "decimal128(scale)", etc.
}
CompareOp¶
pub enum CompareOp { Gt, Lt, Ge, Le, Eq, Ne }
impl std::str::FromStr for CompareOp {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err>;
// Recognized: ">"|"gt", "<"|"lt", ">="|"ge", "<="|"le", "=="|"eq", "!="|"ne"
}
ArithOp¶
FilterPredicate¶
Strip requires mode: TrimMode::Both, TrimMode::Start, or TrimMode::End.
Use Both for the previous behavior of trimming both ends.
pub enum FilterPredicate {
// Value comparison
Equal { field: String, value: String },
NotEqual { field: String, value: String },
Compare { field_a: String, op: CompareOp, field_b: String },
CompareLiteral { field: String, op: CompareOp, value: String },
// String predicates
StartsWith { field: String, value: String },
EndsWith { field: String, value: String },
Contains { field: String, value: String },
Strip { field: String, mode: TrimMode, op: CompareOp, value: String },
Lower { field: String, op: CompareOp, value: String },
Upper { field: String, op: CompareOp, value: String },
Replace { field: String, old: String, new: String, op: CompareOp, value: String },
Length { field: String, op: CompareOp, value: String },
// Membership
In { field: String, values: Vec<String> },
NotIn { field: String, values: Vec<String> },
// Type/null predicates
IsNull { field: String },
IsType { field: String, field_type: FieldType },
// Arithmetic
ArithmeticCompare {
field: String,
arith_op: ArithOp,
arith_value: f64,
cmp_op: CompareOp,
cmp_value: String,
},
// Boolean
NotField { field: String },
Always(bool),
// Regex
Regex { field: String, re: RegexSpec },
// Logical combinators
And(Box<FilterPredicate>, Box<FilterPredicate>),
Or(Box<FilterPredicate>, Box<FilterPredicate>),
Not(Box<FilterPredicate>),
}
/// Compiled regex plus its pattern.
pub struct RegexSpec {
pub pattern: String,
pub compiled: regex::Regex,
}
impl RegexSpec {
/// Compile a regex pattern. Returns an error if the pattern is invalid
/// or the compiled automaton exceeds 1 MiB.
pub fn new(pattern: impl Into<String>) -> Result<Self, regex::Error>;
}
impl FilterPredicate {
pub fn all(a: Self, b: Self) -> Self;
pub fn any(a: Self, b: Self) -> Self;
pub fn not(inner: Self) -> Self;
}
From the Python side
Predicates serialize to a plain dict spec (the filter= kwarg), so the
same filter reads identically from both languages. Three ways to build
one:
# 1. Keyword form (a positional arg is a plain callable, not fusable)
crxml.FilterRows(field="status", op="==", value="active")
# 2. Expression API (polars-style; same spec, composes with &, |, ~)
# (adapters re-export col; users never import rypipe.expr)
crxml.FilterRows((crxml.col("age") >= 18) & crxml.col("name").startswith("A"))
# 3. Logical combinators over keyword-form filters
crxml.FilterRowsAll( # {"and": [spec, spec]}
crxml.FilterRows(field="a", op=">", value="1"),
crxml.FilterRows(field="b", op="==", value="x"),
)
crxml.FilterRowsNot( # {"not": spec}
crxml.FilterRows(field="status", op="==", value="active")
)
The spec dict is what FilterRows._plan_kwargs() returns and what the
adapter's Rust kwargs hand to execution_plan_from_kwargs.
Extending expressions: Python libraries can add new spec producers
freely: any object with a _to_spec() method returning a spec dict can
be passed as FilterRows(predicate=obj), and Predicate composes with
the standard &/|/~. Adding a genuinely new operator is a Rust
change: a new FilterPredicate variant in
rypipe-core (plan.rs) plus a parsing arm in
rypipe-python/src/plan_kwargs.rs (parse_filter_spec). Until both
land, the spec language cannot express the operator and expression
construction raises instead of silently falling back to Python. See
Expression filters.
Pipeline¶
Orchestrates splitting, parsing, and export.
pub struct Pipeline<S, P> {
splitter: S,
parser: P,
plan: Arc<ExecutionPlan>,
}
impl<S, P> Pipeline<S, P>
where
S: Splitter + Clone,
P: RecordParser + Clone,
{
pub fn new(splitter: S, parser: P) -> Self;
pub fn with_plan(self, plan: ExecutionPlan) -> Self;
// Single-threaded
pub fn read_bytes(&self, bytes: &[u8]) -> Result<RecordBatch>;
pub fn read_path(&self, path: impl AsRef<Path>, use_mmap: bool, prefault: bool) -> Result<RecordBatch>;
// Parallel
pub fn read_bytes_par(&self, bytes: &[u8], num_chunks: usize) -> Result<Vec<RecordBatch>>;
pub fn read_path_par(&self, path: impl AsRef<Path>, num_chunks: usize, use_mmap: bool, prefault: bool) -> Result<Vec<RecordBatch>>;
// Streaming
pub fn read_bytes_stream(&self, bytes: &[u8], budget: MemoryBudget) -> Result<Vec<RecordBatch>>;
pub fn read_path_stream(&self, path: impl AsRef<Path>, budget: MemoryBudget, prefault: bool) -> Result<Vec<RecordBatch>>;
// Streaming with callback (constant memory, no batch collection)
pub fn read_bytes_stream_consumer<C>(&self, bytes: &[u8], budget: MemoryBudget, consumer: C) -> Result<()>
where C: BatchConsumer;
pub fn read_path_stream_consumer<C>(&self, path: impl AsRef<Path>, budget: MemoryBudget, prefault: bool, consumer: C) -> Result<()>
where C: BatchConsumer;
// Parallel streaming
pub fn read_path_stream_par(&self, path: impl AsRef<Path>, budget: MemoryBudget, num_chunks: usize, prefault: bool) -> Result<Vec<RecordBatch>>;
}
From the Python side
End users import the adapter, not rypipe. With the reference
adapter crxml, the execution modes map to
CrystalXMLSource options:
| Rust | Python (crxml) |
|---|---|
read_path |
crxml.CrystalXMLSource(path, row_tag="Row").to_arrow() |
read_path_par |
CrystalXMLSource(..., engine="parallel").to_arrow() |
read_path_stream (bounded memory) |
src.iter_record_batches(memory="64MiB", batch_size=...) |
with_plan(plan) |
source kwargs + fused crxml.* stages |
src = crxml.CrystalXMLSource("big.xml", row_tag="Row")
for batch in src.iter_record_batches(memory="64MiB"):
writer.write_batch(batch)
The return value crosses the boundary once as a pyarrow.Table /
RecordBatch via the Arrow PyO3 bridge; per-chunk Rust results become
per-chunk Python objects on streaming paths.
MemoryBudget¶
Batch-sizing target for streaming reads. Input storage, buffer capacity, worker queues, and retained output add memory; this value does not cap process RSS.
BatchConsumer¶
Callback trait for streaming reads without batch collection.
FrozenSchema¶
Resolved schema for a parse run.
pub struct FrozenSchema { /* fields private */ }
impl FrozenSchema {
pub fn from_plan(names: &[&str], plan: &ExecutionPlan) -> Self;
pub fn from_partial_plan(names: &[&str], plan: &ExecutionPlan) -> Self;
pub fn from_discovered(names_in_order: &[String], plan: &ExecutionPlan) -> Self;
pub fn num_columns(&self) -> usize;
pub fn column_names(&self) -> &[Arc<str>];
pub fn column_types(&self) -> &[FieldType];
pub fn resolve(&self, raw_name: &str) -> Option<u32>;
pub fn is_exact(&self) -> bool;
}
// Schema discovery helpers (public functions)
pub fn dynamic_window_count(file_size: u64) -> usize;
pub fn dynamic_window_size(file_size: u64) -> usize;
pub fn layout_signature(bytes: &[u8], opts: &DiscoveryOpts) -> (u64, u64);
pub fn insert_schema_cache(sig: (u64, u64), order: Arc<Vec<String>>);
pub fn clear_schema_cache();
pub fn schema_cache_stats() -> (u64, u64);
DiscoveryOpts¶
Controls schema discovery behavior.
pub struct DiscoveryOpts {
pub full_scan_threshold: u64, // default: 128 MiB
pub windows: usize, // default: 16
pub window_bytes: usize, // default: 2 MiB
pub always_scan_tail: bool, // default: true
}
Internal to the engine: discover_schema() builds these from file size
via the dynamic window heuristics; callers do not construct them.
Python bindings¶
execution_plan_from_kwargs¶
pub fn execution_plan_from_kwargs(
field_mapping: Option<HashMap<String, String>>,
drop_fields: Option<Vec<String>>,
filter: Option<&Bound<'_, PyAny>>,
field_types: Option<HashMap<String, String>>,
dictionary_columns: Option<Vec<String>>,
schema: Option<Vec<String>>,
auto_dict: bool,
auto_dict_threshold: Option<f64>,
auto_dict_max_size: Option<usize>,
strict_types: bool,
max_split_chunks: Option<usize>,
observer: Option<&Bound<'_, PyAny>>,
) -> PyResult<ExecutionPlan>;
Export functions¶
pub fn record_batches_to_pyarrow_table(
py: Python<'_>,
batches: &[RecordBatch],
) -> PyResult<PyObject>;
pub fn record_batch_to_pyarrow(
py: Python<'_>,
batch: &RecordBatch,
) -> PyResult<PyObject>;
Exceptions¶
| Exception | Parent | Meaning |
|---|---|---|
ParseError |
PyException |
File could not be parsed. |
XmlError |
ParseError |
XML-specific parse error. |
PlanError |
PyException |
Invalid plan kwargs. |
MergeError |
PyException |
Schema mismatch between chunks. |
Error enum¶
pub enum Error {
Utf8(simd_utf8::Utf8Error), // invalid UTF-8
Io(std::io::Error), // I/O errors
Plan(String), // invalid execution plan
Merge(String), // chunk merge conflict
Arrow(String), // Arrow construction failure
Parser(String), // parser misbehavior (adapter bug)
Lifetime(String), // borrowed value lifetime violation
}
Error::Parser and Error::Lifetime are reserved for adapter bugs.
Parser signals that the adapter violated the parse contract (e.g., emitted
values outside begin_row/end_row). Lifetime signals that a borrowed
value outlived the chunk's byte buffer.
rypipe-test crate¶
Property-based testing helpers for adapter development.
Strategies¶
Proptest strategies for generating test data:
| Strategy | Generates |
|---|---|
arb_field_name() |
Random field names (1-20 chars, ASCII) |
arb_field_value() |
Random field values (empty, ASCII, Unicode) |
arb_record() |
Random Vec<(String, String)> records |
arb_records() |
Random Vec<Vec<(String, String)>> with 1-100 records |
arb_malformed_utf8() |
Byte sequences with invalid UTF-8 |
arb_nested_quotes() |
Strings with nested quote characters |
Fixtures¶
use rypipe_test::fixtures::{MALFORMED_UTF8, NESTED_QUOTES, EMPTY_VALUES};
use rypipe_test::fixtures::long_column_name;
| Fixture | Description |
|---|---|
MALFORMED_UTF8 |
5 byte sequences that fail UTF-8 validation |
NESTED_QUOTES |
5 strings with nested single/double quotes |
EMPTY_VALUES |
5 empty/blank value variants |
long_column_name(len) |
Generate a column name of the given length |
Helpers¶
use rypipe_test::{parse_test_bytes, assert_batches_equal};
use rypipe_test::{KeyValueParser, NewlineSplitter};
| Helper | Purpose |
|---|---|
parse_test_bytes(bytes) |
Parse bytes into a TableBuilder |
assert_batches_equal(batches, expected_rows) |
Assert row count and non-empty batches |
KeyValueParser |
key=value parser for test adapters |
NewlineSplitter |
Newline splitter for test adapters |