Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion .github/workflows/test-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
# the assertion below fails loudly instead of leaving a confusing pytest crash.
uv venv .venv --python cpython-${{ matrix.python }}
uv run --python .venv --no-project python -c "import sysconfig, sys; sys.exit('picked the free-threaded interpreter for .venv; expected the GIL build' if sysconfig.get_config_var('Py_GIL_DISABLED') else 0)"
uv pip install --python .venv dist/*.whl pytest
uv pip install --python .venv dist/*.whl pytest pandas
- name: pytest
shell: bash
working-directory: crates/fec-py
Expand Down Expand Up @@ -72,3 +72,51 @@ jobs:
with:
name: wheel-${{ matrix.os }}
path: crates/fec-py/dist/*.whl

test-python-freethreaded:
# D8 canary: build against the free-threaded interpreter and run the suite.
# Non-blocking on purpose β€” no free-threaded wheel ships until abi3t exists
# (see build-python-bindings.yml, which stays 3.11/3.14 GIL-only).
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: ". -> target"
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
- name: Build against 3.14t
shell: bash
working-directory: crates/fec-py
run: |
uv venv .venv --python cpython-3.14t
uv run --python .venv --no-project python -c "import sysconfig, sys; sys.exit(0 if sysconfig.get_config_var('Py_GIL_DISABLED') else 'expected the free-threaded interpreter')"
# The crate is abi3-py311 (Cargo.toml), but the limited API doesn't
# exist on free-threaded builds, so pyo3-build-config treats abi3 as
# a no-op there and maturin warns "abi3 does not yet support CPython
# 3.14t ... build artifacts will be version-specific" (verified
# locally 2026-09-18). The result is a version-specific cp314-cp314t
# wheel, not an abi3 one β€” expected, and fine since this job never
# ships a wheel.
uvx maturin@1.15.0 build --release --out dist -i .venv/bin/python
- name: Install + import with warnings as errors
shell: bash
working-directory: crates/fec-py
run: |
# A module that hasn't declared itself free-threading-safe makes 3.14t
# re-enable the GIL and emit a RuntimeWarning; treat that as a failure
# of this canary. Verified locally 2026-09-18: the module imports
# cleanly under `-W error::RuntimeWarning` on 3.14t as-is (pyo3 >=
# 0.28 defaults modules to GIL-not-used), so `#[pymodule(gil_used =
# false)]` is NOT added β€” it would be a no-op here.
uv pip install --python .venv dist/*.whl pytest
uv run --python .venv --no-project python -W error::RuntimeWarning -c "import libfec_parser; print(libfec_parser.__version__)"
# pandas does ship a 3.14t wheel (verified locally 2026-09-18: `uv pip
# install pandas` resolves prebuilt numpy/pandas wheels, no compile),
# so no fallback/deselect is needed here.
uv pip install --python .venv pandas
- name: pytest
shell: bash
working-directory: crates/fec-py
run: uv run --python .venv --no-project pytest tests -v -rs
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,8 @@ libfec fastfec 1805248.fec output/

Now,

## Python bindings

`benchmarks/python/bench.py` benchmarks the `libfec_parser` Python bindings against this
same 91 MB filing; run it with `make bench` from `crates/fec-py`.

242 changes: 242 additions & 0 deletions benchmarks/python/bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
"""Benchmarks for the ``libfec_parser`` Python bindings.

Moved and extended from the ``plans/python/probes/bench.py`` probe (Phase 2,
ticket 19): each scenario below parses the 91 MB ``1805248.fec`` benchmark
filing (gitignored; not present in a fresh checkout) and reports rows parsed,
wall time, and peak RSS.

Every scenario runs in its **own subprocess** β€” ``resource.getrusage(...).ru_maxrss``
is a process-wide high-water mark, so measuring several scenarios in one
interpreter would have each one see the high-water mark of everything before it.

Usage (from the repo root, with the ``libfec_parser`` dev environment active)::

uv run python benchmarks/python/bench.py
uv run python benchmarks/python/bench.py --filing /path/to/other.fec
uv run python benchmarks/python/bench.py --only open,read

or, from ``crates/fec-py``: ``make bench``.
"""
from __future__ import annotations

import argparse
import subprocess
import sys
import textwrap
from pathlib import Path

DEFAULT_FILING = Path(__file__).resolve().parents[1] / "1805248.fec"

# Every scenario body runs after this preamble, with `sys.argv[1]` set to the
# filing path. It defines `_peak_mb()` (None on Windows, where `resource` does
# not exist) and prints one line other code never needs to parse around:
# `RESULT <rows> <seconds> <rss_or_NA> [extra]` or `MISSING <reason>`.
_PREAMBLE = """
import sys, time

def _peak_mb():
if sys.platform == "win32":
return None
import resource
r = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
# ru_maxrss is bytes on macOS, kilobytes on Linux.
return r / 1e6 if sys.platform == "darwin" else r / 1e3

path = sys.argv[1]
"""

# Each scenario is (name, description, subprocess body). The body must set
# `rows` and use `t0 = time.perf_counter()` right before the timed work, then
# print the RESULT/MISSING line itself (so it controls exactly what is timed).
_SCENARIOS: list[tuple[str, str, str]] = [
(
"open",
"sum(1 for _ in libfec_parser.open(p))",
"""
import libfec_parser
t0 = time.perf_counter()
rows = sum(1 for _ in libfec_parser.open(path))
dt = time.perf_counter() - t0
print(f"RESULT {rows} {dt} {_peak_mb()}")
""",
),
(
"open_sb",
'sum(1 for _ in libfec_parser.open(p).rows("SB"))',
"""
import libfec_parser
t0 = time.perf_counter()
rows = sum(1 for _ in libfec_parser.open(path).rows("SB"))
dt = time.perf_counter() - t0
print(f"RESULT {rows} {dt} {_peak_mb()}")
""",
),
(
"open_bytes",
"same as open() over p.read_bytes()",
"""
import libfec_parser
data = open(path, "rb").read()
t0 = time.perf_counter()
rows = sum(1 for _ in libfec_parser.open(data))
dt = time.perf_counter() - t0
peak = _peak_mb()
extra = "NA" if peak is None else (peak - len(data) / 1e6)
print(f"RESULT {rows} {dt} {peak} {extra}")
""",
),
(
"read",
"len(libfec_parser.read(p).rows)",
"""
import libfec_parser
t0 = time.perf_counter()
rows = len(libfec_parser.read(path).rows)
dt = time.perf_counter() - t0
print(f"RESULT {rows} {dt} {_peak_mb()}")
""",
),
(
"read_20x",
"read() once, then 20x len(f.rows) -- the N2 regression guard",
"""
import libfec_parser
t0 = time.perf_counter()
f = libfec_parser.read(path)
for _ in range(20):
rows = len(f.rows)
dt = time.perf_counter() - t0
print(f"RESULT {rows} {dt} {_peak_mb()}")
""",
),
(
"dataframe",
"pd.DataFrame(read(p).rows).shape",
"""
try:
import pandas as pd
except ImportError:
print("MISSING pandas not installed")
else:
import libfec_parser
t0 = time.perf_counter()
df = pd.DataFrame(libfec_parser.read(path).rows)
dt = time.perf_counter() - t0
print(f"RESULT {df.shape[0]} {dt} {_peak_mb()}")
""",
),
(
"compat",
"libfec_parser.fecfile.from_file(p) (unchanged until Phase 3)",
"""
from libfec_parser import fecfile
t0 = time.perf_counter()
d = fecfile.from_file(path)
rows = sum(len(v) for v in d["itemizations"].values())
dt = time.perf_counter() - t0
print(f"RESULT {rows} {dt} {_peak_mb()}")
""",
),
(
"real",
"PyPI fecfile.from_file(p)",
"""
try:
import fecfile
except ImportError:
print("MISSING fecfile not installed")
else:
t0 = time.perf_counter()
d = fecfile.from_file(path)
rows = sum(len(v) for v in d["itemizations"].values())
dt = time.perf_counter() - t0
print(f"RESULT {rows} {dt} {_peak_mb()}")
""",
),
(
"real_iter",
"PyPI fecfile.iter_file(p)",
"""
try:
import fecfile
except ImportError:
print("MISSING fecfile not installed")
else:
t0 = time.perf_counter()
rows = sum(1 for _ in fecfile.iter_file(path))
dt = time.perf_counter() - t0
print(f"RESULT {rows} {dt} {_peak_mb()}")
""",
),
]


def _format_peak(value: str) -> str:
if value == "NA":
return "n/a"
return f"{float(value):.1f}"


def _run_scenario(name: str, body: str, filing: Path) -> str:
"""Run one scenario's code in a fresh subprocess; format its Markdown row."""
code = _PREAMBLE + body
result = subprocess.run(
[sys.executable, "-c", textwrap.dedent(code), str(filing)],
capture_output=True,
text=True,
)
if result.returncode != 0:
return f"| {name} | ERROR | ERROR | {result.stderr.strip().splitlines()[-1:] or 'see stderr'} |"

line = ""
for candidate in result.stdout.splitlines():
if candidate.startswith(("RESULT", "MISSING")):
line = candidate
if not line:
return f"| {name} | ERROR | ERROR | no RESULT/MISSING line in output |"

if line.startswith("MISSING"):
reason = line[len("MISSING "):].strip()
return f"| {name} (n/a: {reason}) | n/a | n/a | n/a |"

parts = line.split()
rows, seconds, peak = parts[1], parts[2], parts[3]
peak_str = _format_peak(peak)
if name == "open_bytes" and len(parts) > 4:
delta = parts[4]
peak_str = f"{peak_str} ({'+' if float(delta) >= 0 else ''}{float(delta):.1f} over bytes)"
return f"| {name} | {rows} | {float(seconds):.2f} s | {peak_str} MB |"


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--filing", type=Path, default=DEFAULT_FILING)
parser.add_argument(
"--only",
type=str,
default=None,
help="comma-separated scenario names to run (default: all)",
)
args = parser.parse_args()

if not args.filing.exists():
print(f"error: filing not found: {args.filing}", file=sys.stderr)
raise SystemExit(1)

only = set(args.only.split(",")) if args.only else None
scenarios = [s for s in _SCENARIOS if only is None or s[0] in only]
if only is not None:
missing = only - {s[0] for s in _SCENARIOS}
if missing:
print(f"error: unknown scenario(s): {', '.join(sorted(missing))}", file=sys.stderr)
raise SystemExit(1)

print(f"Filing: {args.filing} ({args.filing.stat().st_size / 1e6:.1f} MB)\n")
print("| name | rows | seconds | peak MB |")
print("|---|---|---|---|")
for name, _description, body in scenarios:
print(_run_scenario(name, body, args.filing))


if __name__ == "__main__":
main()
14 changes: 11 additions & 3 deletions crates/fec-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub struct FilingHeader {
}

impl FilingHeader {
fn from_record(hdr: csv::StringRecord) -> Result<Self, FilingHeaderError> {
pub fn from_record(hdr: csv::StringRecord) -> Result<Self, FilingHeaderError> {
let record_type = header_get_field!(hdr, 0, "record_type");
let ef_type = header_get_field!(hdr, 1, "ef_type");
let fec_version = header_get_field!(hdr, 2, "fec_version").trim().to_owned();
Expand Down Expand Up @@ -323,10 +323,13 @@ impl<R: Read> Filing<R> {

/// Return the next itemization row in the filing, or None if at end of file.
pub fn next_row(&mut self) -> Option<Result<FilingRow, FilingRowReadError>> {
let (record, original_size) = match self.records_iter.next() {
let (record, original_size, line) = match self.records_iter.next() {
Some(Ok(record)) => {
let n = record.as_slice().len();
(StringRecord::from_byte_record_lossy(record), n)
// `from_byte_record_lossy` drops the position when the record is not
// valid UTF-8, so read the line number off the `ByteRecord` first.
let line = record.position().map(|p| p.line()).unwrap_or(0);
(StringRecord::from_byte_record_lossy(record), n, line)
}
Some(Err(err)) => return Some(Err(FilingRowReadError::CsvError(err))),
None => return None,
Expand Down Expand Up @@ -354,6 +357,7 @@ impl<R: Read> Filing<R> {
Err(e) => return Some(Err(FilingRowReadError::CsvError(e))),
};
let original_size = record.as_slice().len();
let line = record.position().map(|p| p.line()).unwrap_or(0);
let record = StringRecord::from_byte_record_lossy(record);
let row_type = record
.get(0)
Expand All @@ -363,6 +367,7 @@ impl<R: Read> Filing<R> {
row_type,
record,
original_size,
line,
}));
}
None => return None,
Expand All @@ -385,6 +390,7 @@ impl<R: Read> Filing<R> {
row_type,
record,
original_size,
line,
}))
}
}
Expand All @@ -403,6 +409,8 @@ pub struct FilingRow {
pub row_type: String,
pub record: StringRecord,
pub original_size: usize,
/// 1-based physical line of the row in the source file, or 0 if unknown.
pub line: u64,
}

#[cfg(test)]
Expand Down
5 changes: 4 additions & 1 deletion crates/fec-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ crate-type = ["cdylib"]
# "abi3-py311" tells pyo3 (and maturin) to build using the stable ABI with minimum Python version 3.11.
# "extension-module" is intentionally absent: maturin >= 1.9.4 sets PYO3_BUILD_EXTENSION_MODULE itself,
# and leaving it off keeps `cargo test -p libfec_parser` linkable.
pyo3 = { version = "0.29", features = ["abi3-py311"] }
pyo3 = { version = "0.29", features = ["abi3-py311", "jiff-02"] }
fec-parser = { path="../fec-parser" }
csv = "1.3"
# The workspace already resolves jiff 0.2.x via fec-parser; `jiff-02` converts
# `jiff::civil::Date` to `datetime.date` (limited-API safe under abi3-py311).
jiff = "0.2"
Loading
Loading