Skip to content

Changelog

All notable changes to GFFBase are documented here.

The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

0.2.0 — 2026-08-15

Genuine gffutils 0.14 API and CLI parity, first-class support for discontinuous (multipart) GFF3 features, a compat/strict mode axis, transactional storage, and a release pipeline gated on validation.

0.1.1 is folded into this release. It was prepared but never tagged and never published, so its changes ship here; a changelog section for a version nobody can install would only mislead. Everything below is the delta from 0.1.0, which remains the only prior release.

Security

  • Invalid UTF-8 silently destroyed data, three different ways. Found by running 18 hostile inputs through both parser engines and diffing the outcomes. Nothing crashed; everything corrupted quietly, which is worse:

  • The attribute column was decoded with unwrap_or(""), so one bad byte replaced the whole column with an empty string. A gene whose Name carried a stray Latin-1 byte was stored with no attributes at all -- ID included, making the feature unreachable -- while the ingest reported success.

  • seqid and featuretype went through from_utf8_lossy, silently yielding a U+FFFD chromosome name that matches nothing in any later query.
  • Directives kept whatever bytes they had.

UTF-8 is now validated once for the whole line, before any field is read, and a failure is a GFFFormatError naming the line. Costs 2.9% of parser throughput, measured on MANE. Valid multi-byte UTF-8 (café, Ωmega) is unaffected -- it was always legal and still parses.

  • A NUL byte in the database path truncated it, and wrote the file anyway. DuckDB is C++ and takes the path as a C string, so it stops at the first NUL: FeatureDB("a\0b.duckdb") created a file called a -- a different path than the caller named. The stray-file cleanup then called os.unlink with the original path, which raises ValueError rather than OSError, so it escaped the except, left the truncated file on disk, and replaced the real diagnosis with a confusing one. Anything deriving a database path from untrusted input could write to a location it never named. Both FeatureDB and create_db now reject an embedded NUL before touching the filesystem.

  • helpers.make_query's raw-SQL slots are now documented as such. featuretype, limit and strand are bound parameters and order_by is whitelisted, but other and extra are interpolated verbatim -- they exist to carry the caller's own SQL, which is how upstream builds its relation joins. No gffbase code path routes caller data into either. The asymmetry is written down because the validation surrounding them makes it easy to assume otherwise. Audited alongside: execute(), path handling (traversal, null bytes, absolute paths) and every parameterised query surface -- no injection found through any of them.

  • SQL injection through order_by (affects 0.1.0, the only published release). The parameter was interpolated into the query, with anything outside a small set of known column names passed through verbatim as a deliberate escape hatch "for power users". DuckDB executes trailing statements, so

    db.all_features(order_by='start ASC; DROP TABLE attributes; SELECT …')

dropped the table and still returned rows — the trailing SELECT re-supplies the projection the result generator expects, so the call raises nothing and the damage is invisible from the call site. Any statement DuckDB accepts could be substituted, including COPY … TO to write local files. All four entry points were affected (all_features, features_of_type, children, parents); the joined paths had their own copy of the pass-through.

gffutils contains the same interpolation and is not exploitable, because SQLite refuses to execute more than one statement per call. gffbase inherited the API shape and lost that accidental protection when it changed storage engine.

order_by is now a whitelist, shared by both clause builders so no future entry point can reacquire an escape hatch.

  • SQL injection through set_pragmas (affects 0.1.0, the only published release). The same defect one method away, and quieter. FeatureDB.set_pragmas() built PRAGMA {name} = {value} by interpolating both halves of a caller-supplied dict, with the whole loop body inside except duckdb.Error: continue — so

    db.set_pragmas({"threads": "1; DROP TABLE attributes"})

dropped the table, and a payload that failed was swallowed too, leaving no trace anywhere. The swallow existed for a real reason — ported gffutils code passes constants.default_pragmas (synchronous, journal_mode, main.page_size, main.cache_size), none of which DuckDB has — but it could not tell "this is a SQLite pragma" from "DuckDB rejected this".

Names are now matched against DuckDB's own settings catalog and values rendered as SQL literals, so neither reaches the parser as syntax. Matching the live catalog rather than a hardcoded list means the check cannot go stale against a newer DuckDB, and the compatibility behaviour is unchanged but now deliberate: an unrecognized name is skipped and logged, not guessed at.

Unlike order_by, gffutils is vulnerable here too — its version calls cursor.executescript(), which exists precisely to run several statements. Verified against 0.14. Not reported upstream; that is a maintainer decision.

An audit of every remaining f-string SQL site found no third instance.

See docs/security/2026-sql-injection.md for both write-ups and mitigations for anyone who cannot upgrade.

  • The thread-count environment variable is now GFFBASE_THREADS. GFFUTILS2_THREADS predates the rename to gffbase and was the last GFFUTILS2_* name left; it still works, and the new name wins where both are set. Silently ignoring an existing job script's thread limit on a shared machine is worse than an untidy variable name.
  • GFFWriter.close() closed a stream it did not open. GFFWriter accepts either a path or an open file object, and closed both — so GFFWriter(sys.stdout).close() shut stdout down for the whole process and anything written afterwards raised ValueError: I/O operation on closed file. A writer owns only the handles it opened; a caller's stream is flushed and left alone. gffutils has the same defect.
  • The B-tree CI job was red. test_every_invariant_actually_ran required INV-8 to have run and report.skipped to be empty, but INV-8 compares bbox against the coordinates it was built from and therefore only exists when an R-tree does. Under GFFBASE_TEST_DISABLE_RTREE=1 the validator correctly records it as skipped, which the test read as a failure. The skip is the designed behaviour, so it is now what the test asserts.

Testing and CI

  • The parity gate could not pass on any machine but the one that generated it. Three independent causes, each making the manifest a description of the environment rather than of gffutils' API:

  • __firstlineno__ and __static_attributes__ are injected into every class body by CPython 3.13, so a manifest generated there could not validate on 3.11 or 3.12.

  • Members inherited from builtin bases (Exception.add_note, dict.keys) were recorded, and their introspectability changes between releases.
  • gffutils.contrib.plotting does from pybedtools.contrib.plotting import Track, and pybedtools sets Track = None when matplotlib is absent -- so the manifest recorded the presence of a name bound to None, and the inventory tracked a third-party optional dependency.

All three are excluded now. --check also refuses to compare a checkout-generated manifest against a pip-installed oracle (a checkout ships contrib/ and scripts/gffutils-cli, which pip does not package), and it prints what differs rather than only that something does -- "out of date" alone sends the reader to regenerate a file that may be correct.

  • test_api_parity.py failed at import on Python 3.10, the declared floor, because it uses tomllib (3.11+). Windows is tested at the floor, so this took out the parity module and both Windows cells. tomli is now a declared test dependency under an environment marker.

  • pybedtools_integration was measured at 9.8% coverage because its tests skip without the bedtools binary and no runner had one. CI installs it, so the module is exercised rather than counted as untested. The coverage gate is now per-platform, because what is reachable is per-platform: Windows cannot install pybedtools at all.

  • python -m gffbase is tested rather than assumed. It runs in a subprocess the tracer cannot follow, so it is excluded from measurement with the test that covers it named -- rather than left reading 0%.

