Skip to content

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 26 Jun 12:52
· 75 commits to main since this release
54aa961

Breaking

  • Pythonrecord.leader is now a property, not a method (use record.leader,
    not record.leader()). Leader constructs from a 24-character string or as
    Leader() with properties assigned; the old field keyword arguments
    (Leader(record_type=..., ...)) are removed.
  • PythonRecord.get_fields() returns control fields in record order (was
    fixed ascending-tag order).
  • RustMarcError and the PipelineError, RecoveryMode, ValidationLevel,
    EncodingAnalysis, RdfFormat, and IndicatorValidation enums are
    #[non_exhaustive]: exhaustive matches need a wildcard arm, and external
    construction goes through the new MarcError constructors.
  • Rustfrom_path constructors now return MarcReader<BufReader<File>> (was
    MarcReader<File>).
  • Rustrecovery::try_recover_record is removed; truncated-record salvage now
    runs through the ISO 2709 skeleton walk.
  • Build — MSRV is now Rust 1.88; both crates use edition 2024.

Added

  • Community files: issue and pull request templates, and a security policy (SECURITY.md)
    pointing at GitHub private advisories.
  • parse_record_from_bytes: parse one complete MARC record from in-memory bytes with
    no reader I/O and no per-record copies. The Python MARCReader read path now uses it,
    collapsing the former chain of per-record buffer copies between the source and the
    parser to a single pymarc-compatibility stash (current_chunk).
  • parse_record_from_shared_bytes: parse from a buffer the caller already holds behind an
    Arc, without taking ownership. The Python MARCReader uses it so the current_chunk
    stash shares one allocation with the parser instead of cloning, and current_chunk is now
    read lazily — iterating without inspecting it copies no record bytes into Python.
  • Criterion benches for the serialization formats that had no CI perf signal — CSV, MODS,
    Dublin Core, BIBFRAME (Turtle), and MARC-in-JSON (both directions for MODS and
    MARC-in-JSON) — plus a single-thread parser-pool bench that tracks the
    producer-consumer pipeline's per-record cost deterministically under simulation.
  • The pymarc parity oracle now executes in CI and check.sh instead of silently skipping:
    a new oracle extra pins pymarc in uv.lock (Dependabot adjudicates behavior changes on
    bump PRs), and the oracle extends beyond iteration shape to value-level comparisons —
    title, format_field(), value(), and as_marc() byte-equality over the 1k corpus.
  • MarcError::metadata() returns an ErrorMetadata snapshot of every structured field an
    error carries. The per-field accessors, Display/detailed() rendering, JSON output, and
    the Python exception mapping all read from this single per-variant table now; rendered and
    serialized output is unchanged.
  • MarcError (enum and variants) and the grower enums (PipelineError, RecoveryMode,
    ValidationLevel, EncodingAnalysis, RdfFormat, IndicatorValidation) are now
    #[non_exhaustive], so adding a variant or field is no longer a breaking change. Downstream
    matches need a wildcard arm; MarcError construction outside the crate goes through new
    public constructors (invalid_field, truncated_record, record_length_invalid,
    fatal_reader_error) and with_* positional setters.
  • copy.deepcopy() support for Record, Field, and Leader: deep copies are fully
    independent of the original (and of any record), while copy.copy() stays shallow, matching
    pymarc. Deep-copying a live field handle yields a detached snapshot of its current data.
  • A reproducible three-way benchmark (scripts/benchmark_comparison.py, with
    examples/benchmark_native.rs for the native column) and published figures
    (docs/benchmarks/): the Python wrapper reads ~7× pymarc per record, ~30× on the
    parallel bulk path, ~1.6× extract, and ~6.5× roundtrip, against a native-Rust
    ceiling of ~9×/~35× on the reference host. Replaces the prior "needs
    re-measurement" hedge.