Documentation

  • Documentation code is now executed by the test suite. tests/test_docs_snippets.py extracts every fenced python block from docs/, README.md and MIGRATION.md and runs it against the vendored fixtures, with each page treated as a notebook so its examples build on each other the way a reader experiences them. Skipping is opt-out and must state a reason, and the exemption count is a ratchet that may fall but never rise.

docs/cookbooks/index.md claimed every snippet had been validated while its own opening example opened two FeatureDB handles and closed neither -- teaching a lock leak. Nothing caught it because nothing ran it. Running them immediately found four more defects, listed below.

  • create_db() did not accept validation= or on_error=. Both axes are documented, both are supported by resolve_mode() and carried by IngestOptions, and the public entry point simply never forwarded them -- so the documented validation="ncbi", on_error="warn" audit combination raised TypeError: unhandled kwarg. They are now parameters, and documented.

  • docs/usage_gallery.md documented Feature.attributes_dict(), which does not exist on Feature (only on ParsedFeature, and not in gffutils at all). Replaced with dict(feature.attributes).

  • A troubleshooting snippet was not valid Python -- an except clause with no try. It is now a complete, executed example.

  • Four pages stated the wrong number of validator invariants (15; there are 14). tests/test_release_hygiene.py now derives the number from the registry, so prose cannot drift from it again.

  • MIGRATION.md linked docs/cli.md, which 404s on the rendered site, and quoted an unsourced "5-550x query speedups". It also never mentioned connection lifecycle, despite being the first page a porting user reads and despite DuckDB's exclusive lock being the one operational difference from SQLite that will surprise them. It now opens with it.

  • Public API docstrings. mkdocstrings publishes docstrings verbatim, so they are the API reference. count_features_of_type, featuretypes, seqids, all_features, features_of_type, delete, update, children_bp, bed12, iter_by_parent_childs, analyze, the Feature aliases and every GFFWriter method rendered as a bare signature with no description at all. All now document what they do, their parameters, what they return and what they raise, with examples on the high-traffic ones -- and carry the type annotations that Typing :: Typed promises.

  • The site moved to https://khchao.com/gffbase/. docs/CNAME is deleted, which is what was making GitHub Pages redirect the canonical path back to the retired gffbase.khchao.com subdomain. A release-hygiene test fails if the old host reappears anywhere.

  • New pages for everything a first-time reader needed and could not find: Installation, a linear Quickstart (every snippet executed before publishing), Compatibility & strict modes — a core concept previously explained only in passing — Connections & concurrency, Troubleshooting & FAQ, and Benchmark methodology. The changelog, contributing guide and security policy are now on the site rather than GitHub-only.

  • The performance page no longer contradicts itself. It opened with a preamble telling the reader that the numbers below it were stale, and then printed them. It is rewritten around generated tables, and the 35 KB PERFORMANCE_COMPARISON.md — whose §7 still asserted that legacy won on GFF3 ingest, which the same document's headline table denied — is retired.

  • The landing page stopped being a copy of the README. It duplicated it nearly verbatim, including the benchmark table, so the two drifted independently.

  • mkdocs build --strict runs in CI. CONTRIBUTING.md and the PR template both claimed it did. It did not: the only mkdocs invocation was gh-deploy, on main, after merge — so a broken link was found by the deploy rather than by the PR that introduced it.

  • Corrected counts that had gone stale: the test total, the coverage gate (96 % / 95 %, not 99 %), the schema table count in CONTRIBUTING.md, the corpus download size, and a test-tool list naming a dependency the project does not use. docs/usage_gallery.md taught the deprecated GFFUTILS2_THREADS environment variable as the primary name.

  • mkdocs is capped below 2.0. It removes the plugin system with no migration path, so an unpinned floor would let a routine pip install -e .[docs] break the documentation build with no change on our side.

Benchmarks and published numbers

The performance claims could not be reproduced from anything in the repository. This release rebuilds the harness so that they can be, and re-measures everything from scratch.

  • The results file no longer destroys itself. 06_mega.py built a payload containing only the corpora named by --only and wrote the whole file, so each targeted re-run silently deleted the others. benchmarks/out/06_mega.json ended up holding one of five corpora while the performance document named it as the provenance for all five, and four published rows had no surviving measurement. Results now merge by corpus key, and are written after every corpus rather than once at the end.

  • No number is extrapolated any more. A legacy run that exceeded the safety valve had wall_seconds = timeout × 2.0 written into it — a factor with no measurement behind it, whose own comment conceded there was no way to observe gffutils' progress. It was the sole source of the published "≥ 2 hr 30 min" legacy wall and the "≥ 32×" headline. A capped run now reports wall_seconds: null plus wall_seconds_lower_bound, the speedup becomes ingest_speedup_lower_bound, and tables render > 90 min / > N×. The renderer refuses outright to publish a row whose capped run still carries a wall time.

  • Every result carries its provenance. CPU model, physical and logical core count, RAM, OS, Python, DuckDB, PyArrow, gffutils, gffbase and rustc versions, the git commit and whether the tree was dirty, the region-sampling seed, and every run parameter. None of this was recorded before: the numbers carried their environment only as hand-typed prose that said "gffbase 0.1.0" throughout the 0.2.0 cycle, so nothing in the repository could have detected a regression.

  • Published tables are generated, not transcribed. The corpus table lived hand-copied in five files. tools/gen_benchmark_tables.py now renders it from the committed measurements into marked blocks, and tests/test_release_hygiene.py fails both when a table drifts from its data and when a benchmark row is written outside a generated block.

  • Results are committed. benchmarks/results/ is tracked, so a published number has checked-in evidence. The 0.1.0-era artifacts are preserved under benchmarks/results/archive/0.1.0/ with a README recording exactly which of them are unreliable and why.

  • The harness fits on a real disk. Five corpus pairs total ~38 GiB. Each is purged (including .wal / -journal sidecars) as soon as its numbers are recorded, holding the peak near 16 GiB; --keep-db retains one for the later stages, --no-purge keeps everything, and GFFBASE_BENCH_OUT redirects to another volume. Free space is checked before each corpus so a five-hour sweep fails in seconds rather than at hour four.

  • Stages 01–05 can run from a clean checkout. common.py pointed at a GENCODE v45 file that download_corpora.py had stopped fetching, present locally only as a symlink into the retired bench/ tree — so those stages worked on the maintainer's machine and raised FileNotFoundError everywhere else. They now use the v49 GFF3 corpus that the mega benchmark also uses, so the two harnesses can no longer report on different releases. The --reuse-cached flag and its hardcoded literals are gone, as is the cross-directory cache loader that presented old measurements as new ones.

Changed (breaking)

  • FeatureDB.bed12() now refuses a feature its blocks do not span, with gffutils' exact message ("End of last exon (600) does not match end of feature (1000)"). BED12's blockStarts are offsets from chromStart and the last block has to reach chromEnd, so emitting a line for a transcript whose exons stop short produces a record naming a range it does not cover -- and sends it into a genome browser. gffutils raises here; gffbase emitted the line.

Verified against real data before changing it: 5,000 of 5,000 MANE transcripts span their exons exactly, so no real annotation triggers this. The inputs that did were synthetic test fixtures declaring transcripts wider than their children, which have been corrected.

  • FeatureDB.region_batched() now raises on a region it cannot parse, instead of silently dropping it. The query_idx column is documented as the way to map results back to the input, and it was assigned over the rows that survived normalization — so a single unusable region renumbered every later query and the caller attributed whole result groups to the wrong input, with nothing raised on either side.

query_idx is now the item's index in regions as passed. The new on_invalid= argument selects the policy: "raise" (default) reports the offending position and value; "skip" restores the old dropping behaviour but leaves the surviving indices anchored to the input, so a gap is visible rather than closed up.

  • Minimum Python is now 3.10 (was 3.9), and 3.14 is supported. The wheel tag moves from abi3-py39 to abi3-py310. This removes the split dependency story: current DuckDB and PyArrow both require 3.10+, so one dependency set now covers the whole supported range. Dependency floors were raised to the versions actually tested (duckdb>=1.4.1, pyarrow>=18.1) from the previously untested duckdb>=1.0, pyarrow>=14.
  • PyO3 0.22 → 0.29. Migrated off the removed *_bound constructors, into_py, value_bound, and get_type_bound.

  • FeatureDB.schema is a method again, not a property. gffutils documents db.schema() and callers write it that way; as a property the documented call raised TypeError: 'str' object is not callable.

  • merge_strategy="error" is now the real default, so a file with duplicate IDs raises instead of loading with silently renamed rows. Ingestion previously renamed every duplicate to <id>__2 unconditionally, which made the documented default unreachable. create_unique now produces the oracle's <id>_1, <id>_2 rather than <id>__2, <id>__3, and no longer writes duplicates rows -- the oracle records a rename only when merge falls back to create_unique, because that table exists so a later merge can find the sibling rows.
  • DuplicateIDError, AttributeStringError and EmptyInputError now subclass ValueError. gffutils exports DuplicateIDError but raises a bare ValueError("Duplicate ID ..."), so real callers write except ValueError. Subclassing satisfies both the documented type and those callers instead of forcing a choice. FeatureNotFoundError is deliberately left on Exception.

Changed

  • rust/Cargo.lock is now committed. rust/Cargo.toml and MANIFEST.in both already claimed it was shipped; .gitignore excluded it. Dependency resolution for the published wheel therefore varied with build time and platform, contradicting the declared MSRV.
  • Coverage flags moved out of the default pytest invocation. addopts hard-required pytest-cov (absent from the dev extra) and made a bare pytest fail on coverage rather than on tests. Coverage is now applied explicitly in CI. pytest-cov was added to the dev extra.
  • Declared Rust MSRV raised from 1.69 to 1.83. The 1.69 claim was justified by a Cargo.lock that was gitignored and absent, so it had never been verified; with the lock now committed, the resolved graph includes flate2 1.1.x, which does not build on 1.69. 1.83 is the toolchain the lint CI job now compiles the whole crate with, so the floor is enforced rather than asserted. This affects source builds only — the published wheels are abi3 and need no Rust toolchain.
  • CI now enforces what it claimed to. The lint job runs ruff format --check (never run before, 42 files were drifting), includes benchmarks/ in its scope (70 errors were invisible), and finally invokes the clippy that was being installed and discarded. The test job runs the full cargo test rather than --lib, asserts native_available() instead of letting a broken extension build silently skip every Rust cell, and installs the all extra so the format="df" / format="polars" paths are exercised instead of skipped.
  • Ruff configuration gained a [tool.ruff.format] section and per-file E402 ignores for the bench/ and benchmarks/ entry-point scripts, which must bootstrap sys.path before importing. The whole tree is now ruff format clean.
  • UP006/UP007/UP035/UP045 are ignored for this release. Every module carries from __future__ import annotations, so ruff proposes PEP 585/604 rewrites regardless of target-version; applying them wholesale is not safe while 3.9 is supported. They are re-enabled in 0.2.0 when the floor moves to 3.10.

Added

  • FeatureDB.to_table() — the whole database, or a filtered slice of it, as one pyarrow.Table / pandas.DataFrame / polars.DataFrame. The columnar counterpart to all_features(): same filters (featuretype, limit, strand, order_by, completely_within), but no Feature object is constructed at any layer.
exons = db.to_table("exon", format="arrow")
df = db.to_table(["exon", "CDS"], format="df", limit="chr1:1-10000")

gffutils has no equivalent; the row-by-row path pays a Python object per row, which on a whole-genome corpus is millions of allocations and dominates everything else.

  • gffbase stats — a summary of what is actually in a database: feature counts broken down by type with percentages, sequence count and names, discontinuous-feature count, and how the database was built (format, mode, schema version, which spatial index). The first question anyone asks of an unfamiliar annotation, which otherwise means writing the same throwaway script every time.

  • The compatibility submodules are bound on the package namespace. gffutils binds attributes, bins, constants, create and version on its package, so gffutils.constants.always_return_list = True works after a plain import. gffbase bound none of them, so the one-line migration its own README advertises -- import gffbase as gffutils -- raised AttributeError on the first line of any script using one.

  • FeatureDB.method(), gffutils' alias for all_features(). It is a plain alias upstream too, and ported code calls it.

  • FeatureDB connection lifecycle: close(), context-manager support, and read_only=True. DuckDB holds an exclusive lock on the database file for the life of a writable connection, and there was no way to release it — no close, no __enter__/__exit__, no __del__. Two things were therefore impossible: replacing or deleting a database file while any handle existed (fatal on Windows), and reading one annotation database from several worker processes at once — which is the shape of every PyTorch DataLoader job.

with create_db("gencode.gtf.gz", "gencode.duckdb") as db:
    ...                                    # lock released at block exit

db = FeatureDB("gencode.duckdb", read_only=True)   # N workers may share it

close() is idempotent, and closes the connection only when this handle opened it: a caller who passes their own duckdb connection still owns it afterwards. create_db() transfers ownership explicitly, so the handle it returns does close the connection it created. The lazily-created segment cursor is always closed, because gffbase created it either way.