Changed

  • The parallel parse helpers (parse_batch_parallel / parse_batch_parallel_limited) and the
    blocking ProducerConsumerPipeline reads now release the GIL during the parallel parse and the
    channel wait, so other Python threads keep running instead of being blocked for the duration.
    (The parallel helpers now take ownership of the buffer, required for sound GIL release.)
  • Record.get_fields() with no arguments is faster: it made one PyO3 call per control tag
    (nine) plus one per data field, and now fetches all control fields in a single call.
    Control fields come back in record order rather than fixed ascending-tag order (a
    difference only for records whose control fields are stored out of order; record order
    matches pymarc).
  • Iterating a MARCReader is faster: wrapping each parsed record no longer builds a
    throwaway inner _Record and two _Leader objects only to discard them, lifting
    per-record read throughput (path and bytes inputs alike). Output is unchanged.
  • Writing ISO 2709 records (MARCWriter and the authority/holdings writers) allocates less:
    each directory entry's length and start-position digits are written straight into the
    output buffer instead of through a per-field format!.
  • MARCXML deserialization and 880-linkage parsing no longer recompile their regexes on
    every call (hoisted to LazyLock statics). Parsing MARCXML records one at a time — e.g.
    parse_xml_to_array in a loop — is substantially faster; batch/whole-document parsing
    already amortized the cost and is unchanged.
  • Reading from a Python file-like object (MARCReader(open(path, "rb")), BytesIO, etc.)
    now reads the source in 256 KiB chunks and slices records out in Rust, instead of two
    file.read() calls plus a getattr("read") per record. The read method is bound once
    and each chunk is borrowed via PyBytes rather than copied. Output is unchanged; file-path
    and bytes inputs were already chunked and are unaffected.
  • Cargo.lock is now committed, so CI, wheel builds, and local checkouts resolve the
    same dependency versions; Dependabot manages version bumps as reviewable diffs.
  • Removed unused dependencies (bytes, nom, encoding_rs, csv, bindings-crate
    tempfile) and demoted example-only anyhow/flate2 to dev-dependencies, shrinking
    the published crate's dependency tree. check.sh now runs cargo machete to keep it so.
  • The version is now declared once, in [workspace.package]: both crates inherit it and
    pyproject.toml reads it via dynamic = ["version"]. Shared dependencies and
    rust-version are also workspace-inherited, so they cannot drift between crates.
  • BIBFRAME/RDF support is now behind the default-on bibframe cargo feature. Default
    builds and the Python wheel are unchanged; --no-default-features drops the
    oxrdf/oxrdfio dependency tree for MARC-only users.
  • Record parsing no longer copies every record's bytes into the error-diagnostics
    buffer: the parse buffer is shared by refcount instead, deleting a per-record
    alloc+memcpy that every read path paid so that the under-1% of records that error
    could render hex dumps. Error bytes_near output is unchanged.
  • Tuned the release profile: fat LTO, codegen-units = 1, and debuginfo stripping.
    Wheels get ~2-3% higher read throughput and a 21% smaller extension binary.
  • The Python leader surface now matches pymarc: record.leader is a property (was a
    method) and assignable from a Leader or 24-character string; Leader renders and
    compares as its 24-character MARC 21 string (str(), len(), == with str, repr)
    and constructs from one (Leader('00136nam a2200061 4500')). The oracle now
    compares leaders against pymarc value-for-value. Rust Leader gains a Display impl.
  • Migrated both crates to Rust edition 2024 and raised the MSRV from 1.87 to 1.88
    (let-chains require 1.88). The workspace now uses resolver v3, so dependency
    resolution respects rust-version instead of breaking the MSRV build on upgrades.
  • Releases now publish a source distribution and macOS universal2 wheels, so Intel Macs
    and platforms outside the wheel matrix can install mrrc (previously "no matching
    distribution"). PyPI metadata completed: real author, PEP 639 license expression,
    Typing :: Typed classifier, and project URLs (homepage, docs, changelog, issues).
  • File-path readers now buffer their reads (64 KiB): Python MARCReader(path) (and the
    authority/holdings readers) and Rust from_path previously issued two-plus read(2)
    syscalls per record. Rust from_path constructors now return
    MarcReader<BufReader<File>> instead of MarcReader<File>.
  • Updated pyo3 to 0.29, resolving RUSTSEC-2026-0176 (out-of-bounds read in nth/
    nth_back on PyList/PyTuple iterators in pyo3 ≤0.28).
  • Updated quick-xml to 0.40. MARCXML text and attribute decoding now applies XML 1.0
    end-of-line and attribute-value normalization explicitly (previously the XML 1.1 EOL
    set, which additionally folded NEL/LSEP — those are no longer normalized, matching
    MARCXML's XML 1.0 reality).
  • Corrected the declared Rust MSRV from 1.71 to 1.87 — the floor the dependency tree
    already required, so 1.71 never actually built; a new CI job now verifies the
    workspace compiles on the declared MSRV so the claim cannot drift again. The accurate
    MSRV unlocked clippy-driven cleanups: the MARC-8 tables now use std::sync::LazyLock
    (dropping the lazy_static dependency) and I/O error construction uses
    std::io::Error::other.
  • Truncated records no longer allocate or zero-pad a buffer of the leader's claimed
    length: the reader grows the record buffer as bytes arrive (8 KiB steps) and lenient
    salvage parses the short body directly, so allocation is bounded by actual input size
    instead of a claimed maximum-length record on a small stub.

Removed

  • Deleted four dead manual-profiling bench harnesses (profiling_harness,
    detailed_profiling, rayon_profiling, rayon_file_io_profiling). They produced no
    CI or CodSpeed signal, referenced documents that no longer exist, and one wrote
    scratch files to /tmp. The bench target now contains only criterion benches.
  • The placeholder mrrc binary (src/main.rs, a banner print) and the orphaned
    tests/create_sample_data.py (fixtures come from scripts/generate_benchmark_fixtures.py).
  • The docs.rs front page no longer embeds the README (whose CI badges and repo-relative
    links 404 off GitHub); it now renders the curated crate-level docs instead.
  • recovery::try_recover_record: truncated-record salvage now runs through the shared
    ISO 2709 skeleton's clamped directory walk, which slices fields at their data-area
    offsets — intact fields ahead of the truncation point are now recovered instead of
    dropped. Non-digit directory bytes on the salvage walk keep their InvalidField (E106)
    shape but now also count against the reader's recovered-error cap.

Fixed

  • Record is now iterable: for field in record yields each field as a wrapped mrrc.Field
    (control and data, in record order), matching pymarc. It previously raised TypeError.
  • Record.remove_field_at() now returns the mrrc.Field wrapper (with __getitem__ and the
    other pymarc conveniences) instead of the bare _mrrc.Field extension type.
  • copy.copy() of a Record no longer raises RecursionError: the wrapper's attribute
    delegation now stops cleanly when the inner record is absent during copy reconstruction.
  • Corrected Python documentation errors that broke copy-pasted code: the README and
    mrrc.read() examples called record.title() (it is a property); docs showed imports
    and reader constructors that do not exist in Python (MarcError,
    MARCReader.from_path(), .with_source()); the API reference stated the wrong
    recovery_mode default ("strict"; it is "permissive") and omitted the
    validation_level and max_errors keyword arguments.
  • Corrected documentation drift: the Rust API reference showed parse_batch_parallel with
    the wrong arity and a stale 5-variant MarcError table; parse_batch_parallel /
    parse_batch_parallel_limited were missing from the Python API reference; the release
    procedure understated the wheel count and used retired issue-tracker commands.

Dependencies

  • Bump ruff from 0.15.16 to 0.15.17
  • Bump syrupy from 5.3.1 to 5.3.2
  • Bump pytest from 9.0.3 to 9.1.0
  • Bump CodSpeedHQ/action from 4.17.0 to 4.17.5