Under read_only=True every mutator (update, delete, add_relation, add_relations, analyze) raises ReadOnlyError, and upgrade="auto" is coerced to "never" so a v1 database cannot be migrated by a handle that promised not to write. set_pragmas() stays allowed: SET is session state, not a write to the file, and legacy callers pass constants.default_pragmas routinely. execute() is deliberately unguarded — it is the escape hatch, and DuckDB's own refusal names the statement it rejected.

Using a closed handle raises ClosedDatabaseError naming the call and the remedy, rather than DuckDB's bare ConnectionException. Both new exceptions subclass ValueError, so existing except ValueError handlers keep working.

  • FeatureDB accepts pathlib.Path. The constructor took str only and rejected everything else with TypeError: dbfn must be a path — while refusing an actual Path. create_db already accepted one, so the two entry points disagreed about their own documented type.

  • A gffbase command-line interface, registered as a console script and runnable as python -m gffbase. Ten commands: create, fetch, children, parents, region, search, rmdups, sanitize, plus gffbase-only validate and migrate.

Argument names and output shapes follow gffutils-cli so a script written against it keeps working. What does not follow it is how much of it runs. Of the thirteen commands gffutils-cli defines, five work: annotate and convert are defined but never registered, so they are unreachable from the shell; clean, common and region raise NotImplementedError; fetch raises TypeError because helpers.get_gff_db hands it a path string on its common branch and it indexes that as a database; and search raises AttributeError because it calls db.attribute_search(...), a method that exists nowhere in gffutils. All ten gffbase commands work.

Two conventions the tests enforce: feature output goes to stdout and progress to stderr, so gffbase rmdups in.gff > out.gff produces a valid file — upstream's rmdups prints its banner into the middle of the GFF it is writing — and a command that could not do what was asked says so in its exit status, not only in a message. gffbase validate --strict is the CI form.

Uses argparse, adding no dependency. gffutils makes argh and argcomplete hard runtime requirements of the library itself, so importing it at all pulls in a CLI framework. - FeatureDB.attribute_search(text, featuretype=None) — case-insensitive LIKE over attribute values. gffutils' CLI calls this method; gffutils does not have it. - The gffutils module surface is complete. All ten missing compatibility modules and every one of the 38 planned symbols are implemented — parity goes from 30/90 symbols (33%) to 87/90 (97%), with zero modules outstanding. deviations.toml no longer contains a single planned entry; what remains is a register of deliberate differences, each naming the test that pins it.

New modules: gffbase.bins, attributes, constants, convert, create, inspect, version, biopython_integration, pybedtools_integration and contrib.plotting. gffbase.helpers grows from one function to thirteen, including make_query, infer_dialect, sanitize_gff_db, canonical_transcripts and get_gff_db.

These are not import shims. Each is tested for behaviour (tests/test_compat_surface.py), bins is differential-tested against the oracle over 20,044 comparisons with zero mismatches, and the two documented global toggles are wired into live code rather than merely exported: constants.always_return_list changes what feature.attributes[key] returns, and constants.ignore_url_escape_characters turns percent decoding and re-encoding off together.

Several upstream defects are deliberately not reproduced, and each is recorded with its reason: helpers.get_gff_db returns a FeatureDB rather than sometimes a path string (the inconsistency that makes gffutils-cli fetch raise TypeError in its common case); helpers.dialect_compare works on dialects carrying an order list, where the oracle raises TypeError: unhashable type: 'list' on all of them; helpers.to_unicode actually decodes bytes, where a 2to3 artifact left the oracle's body unreachable; helpers.canonical_transcripts selects the longest transcript rather than the shortest and does not print to stdout; helpers.make_query validates a string order_by instead of interpolating it verbatim; and helpers.annotate_gff_db raises rather than silently doing nothing.

biopython_integration also fixes a genuine incompatibility: BioPython removed the SeqFeature(strand=...) argument and moved strand onto the location, so the oracle's call raises TypeError on any current install. The round trip here is exact for +, - and .. - create_db accepts an iterable of features, not just a path or a string. This is what _FeatureIterator exists for upstream and what helpers.sanitize_gff_db needs. - gffbase.interface.assign_child and no_children, the two symbols merge_all is built from upstream, plus FeatureDB.add_relations for linking many pairs with a single closure rebuild. - gffbase.helpers.merge_attributes, the sorted-set union of two attribute mappings that every derived-feature method needs. - FeatureDB.mode, .validation and .on_error, recovered from the database rather than assumed, and FeatureDB.derived_source — the source stamped on features gffbase derives rather than reads. It is gffutils_derived under mode="compat" so a ported script filtering on that string keeps working, and gffbase_derived under mode="strict", which reports honest provenance. - First-class discontinuous (multipart) GFF3 features. Several lines sharing one ID — how NCBI represents a split CDS — are now one logical feature. Previously the second line collided against the primary key and every merge strategy lost information. - Schema v2. features keeps one row per logical feature, with its coordinates widened to the envelope, and segments is a sparse side table holding physical lines only where n_segments > 1. Logical dedup stays structural (no DISTINCT anywhere), the R-tree stays the primary access path, storage grows with duplicate lines rather than corpus size, and a v1 → v2 migration touches zero feature rows. segments_all gives the one-row-per-input-line view. - MultipartFeature and FeatureSegment, both subclassing Feature and overriding none of __str__, __len__, __hash__, __eq__, __getitem__ or astuple — the compatibility surface is preserved by inaction. len() stays the envelope span; covered_length is the new quantity that excludes the gaps. Each segment carries its own phase, which is the reason the storage exists. - Fusing happens only under mode="strict", deliberately: gffutils' merge requires all eight non-attribute columns to match, so it never merges a genuine split feature. on_multipart_conflict chooses between raising MultipartConstraintError and splitting when lines sharing an ID disagree on seqid, source, featuretype or strand. - explode_segments=True on region_batched / children_batched / parents_batched yields one row per input line. Offered on the tabular APIs only — a FeatureSegment leaking into region() or children() would corrupt legacy consumers. - Measured on the FlyBase 50k corpus: 345 discontinuous features over 690 lines, and all 49,981 input lines round-trip byte for byte. - Feature.to_line(normalized=False) and to_lines(). The default is byte-faithful; normalized=True re-renders column 9 from the parsed mapping, which is what the oracle always does. - gffbase.migratemigrate_v1_to_v2() upgrades in place, in one transaction, idempotently, and is run automatically when a v1 database is opened (FeatureDB(..., upgrade="auto"|"never"|"error")). It is structural only and changes no query result, which is what makes doing it unasked acceptable. coalesce_multipart() is the separate, opt-in second step that re-fuses v1's x_1 rows — it changes results, so the caller has to ask. Tested against a real v1 database built by the pre-v2 code, committed as tests/data/v1/. - gffbase.validate — 14 post-ingest invariants, run automatically at the end of a strict-mode ingest and available as db.validate(). Every check is a single set-based query. The one that matters most is INV-5: a fused feature whose envelope is narrower than its segments simply stops being returned by region(), with nothing raised anywhere. - mypy runs clean over python/gffbase and is a CI gate, backing the Typing :: Typed classifier that 0.1.1 made honest by shipping py.typed. - Full create_db option fidelity. Twelve parameters were previously accepted and ignored; every one now changes behaviour or raises. - gffbase._options.IngestOptions validates the whole option set before any work starts -- in particular before the destination database is touched. - id_spec in all four shapes (attribute name, ordered list, per-featuretype mapping, callable), plus autoincrement:BASE and the :seqid: syntax for keying on a GFF column instead of an attribute. Defaults follow the dialect: "ID" for GFF3, {"gene": "gene_id", "transcript": "transcript_id"} for GTF -- which is what fixes gencode-v19.gtf yielding 26 features against the oracle's 21, and ID= yielding an empty-string primary key instead of protein_1. - All five merge_strategy values, and force_merge_fields with the oracle's ValueError on start/end and its warning on frame/strand. - transform (a falsy return drops the feature, and mutations are persisted), checklines, force_gff, force_dialect_check, from_string, dialect, _keep_tempfiles, pragmas, text_factory, verbose, and infer_gene_extent (deprecated: warns, then sets both disable_infer_* flags). - keep_order and sort_attribute_values now reach materialized features. They were stored on FeatureDB and never passed on, so both were inert. - Positional arguments work again, in the oracle's exact order. Every option had been made keyword-only, so any positional call written against gffutils raised TypeError. - An unrecognized keyword raises TypeError, matching the oracle's deprecation_handler, rather than being absorbed by **kwargs. - Attribute values now follow the oracle's empty-value rule exactly: a wholly empty value (ID=) yields the key with no values, while a multi-valued attribute keeps its empty parts (Parent=x, stays ["x", ""]). This matters because callers write if f.attributes["ID"]:, and [""] is truthy where [] is not. - A gffutils parity harness, pinned to upstream commit 6b84330: - tools/gen_parity_manifest.py generates a machine-readable inventory of the oracle's 19 modules and 90 public symbols, committed as tests/parity/gffutils_manifest.json so the structural checks run without gffutils installed. --check verifies it has not drifted. - tests/parity/deviations.toml records every difference, enforced in both directions: an undeclared gap fails, and so does a declaration that outlives the work it describes. Current state: 26 of 90 symbols (29%), with the remaining 10 modules and 30 symbols each declared and attributed to a delivering phase. - tests/parity/test_differential.py runs 30 vendored upstream fixtures through both libraries and compares feature ids, all nine GFF columns, attributes, dialect, directives, relations at every level, query results, serialization and failure modes. Known failures are xfail(strict=True) per fixture, so a fix cannot land unnoticed. - tests/data/upstream/ vendors the upstream corpus with full MIT attribution and provenance.

  • mode="compat" / mode="strict". Validation conflated two independent questions -- which rules apply, and what a violation does. They are now separate axes (validation, on_error) behind one switch, with compat as the default for create_db and strict for parse_gff. strict= keeps working for one deprecation cycle; passing it together with on_error= raises TypeError.
  • FeatureDB.warnings reports every specification violation tolerated while building the database, with kind, line number and message -- so a compat-mode caller gets exactly gffutils' data plus a diagnostic gffutils never offered.
  • docs/design/schema-v2.md records the design for schema v2, the multipart feature model, and this mode axis.

  • python/gffbase/py.typed. The Typing :: Typed classifier was declared but no PEP 561 marker shipped, so downstream type checkers saw nothing.

  • CODE_OF_CONDUCT.md — referenced by CONTRIBUTING.md but missing.
  • CITATION.cff — referenced by README.md but missing.
  • SECURITY.md and this CHANGELOG.md.
  • pandas, polars, fasta, and all optional-dependency extras. The format="df" and format="polars" code paths were advertised with no way to install what they need.
  • native, rtree, and slow pytest markers.

Fixed

  • CRLF files parsed differently on each engine. The Rust parser trimmed the trailing \r only after directive handling had run, so every directive from a Windows-line-ended GFF3 was stored as sequence-region chr1 1 1000\r, while the Python fallback -- which reads with universal newlines -- stored it clean. A blank \r\n line was also not empty by the time it was checked, so it fell through to the tab split and raised expected at least 9 tab-separated fields, found 1. Both engines now trim before anything inspects the line.

  • A coordinate past i64 was accepted by the Python fallback (Python ints are unbounded) and deferred the failure to INSERT time, far from the line that caused it, and only on one engine. It is now rejected at the line, as the Rust engine already did.

  • helpers.example_filename could not find the canonical example. FBgn0031208.gff -- the fixture every gffutils tutorial opens -- is vendored under tests/data/upstream/, but that directory was not on the search path, so the call failed in a source checkout with the file sitting on disk. The FileNotFoundError now also states that the fixtures ship in the sdist and the checkout but not the binary wheel, and names the install that works, rather than leaving the reader hunting for a typo.

  • "missing" in db raised instead of returning False. gffutils.FeatureDB defines neither __contains__ nor __iter__, so Python falls back to iterating via __getitem__, which raises on the first missing key -- the in operator failing on precisely the question it exists to answer. Declared as an intentional deviation.

  • region() crashed on every database containing a discontinuous feature. DuckDB's R-tree scan optimizer builds a projection map for the index scan, and any subquery sharing that WHERE clause throws its column numbering out — so pairing ST_Intersects with the multipart recheck aborted the planner with INTERNAL Error: Failed to bind column reference "file_order". Every region query against a RefSeq- or MANE-shaped corpus failed, on both region() and the _batched path.

It is not about how the correlation is written: qualified, unqualified and rewritten-as-a-semi-join all fail identically, and the B-tree path is unaffected. The spatial scan is now wrapped in a derived table and the recheck applied outside it, keeping the two apart. EXPLAIN confirms the plan still contains RTREE_INDEX_SCAN (Index: features_rtree), so the index does the same work — the recheck filters its output rather than being fused into it.

  • children_batched(level=None) silently returned a truncated result set. _batched_relation carried its own copy of the cache-vs-dynamic decision, and that copy was missing the overflow check: for level=None it asked only whether the closure was empty, never whether the hierarchy ran deeper than the cache. On a corpus deeper than max_depth it therefore chose the closure cache, which only reaches max_depth.

Measured on a six-level hierarchy with max_depth=2: children() returned all six descendants and children_batched() returned two. Two APIs answering the same question differently, neither raising — in the batched API the project recommends for bulk ML extraction. Both now route through _dispatch_relation; for a batch it asks "does any anchor overflow?" as a single query, so one overflowing anchor sends the whole batch to the dynamic CTE. parents_batched had the same defect.

  • delete() left orphaned rows in the transitive closure. It removed only the closure rows that named the deleted id as ancestor or descendant. A depth-2 row names neither when it merely routed through the deleted node — delete the mRNA from gene → mRNA → exon and gene → exon survives — so children(gene, level=None) kept returning the exons of a transcript that no longer existed. The closure is now rebuilt from edges, which is what update() already did.

  • update() and add_relations() left the dispatcher reading stale corpus statistics. _closure_max_depth and _n_multipart are read once when a handle opens and trusted for its lifetime, but both mutators rebuilt the closure without refreshing either the instance attributes or the meta rows — so relational routing kept deciding on the shape the database had before the write, and a handle opened later disagreed with the one that did it. All three mutators now refresh and persist both.

  • DataIterator never dispatched on its input. The factory handed everything to the file-path iterator, so a URL was opened as a filename and an in-memory feature iterable raised — while _UrlIterator and _FeatureIterator sat unreachable beneath it, their docstrings describing a dispatch that did not exist. gffutils.DataIterator accepts all of these. The dispatch now exists; _UrlIterator also unlinks its download (it used NamedTemporaryFile(delete=False) and never removed it, leaking a full copy of the annotation per call) and gained close() plus context-manager support. _FeatureIterator.__iter__ returned the underlying list's own iterator, bypassing __next__ and silently dropping transform.

  • cargo test could not link on macOS. extension-module was enabled unconditionally in rust/Cargo.toml and passed by maturin (features = ["pyo3/extension-module"]). Enabling it tells the linker not to link libpython, which is right for the wheel and fatal for a test binary, so every cargo test died in a wall of "symbol(s) not found for architecture arm64" — on the platform CONTRIBUTING.md tells contributors to run it. maturin still supplies the feature for the wheel.

  • .github/workflows/testpypi-release.yml could not be loaded by GitHub Actions. Its verify job declared name: and runs-on: twice. PyYAML's safe_load tolerates duplicate keys — last one wins — so a naive parse looked fine; the real parser rejects them, which means the release-candidate dress rehearsal had never been able to run. tests/test_release_hygiene.py now parses every workflow with a duplicate-key-strict loader.

  • The sdist shipped a MANIFEST.in naming five files it did not contain. maturin does not read MANIFEST.inpyproject.toml says so — so CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, CITATION.cff and MIGRATION.md were referenced and absent. Found by unpacking a real sdist and running the suite from it, where the two hygiene tests that exist to check exactly this failed. The full suite now passes from an unpacked sdist, so "users can rebuild from sdist and run the suite" is true.

  • The corpus pytest marker was declared and carried by no test, so pytest -m corpus selected nothing and reported success. It now has the harness it was declared for (tests/test_corpus.py): ingest-and-validate over all five whole-genome annotations, R-tree-vs-B-tree agreement at a scale where the spatial index earns its place, and a byte-faithful round trip. The native and rtree markers, also unused, are removed — both conditions are handled where they arise. hypothesis is no longer a declared test dependency; nothing imported it.
  • The release workflow could publish from a red tree. ci.yml runs on pushes to main and release/* and not on tags, and the publish job depended only on the wheel builds — so a tag pushed from a failing tree went straight to PyPI with nothing having run the suite. Both release workflows now gate every builder on a verify job that builds the tagged commit, asserts the native extension is present, runs the tests, and refuses if the tag does not match gffbase.__version__.
  • Both release workflows claimed abi3-py39 covering "CPython 3.9-3.13"; the wheels are abi3-py310 covering 3.10–3.14.
  • gffbase migrate --coalesce crashed on every invocation. The command passed a path to coalesce_multipart, which takes an open connection — AttributeError: 'str' object has no attribute 'execute'. It also needed the connection to have the spatial extension loaded, or DuckDB refuses to modify a table carrying an R-tree index. Both fixed, and verified end to end against the committed v1 fixture: schema 1 → 2, one multipart feature re-fused.
  • The pure-Python fallback parser was 73% covered and had no direct tests. It is the oracle the Rust parser is differentially compared against and the only parser on a wheel-less install, so it was the worst place in the codebase to be under-tested — a bug there could make a Rust bug look like agreement. Now 93%, with a dedicated tests/test_pyfallback_parser.py.

Two defects surfaced immediately. _FallbackIterator._drain_for_metadata pulled a record to populate the dialect but never captured it, so .dialect() returned {} until something happened to iterate — the same call gave a populated dialect or an empty one depending on nothing the caller could see. And a file with directives but no features never reached a yield at all, so .dialect()["fmt"] was a KeyError on exactly the inputs a caller probes before deciding what to do. Both now match the Rust engine.

The gap existed because every fallback test used a file smaller than checklines, so the parser's second loop — which processes nearly every line of a real annotation — had never run. - _FeatureIterator.dialect and .directives were methods where their base class has them as properties, with a type: ignore hiding the mypy error. The same expression worked against one iterator and raised TypeError: 'list' object is not callable against another. - Removed two dead definitions from the fallback parser (_parse_coord and _LazyGFFFormatErrorProxy), neither referenced anywhere.

  • FeatureDB.bed12() emitted a blockCount that counted all block children while blockSizes/blockStarts silently dropped any child with a missing coordinate, producing a BED12 line whose three block fields disagreed. All three now derive from the same filtered list.
  • Feature.sequence() and FeatureDB.bed12() did unguarded arithmetic on nullable coordinates, raising TypeError: unsupported operand type(s) for -: 'NoneType' and 'int' instead of something actionable. Both now raise a ValueError naming the feature.
  • Passing a hand-built Feature (which has id is None) to db[...], children(), parents(), delete() or update() bound SQL NULL and silently matched nothing. It now raises.
  • Roughly a dozen con.execute(...).fetchone()[0] call sites would raise TypeError: 'NoneType' object is not subscriptable on an empty result. They now go through gffbase._dbutil.scalar / scalar_or.
  • Dialect inference was nondeterministic. Both engines resolved a tied plurality vote over the attribute field separator through a randomly-seeded hash container -- HashMap in Rust, set() in Python -- so the winner varied between processes. Since that separator is what a re-serialized feature is written with, the same annotation file could round-trip to different text on different runs of identical code. Measured at 3 of 20 runs disagreeing on gms2_example.gff3. Both now tally in insertion order and break ties by first appearance. Guarded by tests/test_dialect_determinism.py, which compares across fresh interpreters because a single-process test cannot see this class of bug.
  • Directives kept their ## prefix. db.directives is a documented attribute and the oracle stores directives with the prefix stripped (gff-version 3, not ##gff-version 3), so every consumer reading them saw the wrong strings.
  • Every derived-feature method disagreed with the oracle, and there was no differential test over any of them — which is how each of these survived.

  • merge_all did not do the two things it documents. It returned every input feature, merged or not, so the result was the size of the database rather than the number of merges; and it persisted nothing, despite the docstring promising that "the resulting records are added to the database". It also accepted exclude_components and ignored it, so asking for the components to be removed silently did nothing. It now emits only genuine merges, inserts them, and either deletes the components or links them with a Parent pointing at the merged feature.

    The discriminator that makes this possible was missing too: merge() set children unconditionally, so every feature looked merged. A run of one now gets no_children.

  • merge() extended only end. With a caller-supplied merge_order the run is not necessarily start-sorted, so a merged feature could be silently truncated at the front. It also re-sorted its input, discarding the very ordering merge_all had asked for; assigned no id, so the merged feature could not be deleted or linked; and never flagged ambiguity, so a merge across strands kept the first component's strand rather than ..

  • create_introns computed introns across transcript boundaries. grandparent_featuretype="gene" was treated as the direct anchor, pooling every isoform's exons into one sorted list. On FBgn0031208.gff that was 1 "intron" where the oracle finds 3, and for any multi-isoform gene the gaps produced were not introns of anything.

  • create_splice_sites emitted 1 bp sites. A splice site is a dinucleotide, so these named half of one. They were also always typed splice_site rather than by position in the transcript, and carried no attributes at all.

  • interfeatures stamped no derived source, produced a nonsense feature spanning two different sequences whenever consecutive inputs changed seqid, typed unnamed gaps with a constant instead of inter_<a>_<b>, never set strand to . on a mismatch, ignored numeric_sort, and took a three-argument attribute_func where the oracle takes one — so any gffutils caller passing a callback got a TypeError.

  • bed12 put a trailing comma on blockSizes/blockStarts (making every line differ), accepted thin_featuretype and ignored it with no mutual-exclusion error, and on a feature with no CDS set both thickStart and thickEnd to chromStart — rendering it entirely thin, the opposite of what the oracle draws.

  • children_bp swallowed unknown keyword arguments, including the removed ignore_strand, returning a plausible number instead of saying no.

  • Three merge_criteria predicates were distance tests, not range tests. overlap_end_threshold and friends computed abs(acc.end - cur.start) <= threshold, which rejects a feature lying entirely inside the accumulator — the most unambiguous overlap there is. The three existing tests passed under both formulas and so had never pinned this; the case that separates them is now tested directly.

  • add_relation discarded its callbacks' return values and wrote nothing back, so child_func=assign_child set an attribute on a throwaway object. Callbacks were also skipped entirely when ids were passed instead of Features. A batched add_relations was added because the closure is re-derived per call, which would have made merge_all quadratic.

Backed by a new differential group comparing introns, interfeatures, bed12, children_bp and merge_all against gffutils 0.14 — none of which had any differential coverage before.

  • The ingest mode was not recorded anywhere. meta held the dialect, the format, the R-tree flag and the depths, but nothing said whether a database had been built in compat or strict mode — so a file on disk could not report how it was made. It now stores mode, validation and on_error, readable as db.mode and friends. Additive, so no schema version bump; a database written earlier has no key and reads back as compat, which is what it was.

  • Reading an attribute and writing the feature back out could emit structurally invalid GFF3. Materializing feature.attributes takes serialization off the raw-bytes fast path, and there was no re-encode step, so percent-escaping was simply lost: Note=hello%20world came back as Note=hello world, and — far worse — a value containing ; or , came back bare. On tests/data/upstream/nonascii one attribute became five and column 9 gained three separators it should not have had. Nothing raised, in about the most ordinary usage pattern there is.

The same defect had a persistent form: merge_strategy="merge" rebuilds features.attributes_blob from the decoded attributes rows, so an unescaped value was written into the database and every later read of that feature parsed one value as several.

gffbase now has an encoder (gffbase._serialize), and gffutils.parser's Quoter, quoter, _reconstruct and quoted_semicolon_patterns are available under their upstream names. Note that it is deliberately not urllib.parse.quote: the space is not a reserved character in GFF3 and non-ASCII is not escaped, so Name=CkIIα[Tik]-1 survives unchanged where quote() would have produced CkII%CE%B1[Tik]-1.

Porting _reconstruct wholesale rather than only the encoder also fixed three things that were silently wrong on the normalized path: keep_order and dialect["order"] were ignored, repeated keys was ignored (so Parent=a;Parent=b always collapsed to Parent=a,b), and the field separator was flattened to ; or ;, losing ;.

Verified two ways. The vendored attr_test_cases.py table — upstream's own ground truth, 18 cases, shipped in tests/data/upstream/ and until now used by nothing — round-trips exactly. And a new differential test asserts that to_line(normalized=True) reproduces the oracle's bytes for whole rendered lines across the shared corpus: 11 of 15 GFF3 fixtures match exactly, and the four that do not are dialect-inference differences, measured and recorded, not serialization ones.

Three strict xfails retire. - Every synthesized GTF gene and transcript was invisible to R-tree region() queries. seqid_map was populated during the R-tree build, which runs after GTF synthesis — so the pass that stamps a synthesized row's seqid_y and bbox joined an empty table and those rows kept a NULL envelope. On ensembl_gtf.txt the R-tree path returned 32 features where the B-tree path returned 33, silently omitting the transcript itself. Found by the new INV-8 within minutes of the validator existing. - closure could contain duplicate rows. GFF3 permits a DAG — a feature may name several Parents — so the same descendant is reachable by two paths of equal length, and the recursive CTE's UNION ALL emitted one row per path. On random-chr.gff, children(gene, level=2) returned five features of which only three were distinct. gffutils never had this because its relations table is keyed on exactly that triple. - A cyclic Parent graph made the hierarchy walks lap rather than terminate. All three recursive walks followed the cycle until the depth budget ran out, so a two-feature cycle made children() return 64 rows — the same two features, thirty-two times each. Each walk now carries its path and refuses to revisit a node, which is free on well-formed data (in a DAG the filter cannot fire) and verified identical on the FlyBase 50k corpus. Cycles are logged and recorded rather than silently repaired. - A failed ingest left a file behind: valid DuckDB with the full schema, no data and no metadata. Retrying then refused with "already exists. Pass force=True", and opening the leftover produced an empty database that reported itself as current — a missing schema_version looked like a v1 database and was dutifully migrated. Ingest now builds beside the target and renames on success, so a failed force=True overwrite also leaves the original intact; being handed such a file from elsewhere is refused at open. - The UCSC bin column in the SQLite export was computed one level off_BINOFFSETS was missing its top entry and used 0 where the oracle uses 1, differing from gffutils.bins on ten of eleven representative ranges. Since gffutils.FeatureDB.region(completely_within=True) filters on bin, an exported database answered those queries with nothing at all. - export_sqlite wrote one row per logical feature, so a discontinuous feature was exported with its envelope coordinates rather than its lines. It now flattens through segments_all into the N features gffutils itself would have made, fanning relations out over both endpoints and recording the grouping in duplicates. - The attributes table disagreed with Feature.attributes for a wholly empty value: pseudo= was indexed as a row while the object reported [], so a SQL query and the object model gave different answers for the same feature — and a bare Parent= created an edge to the empty id. - GTF-synthesized gene and transcript rows ignored a caller-supplied id_spec, taking the grouping key regardless. They now honour it, with the named attribute carried onto the inferred row first (so {"gene": "gene_name"} yields a gene actually named after gene_name, not an autoincremented fallback), and the rename applied after the edges are built so the hierarchy survives it. - merge_strategy="merge" never regenerated attributes_blob, so a merge was invisible to every caller: the table held both values while the feature reported one.

  • gffbase rejected 6 of the 23 upstream fixtures gffutils reads, including FBgn0031208.gff, gffutils' own canonical fixture. rust/src/validate.rs validated to the NCBI GFF3 specification unconditionally, but create_db() is the compatibility entry point and real annotation files break that spec routinely. Under compat the rules still run and every violation is reported, but the record is kept. Corpus-wide result: 0 rejections, and 23 of 28 files now produce byte-identical feature counts.
  • An embedded FASTA section without a ##FASTA directive was parsed as features. A bare > line ends the feature section in gffutils; gffbase only stopped at the directive, so FBgn0031208.gff gained three junk features from its sequence lines.
  • The two engines disagreed on padded coordinates. Python's int() strips surrounding whitespace and Rust's parse::<i64>() does not, so the Rust engine dropped any record with a coordinate like 944828 while the pure-Python fallback kept it -- a silent, engine-dependent difference in which records exist. wormbase_gff2.txt exercises it.

  • Null coordinates now round-trip. features.start/"end" were declared NOT NULL, so the Arrow batch builder coerced a . column to 0: the feature reopened as 0..0 and serialized zeros where the source said .. The columns are nullable, the coercion is gone, and the R-tree envelope is CASE-guarded so a null coordinate yields a null bbox instead of failing the insert. Verified that the R-tree and B-tree paths agree on which rows a region query returns -- they reach that answer by different routes (a null envelope never intersects; a null comparison is never true), so agreement was not automatic.

  • Six coordinate-space operations raised TypeError: '<' not supported between instances of 'int' and 'NoneType' once coordinates could be null: merge, merge_all, interfeatures, create_introns, create_splice_sites and bed12. They now skip features that have no position, via one shared _with_coordinates filter -- a feature outside coordinate space is not in the input domain of a coordinate operation, and raising instead would make merge_all() unusable on any file containing such a row (WormBase emits them).
  • bed12 filtered null-coordinate block children after sorting them, so the guard added earlier in this release was unreachable and the sort raised TypeError first.
  • merge_strategy="merge" did not actually merge, as far as any caller could tell. It folded the incoming attributes into the attributes table but never regenerated attributes_blob, and Feature.attributes reads the blob -- so the table held both values and the feature reported one. Merged attributes now match the oracle exactly.
  • Removed ingest._derive_id, dead since the id_spec work replaced it.

  • import gffbase crashed on Python 3.9. ParsedFeature used @dataclass(slots=True), which is Python 3.10+, while the package declared requires-python >=3.9, shipped an abi3-py39 wheel, and advertised a 3.9 classifier. Every 3.9 install succeeded and then failed on first import with TypeError: dataclass() got an unexpected keyword argument 'slots'. slots is now applied conditionally, so 3.10+ keeps the per-record memory saving and 3.9 works.

  • gffbase.gffwriter referenced an undefined io name in the GFFWriter.__init__ type annotation (F821). The module is now imported.
  • _pyfallback.parser re-raised a coordinate parse failure without chaining, masking the original ValueError (B904).
  • The cargo test doc-test target failed to compile: a module doc comment in rust/src/lib.rs used an indented block that rustdoc interpreted as Rust source. CI only ran cargo test --lib, so this was never seen.
  • Removed a dead parse_coord in rust/src/parser.rs, superseded by parse_coord_strict, which caused a dead_code warning.

Removed

  • Six PHASE*.md entries from pyproject.toml and MANIFEST.in referring to files deleted in 44268ce, plus a recursive-include python/gffbase *.pyi matching no files.

  • The memmap2 Rust dependency, which was declared and never used — no Mmap appears anywhere in the crate. An unused dependency is still compiled, still locked, and still part of the supply chain of every published wheel.

  • The bench/ directory. It was the predecessor of benchmarks/, and benchmarks/common.py reached into it for cached legacy timings, which is how measurements from an older corpus ended up presented as current ones. Its small result files are preserved under benchmarks/results/archive/0.1.0/.

  • Thirty-three internal "Phase N" references from docstrings and comments across ten modules. These rendered on the public mkdocstrings API reference — gffbase.__init__'s module docstring opened "Phase 5: full drop-in public API surface … on top of the Phase 4 DuckDB ingestion engine" — and named a development schedule no reader has access to.

Intentional deviations

  • Attribute keys are stripped of surrounding whitespace, and the empty key a trailing ; produces is dropped. The oracle keeps both literally, and on FBgn0031208.gff line 84 that costs it real data: the line separates attributes with ; while the file's inferred separator is ;, so the key is stored as ' Parent', relationship building looks up 'Parent', and the edge silently vanishes -- db.parents("CDS:Fk_gene_1:1") returns [] under gffutils and ["Fk_gene_1", "transcript_Fk_gene_1"] under gffbase. Compatibility mode preserves quirks, but not data-loss defects.

Known gaps recorded by the new harness

Not yet fixed, but now measured and pinned rather than unknown:

  • The oracle weights its dialect vote by attribute count; gffbase weights all sampled lines equally. Both are deterministic; only the winner can differ, and only where a file's attribute strings vary in length.
  • The oracle renders a valueless attribute as a bare ID where gffbase re-emits the source's ID=. This one is deliberate — str(feature) is byte-faithful by design, so gffbase reproduces the input and the oracle does not. to_line(normalized=True) matches the oracle exactly.
  • gffbase strips whitespace around attribute keys where the oracle keeps it literally. Also deliberate: on FBgn0031208.gff the oracle's behaviour silently loses a Parent edge, and compatibility mode preserves quirks but not data-loss defects.

(Two entries left this list: GTF synthesis now honours id_spec, and attribute escaping now survives materialization.)


Notes

Version 0.1.0 is being yanked from PyPI: its metadata advertises Python 3.9 support that the artifact cannot deliver.



0.1.0 — 2026-05-07

Initial public release.