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-09-11¶
0.2.0rc1 was published to TestPyPI as the release rehearsal of this same content; it was never on PyPI.
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 whoseNamecarried a stray Latin-1 byte was stored with no attributes at all – ID included, making the feature unreachable – while the ingest reported success.seqidandfeaturetypewent throughfrom_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
GFFFormatErrornaming 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 calleda– a different path than the caller named. The stray-file cleanup then calledos.unlinkwith the original path, which raisesValueErrorrather thanOSError, so it escaped theexcept, 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. BothFeatureDBandcreate_dbnow reject an embedded NUL before touching the filesystem.helpers.make_query‘s raw-SQL slots are now documented as such.featuretype,limitandstrandare bound parameters andorder_byis whitelisted, butotherandextraare 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, sodb.all_features(order_by='start ASC; DROP TABLE attributes; SELECT …')
dropped the table and still returned rows — the trailing
SELECTre-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, includingCOPY … TOto 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_byis 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()builtPRAGMA {name} = {value}by interpolating both halves of a caller-supplied dict, with the whole loop body insideexcept duckdb.Error: continue— sodb.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 callscursor.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.
Published as GHSA-5f5g-g3v5-prrg (High, CVSS 8.1). See the SQL injection advisory for both write-ups and mitigations for anyone who cannot upgrade.
The thread-count environment variable is now
GFFBASE_THREADS.GFFUTILS2_THREADSpredates the rename to gffbase and was the lastGFFUTILS2_*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.GFFWriteraccepts either a path or an open file object, and closed both — soGFFWriter(sys.stdout).close()shut stdout down for the whole process and anything written afterwards raisedValueError: 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_ranrequired INV-8 to have run andreport.skippedto be empty, but INV-8 comparesbboxagainst the coordinates it was built from and therefore only exists when an R-tree does. UnderGFFBASE_TEST_DISABLE_RTREE=1the 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.plottingdoesfrom pybedtools.contrib.plotting import Track, and pybedtools setsTrack = Nonewhen matplotlib is absent – so the manifest recorded the presence of a name bound toNone, and the inventory tracked a third-party optional dependency.
All three are excluded now.
--checkalso refuses to compare a checkout-generated manifest against a pip-installed oracle (a checkout shipscontrib/andscripts/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.pyfailed at import on Python 3.10, the declared floor, because it usestomllib(3.11+). Windows is tested at the floor, so this took out the parity module and both Windows cells.tomliis now a declared test dependency under an environment marker.pybedtools_integrationwas measured at 9.8% coverage because its tests skip without thebedtoolsbinary 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 gffbaseis 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¶
The published site rendered literal backticks in 92 places. Markdown nests inline markup and reStructuredText does not, so the MkDocs conversion carried a code span inside a bold span across verbatim – which RST renders with the backticks visible, because nothing nests inside a strong span.
sphinx-build -Wreports nothing: it is valid RST that means something else. Every span was split so both formattings survive (order_byis validated), and the site is now checked by scanning the rendered HTML, which is the only thing that can see this class at all.Related: autodoc publishes docstrings verbatim, and the docstrings use a single backtick for code in the project’s house style. RST’s default role for a single backtick is
title-reference, so everyFeatureDBandorder_byon the API pages was rendering as italic prose.default_roleis nowcode, which makes several hundred spans across 38 modules mean what they say without rewriting any of them.tools/md2rst.pyis gone. It was the one-shot MkDocs to RST converter. The RST is now canonical and has been hand-edited since – including the repairs above – so re-running it would silently clobber the corrected sources with a fresh conversion of documents that no longer exist. No test and no workflow invoked it.Documentation code is now executed by the test suite.
tests/test_docs_snippets.pyextracts every fencedpythonblock fromdocs/,README.mdandMIGRATION.mdand 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.mdclaimed every snippet had been validated while its own opening example opened twoFeatureDBhandles 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 acceptvalidation=oron_error=. Both axes are documented, both are supported byresolve_mode()and carried byIngestOptions, and the public entry point simply never forwarded them – so the documentedvalidation="ncbi", on_error="warn"audit combination raisedTypeError: unhandled kwarg. They are now parameters, and documented.docs/usage_gallery.mddocumentedFeature.attributes_dict(), which does not exist onFeature(only onParsedFeature, and not in gffutils at all). Replaced withdict(feature.attributes).A troubleshooting snippet was not valid Python – an
exceptclause with notry. It is now a complete, executed example.Four pages stated the wrong number of validator invariants (15; there are 14).
tests/test_release_hygiene.pynow derives the number from the registry, so prose cannot drift from it again.MIGRATION.mdlinkeddocs/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, theFeaturealiases and everyGFFWritermethod 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 thatTyping :: Typedpromises.The site moved to https://khchao.com/gffbase/.
docs/CNAMEis deleted, which is what was making GitHub Pages redirect the canonical path back to the retiredgffbase.khchao.comsubdomain. 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 --strictruns in CI.CONTRIBUTING.mdand the PR template both claimed it did. It did not: the only mkdocs invocation wasgh-deploy, onmain, 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.mdtaught the deprecatedGFFUTILS2_THREADSenvironment variable as the primary name.mkdocsis capped below 2.0. It removes the plugin system with no migration path, so an unpinned floor would let a routinepip 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 published numbers were a macOS run of a version that was never built. The tables came from
benchmarks/results/06_mega.json: four of five corpora, Apple Silicon, provenance naming agffbase 0.2.0that no build ever produced, and GENCODE’s comparator censored under a different GTF arm than the one the headline names. They claimed 1.20x-1.43x. A Linux run of all five corpora, under the schema-v3 evidence contract with every correctness signature matching, measures 1.14x, 1.21x, 0.93x, 0.90x and 0.69x – gffbase ahead where per-feature overhead dominates, behind on attribute-dense whole-genome annotations. Those are the numbers now published; the macOS bytes are retained untouched as the historical platform entry.Five pages told the reader the table above them was something else. The generated blocks moved to the Linux artifact; the hand-written prose wrapped around them did not.
README.mdandperformance.rstintroduced a five-corpus Linux table as “the retained historical Mac run”, under a Historical Mac sweep heading, sourced to “the committed historical Mac file” – while the provenance block three lines below named06_mega.linux-x86_64.json.performance.rstalso carried a note explaining that GENCODE GFF3 was missing from this run, directly above the row measuring it at 11 min 8 s, and a trade-off ledger still reading “faster in each completed, comparable corpus” over a table with three rows below 1.0x.datasets.rstsent readers to the vanished note. None of it was caught, because the release guards check the generated blocks and three specific strings, and every one of these lived in the prose between them. The pages now say what was measured: the ingest column is a draw, ahead where per-feature overhead dominates and behind on attribute-dense whole-genome files; the GTF row is the inference-disabled arm, which is the arm least favourable to gffbase;peak RSSis ingest plus exhaustive validation and not what the default path costs; and ingest wall is measured once per corpus, with the measured spread stated so a single figure is worth what it is worth.Ingest is attribute-bound and essentially serial, and the docs now say so. Cost tracks attributes rather than features: across the three attribute-dense corpora, at 12.8 to 17.9 attributes per feature, gffbase holds 157,000 to 168,000 attributes per second over a sixteen-fold range of corpus size. The rate then falls with the attribute density – 130,600 on RefSeq at 11.2, 62,400 on CHESS at 2.6 – which is an attribute-bound cost with a fixed per-feature floor showing through, and is why gffbase wins on CHESS and not on GENCODE. A 25-job sweep over five corpora at 1, 2, 4, 8 and 10 threads shows raising DuckDB threads buys between 1.05x and 1.25x: ten times the cores, at most a quarter more throughput. The GENCODE GTF result is not a GTF defect – gffbase moves 157k attributes/s there, its own third-fastest of the five and within 7% of its best, just under GENCODE GFF3 at 162k and MANE at 168k – it is that the comparator’s
no-inferGTF path is a plain bulk insert, its fastest case anywhere at 229k/s. Recorded in the methodology, with parallel ingest on the roadmap.A published “peak RSS” that was six times the cost of ingesting.
peak_rss_bytesis the peak of the ingest subprocess, and that subprocess also runsvalidate(level="full", sample=None). Under the canonical exhaustive validation the validation dominates: 62.0 GB where the same corpus validated atsample=10000peaks at 10.0 GB. Sitting unlabelled beside an ingest time, it read as the memory needed to ingest. The column now names the work it measured, and the memory ratio is gone entirely – gffbase’s figure came from a process that validated exhaustively and the comparator’s from one that did not, so their quotient (published as “496x”) described neither engine. “Equal work, or no ratio” is the rule the rest of the harness follows.A generated caption was silently dropped from every RST page.
markdown_to_rstrouted any block containing a pipe to the table converter, which emitted the list-table and discarded everything after it – including the caption naming how many corpora the ratios cover. A hand-written copy left outside the markers went on claiming three corpora after the run had grown to five: a stale number directly beneath a freshly generated table, which is the exact failure generated blocks exist to prevent.statusaborted healthy campaigns. It validates an attempt directory withexact_directory_scan, which stats every entry, rescans, and requires the two passes to agree byte for byte – the right contract for settled evidence and an impossible one for a directory whose worker is still in it. A DuckDB.walgrows continuously, so a 36-job run died at job 14 ongencode-gff3.duckdb.gffbase-building.<pid>.wal, with nothing actually wrong. An earlier fix had allowed those names; the exactness requirement remained.result.jsonis written once and last, so its absence is what “still running” looks like from outside: an attempt without it is now scanned without the second pass, keeping the name set, symlink rejection, entry kinds, modes and device/inode identity, and giving up only the claim that the bytes held still.The published tables could not render a Linux campaign at all. The generator read one hardcoded path,
benchmarks/results/06_mega.json, which holds the historical macOS run — four corpora, schema v2, provenance naming agffbase 0.2.0that was never built.merge --publishwrites neither that file nor anything the generator can use: its portable projection dropsinputanddb_paths, two of the fourteen keys a schema-v3 row must carry. So a finished campaign left the site rendering the artifact it superseded. The generator now prefers a per-platform06_mega.<platform>.json, falling back to the macOS artifact;tools/gen_published_measurements.pyderives that file from a campaign’s run-local record, where each primary job’s payload already is a schema-v3 row; and06_mega.py --publishwrites under a platform key too, so a local sweep can no longer overwrite the pinned artifact. Selection is onprimary_eligible, never the corpus key — a scaling job carries the samepayload["key"]as the canonical row for that corpus, so keying on it would publish a thread-sweep as the headline number. The three prose guards resolve the file through the generator rather than naming it, so they cannot check yesterday’s numbers against today’s tables.Three of five corpora reported a content divergence that was not one. The signature that decides whether a speedup may be published compared gffbase’s transitive closure against gffutils’
relationstable, but what gffutils stores atlevel = 2is not a closure:_update_relationsinserts, for each feature, the children of its children — one hop, no iteration to a fixed point. On a four-deep chain it records five ancestor/descendant pairs and omits the sixth. RefSeq differed by 3,218 pairs and GENCODE GFF3 by 108, on hierarchies whose direct edges agreed exactly — and the closure is a function of those edges. The comparator’s closure is now derived from them rather than read from the cache.A comma followed by a space cost CHESS its speedup. GFF3 says an unescaped comma separates values and a literal comma must be percent-encoded; gffbase follows that, and gffutils deliberately does not, keeping
, `` inside the value so an unescaped ``description=kinase, subunit 1survives. Ten CHESS genes record two names asgene_name=ADAM6, RPS8P1, which showed up as a 20-row divergence. The signature now applies one rule to both engines — the comparator’s coarser one, because re-splitting on every comma would shatter 2,294 correctly escaped CHESS descriptions to line up twenty gene names. The parse-policy difference itself is now a declared API deviation intests/parity/deviations.toml, pinned by a differential test; no fixture in the shared corpus contained a comma-space attribute, which is why nothing caught it.The 0.1.0 arm of the version bridge could never have run. Two independent defects, one hiding the other.
bridge.pyreleased its handle behindif hasattr(db, "close")— False for gffbase 0.1.0, whose missingclose()is one of the defects this release fixes — so DuckDB kept its exclusive lock and signing the database raised. Behind that,database_signatureassumed the current schema unconditionally and could not read schema v1 at all, which has nosegments_all, one row per feature, and noseg_idxonattributes. Every bridge job must carry a valid signature, so those jobs were unsatisfiable by construction. Both are fixed and the arm now completes in 34 s on MANE.A signature that never finished. Normalizing gffutils for comparison built its temporary tables without the indexes the following joins need, and let SQLite spill its sorts to
/var/tmp— a 32 GB root volume, not the scratch the databases sit on. On GENCODE GTF (6.07M features, 12.34M relations) the correlatedNOT EXISTSover an unindexed table ran for 2 h 39 m without finishing, and took the whole job down with the controller’s timeout. Indexed, and spilling beside the work, it returns byte-identical components in under 16 minutes.The results file no longer destroys itself.
06_mega.pybuilt a payload containing only the corpora named by--onlyand wrote the whole file, so each targeted re-run silently deleted the others.benchmarks/out/06_mega.jsonended 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.0written into it — a factor with no measurement behind it, whose own comment conceded there was no way to observegffutils’ progress. It was the sole source of the published “≥ 2 hr 30 min” legacy wall and the “≥ 32×” headline. A capped run now reportsstate: timed_out,wall_seconds: null, and the observedcap_seconds. Timed-out comparators produce neither a ratio nor a ratio floor; tables label them as censored. The preserved schema-v2 Mac artifact retains its original lower-bound field names, but the renderer never presents them as a current performance claim.Every result carries its provenance. CPU model, physical and logical core count, RAM, OS, Python, DuckDB, PyArrow,
gffutils,gffbaseandrustcversions, 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.pynow renders it from the committed measurements into marked blocks, andtests/test_release_hygiene.pyfails 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 underbenchmarks/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/-journalsidecars) as soon as its numbers are recorded, holding the peak near 16 GiB;--keep-dbretains one for the later stages,--no-purgekeeps everything, andGFFBASE_BENCH_OUTredirects 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.pypointed at a GENCODE v45 file thatdownload_corpora.pyhad stopped fetching, present locally only as a symlink into the retiredbench/tree — so those stages worked on the maintainer’s machine and raisedFileNotFoundErroreverywhere 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-cachedflag and its hardcoded literals are gone, as is the cross-directory cache loader that presented old measurements as new ones.
Changed (breaking)¶
order_by="score"sorts numerically instead of lexicographically. GFF column 6 is stored as text – the spec allows.there, and the oracle stores it as text too – so an ORDER BY on it ranked10 < 100 < 1e3 < 2.5 < 9. “The highest-scoring features” came back wrong, and nothing raised. The whitelist entry is nowTRY_CAST(score AS DOUBLE), which yields NULL for.and for any non-numeric value; DuckDB’s NULLS LAST default then puts unscored features at the end, which is what a caller asking to sort by score means.gffutils has the same defect, so this is a deliberate divergence rather than a parity fix, and it is recorded as one in
tests/parity/deviations.toml.Ordered queries now have a total order. No sort column is unique – features share a start, a featuretype, a score, and even
file_orderrepeats, because GTF synthesis stamps a synthesized parent with theMIN(file_order)of its children. DuckDB sorts in parallel and does not preserve ties, so the same query over the same database could return tied rows in a different order on consecutive runs. Every ordered query now appendsfile_order ASC, id ASCas a final tiebreak.file_ordercomes first so that tied rows come back in the order they appeared in the file, which is what the oracle does – breaking ties byidalone putFBgn0031208:3ahead ofexon_2at the same start, purely becauseFsorts beforee. Same features, same coordinates, different sequence: the kind of difference that surfaces only once someone’s output does, and the parity suite caught it.idstill follows, becausefile_orderis not unique either.The tiebreak is ascending regardless of
reverse: it exists to be stable, not meaningful, and flipping it alongside the caller’s key would makereverse=Truesomething other than the exact reverse of the forward order for tied rows. Results are now reproducible run to run; they are not guaranteed to match the order a pre-0.2.0 run produced.order_byaccepts multiple keys, andreverseapplies to every one. A tuple, a list, or a comma-separated string all work. Only a single name worked before: a tuple was interpolated as a Python repr, which DuckDB parses as a constant struct, so the query silently sorted by nothing; a list raisedTypeError: unhashable type: 'list'from a set membership test. The whitelist also gainedid,file_orderandlength, none of which is in the oracle’s documented list but all of which are real columns callers sort by.gffutils appends the direction once, which in SQL reverses only the last key. gffbase applies it to each key. Since multi-key sorting did not function here at all, no working gffbase behaviour changes, and reproducing the upstream shape in new code would be copying a defect.
validation="ncbi"accepts an unquoted GTF attribute value. The GTF specification quotes values, but unquoted bare tokens are common in real annotation releases, and rejecting them meant strict mode could not read files every other tool accepts. A value now validates if it is properly double-quoted or is a bare token containing no whitespace,"or;. Still rejected: an unbalanced quote, an unescaped quote inside a quoted value, and an unquoted value containing whitespace. Callers who usedvalidation="ncbi"specifically to reject unquoted GTF no longer get that.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. Thequery_idxcolumn 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_idxis now the item’s index inregionsas passed. The newon_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-py39toabi3-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 untestedduckdb>=1.0,pyarrow>=14.PyO3 0.22 → 0.29. Migrated off the removed
*_boundconstructors,into_py,value_bound, andget_type_bound.FeatureDB.schemais a method again, not a property. gffutils documentsdb.schema()and callers write it that way; as a property the documented call raisedTypeError: '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>__2unconditionally, which made the documented default unreachable.create_uniquenow produces the oracle’s<id>_1,<id>_2rather than<id>__2,<id>__3, and no longer writesduplicatesrows – the oracle records a rename only whenmergefalls back tocreate_unique, because that table exists so a later merge can find the sibling rows.DuplicateIDError,AttributeStringErrorandEmptyInputErrornow subclassValueError. gffutils exportsDuplicateIDErrorbut raises a bareValueError("Duplicate ID ..."), so real callers writeexcept ValueError. Subclassing satisfies both the documented type and those callers instead of forcing a choice.FeatureNotFoundErroris deliberately left onException.
Changed¶
rust/Cargo.lockis now committed.rust/Cargo.tomlandMANIFEST.inboth already claimed it was shipped;.gitignoreexcluded 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
pytestinvocation.addoptshard-requiredpytest-cov(absent from thedevextra) and made a barepytestfail on coverage rather than on tests. Coverage is now applied explicitly in CI.pytest-covwas added to thedevextra.Declared Rust MSRV raised from 1.69 to 1.83. The 1.69 claim was justified by a
Cargo.lockthat was gitignored and absent, so it had never been verified; with the lock now committed, the resolved graph includesflate2 1.1.x, which does not build on 1.69. 1.83 is the toolchain thelintCI job now compiles the whole crate with, so the floor is enforced rather than asserted. This affects source builds only — the published wheels areabi3and 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), includesbenchmarks/in its scope (70 errors were invisible), and finally invokes the clippy that was being installed and discarded. The test job runs the fullcargo testrather than--lib, assertsnative_available()instead of letting a broken extension build silently skip every Rust cell, and installs theallextra so theformat="df"/format="polars"paths are exercised instead of skipped.Ruff configuration gained a
[tool.ruff.format]section and per-fileE402ignores for thebench/andbenchmarks/entry-point scripts, which must bootstrapsys.pathbefore importing. The whole tree is nowruff formatclean.UP006/UP007/UP035/UP045are ignored for this release. Every module carriesfrom __future__ import annotations, so ruff proposes PEP 585/604 rewrites regardless oftarget-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 onepyarrow.Table/pandas.DataFrame/polars.DataFrame. The columnar counterpart toall_features(): same filters (featuretype,limit,strand,order_by,completely_within), but noFeatureobject 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,createandversionon its package, sogffutils.constants.always_return_list = Trueworks after a plain import. gffbase bound none of them, so the one-line migration its own README advertises –import gffbase as gffutils– raisedAttributeErroron the first line of any script using one.FeatureDB.method(), gffutils’ alias forall_features(). It is a plain alias upstream too, and ported code calls it.FeatureDBconnection lifecycle:close(), context-manager support, andread_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 — noclose, 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 PyTorchDataLoaderjob.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 ownduckdbconnection 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=Trueevery mutator (update,delete,add_relation,add_relations,analyze) raisesReadOnlyError, andupgrade="auto"is coerced to"never"so a v1 database cannot be migrated by a handle that promised not to write.set_pragmas()stays allowed:SETis session state, not a write to the file, and legacy callers passconstants.default_pragmasroutinely.execute()is deliberately unguarded — it is the escape hatch, and DuckDB’s own refusal names the statement it rejected.Using a closed handle raises
ClosedDatabaseErrornaming the call and the remedy, rather than DuckDB’s bareConnectionException. Both new exceptions subclassValueError, so existingexcept ValueErrorhandlers keep working.FeatureDBacceptspathlib.Path. The constructor tookstronly and rejected everything else withTypeError: dbfn must be a path— while refusing an actualPath.create_dbalready accepted one, so the two entry points disagreed about their own documented type.A
gffbasecommand-line interface, registered as a console script and runnable aspython -m gffbase. Ten commands:create,fetch,children,parents,region,search,rmdups,sanitize, plus gffbase-onlyvalidateandmigrate.Argument names and output shapes follow
gffutils-cliso a script written against it keeps working. What does not follow it is how much of it runs. Of the thirteen commandsgffutils-clidefines, five work:annotateandconvertare defined but never registered, so they are unreachable from the shell;clean,commonandregionraiseNotImplementedError;fetchraisesTypeErrorbecausehelpers.get_gff_dbhands it a path string on its common branch and it indexes that as a database; andsearchraisesAttributeErrorbecause it callsdb.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.gffproduces a valid file — upstream’srmdupsprints 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 --strictis the CI form.Uses
argparse, adding no dependency.gffutilsmakesarghandargcompletehard runtime requirements of the library itself, so importing it at all pulls in a CLI framework.FeatureDB.attribute_search(text, featuretype=None)— case-insensitiveLIKEover 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.tomlno longer contains a singleplannedentry; 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_integrationandcontrib.plotting.gffbase.helpersgrows from one function to thirteen, includingmake_query,infer_dialect,sanitize_gff_db,canonical_transcriptsandget_gff_db.These are not import shims. Each is tested for behaviour (
tests/test_compat_surface.py),binsis 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_listchanges whatfeature.attributes[key]returns, andconstants.ignore_url_escape_charactersturns percent decoding and re-encoding off together.Several upstream defects are deliberately not reproduced, and each is recorded with its reason:
helpers.get_gff_dbreturns aFeatureDBrather than sometimes a path string (the inconsistency that makesgffutils-cli fetchraiseTypeErrorin its common case);helpers.dialect_compareworks on dialects carrying anorderlist, where the oracle raisesTypeError: unhashable type: 'list'on all of them;helpers.to_unicodeactually decodes bytes, where a2to3artifact left the oracle’s body unreachable;helpers.canonical_transcriptsselects the longest transcript rather than the shortest and does not print to stdout;helpers.make_queryvalidates a stringorder_byinstead of interpolating it verbatim; andhelpers.annotate_gff_dbraises rather than silently doing nothing.biopython_integrationalso fixes a genuine incompatibility: BioPython removed theSeqFeature(strand=...)argument and moved strand onto the location, so the oracle’s call raisesTypeErroron any current install. The round trip here is exact for+,-and..create_dbaccepts an iterable of features, not just a path or a string. This is what_FeatureIteratorexists for upstream and whathelpers.sanitize_gff_dbneeds.gffbase.interface.assign_childandno_children, the two symbolsmerge_allis built from upstream, plusFeatureDB.add_relationsfor 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,.validationand.on_error, recovered from the database rather than assumed, andFeatureDB.derived_source— thesourcestamped on features gffbase derives rather than reads. It isgffutils_derivedundermode="compat"so a ported script filtering on that string keeps working, andgffbase_derivedundermode="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.
featureskeeps one row per logical feature, with its coordinates widened to the envelope, andsegmentsis a sparse side table holding physical lines only wheren_segments > 1. Logical dedup stays structural (noDISTINCTanywhere), 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_allgives the one-row-per-input-line view.MultipartFeatureandFeatureSegment, both subclassingFeatureand overriding none of__str__,__len__,__hash__,__eq__,__getitem__orastuple— the compatibility surface is preserved by inaction.len()stays the envelope span;covered_lengthis 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’mergerequires all eight non-attribute columns to match, so it never merges a genuine split feature.on_multipart_conflictchooses between raisingMultipartConstraintErrorand splitting when lines sharing anIDdisagree on seqid, source, featuretype or strand.explode_segments=Trueonregion_batched/children_batched/parents_batchedyields one row per input line. Offered on the tabular APIs only — aFeatureSegmentleaking intoregion()orchildren()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)andto_lines(). The default is byte-faithful;normalized=Truere-renders column 9 from the parsed mapping, which is what the oracle always does.gffbase.migrate—migrate_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’sx_1rows — it changes results, so the caller has to ask. Tested against a real v1 database built by the pre-v2 code, committed astests/data/v1/.gffbase.validate— 14 post-ingest invariants, run automatically at the end of a strict-mode ingest and available asdb.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 byregion(), with nothing raised anywhere.mypyruns clean overpython/gffbaseand is a CI gate, backing theTyping :: Typedclassifier that 0.1.1 made honest by shippingpy.typed.Full
create_dboption fidelity. Twelve parameters were previously accepted and ignored; every one now changes behaviour or raises.gffbase._options.IngestOptionsvalidates the whole option set before any work starts – in particular before the destination database is touched.id_specin all four shapes (attribute name, ordered list, per-featuretype mapping, callable), plusautoincrement:BASEand 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 fixesgencode-v19.gtfyielding 26 features against the oracle’s 21, andID=yielding an empty-string primary key instead ofprotein_1.All five
merge_strategyvalues, andforce_merge_fieldswith the oracle’sValueErroronstart/endand its warning onframe/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, andinfer_gene_extent(deprecated: warns, then sets bothdisable_infer_*flags).keep_orderandsort_attribute_valuesnow reach materialized features. They were stored onFeatureDBand 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’sdeprecation_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 writeif f.attributes["ID"]:, and[""]is truthy where[]is not.A gffutils parity harness, pinned to upstream commit
6b84330:tools/gen_parity_manifest.pygenerates a machine-readable inventory of the oracle’s 19 modules and 90 public symbols, committed astests/parity/gffutils_manifest.jsonso the structural checks run without gffutils installed.--checkverifies it has not drifted.tests/parity/deviations.tomlrecords 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.pyruns 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 arexfail(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, withcompatas the default forcreate_dbandstrictforparse_gff.strict=keeps working for one deprecation cycle; passing it together withon_error=raisesTypeError.FeatureDB.warningsreports 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.mdrecords the design for schema v2, the multipart feature model, and this mode axis.python/gffbase/py.typed. TheTyping :: Typedclassifier was declared but no PEP 561 marker shipped, so downstream type checkers saw nothing.CODE_OF_CONDUCT.md— referenced byCONTRIBUTING.mdbut missing.CITATION.cff— referenced byREADME.mdbut missing.SECURITY.mdand thisCHANGELOG.md.pandas,polars,fasta, andalloptional-dependency extras. Theformat="df"andformat="polars"code paths were advertised with no way to install what they need.native,rtree, andslowpytest markers.
Fixed¶
CRLF files parsed differently on each engine. The Rust parser trimmed the trailing
\ronly after directive handling had run, so every directive from a Windows-line-ended GFF3 was stored assequence-region chr1 1 1000\r, while the Python fallback – which reads with universal newlines – stored it clean. A blank\r\nline was also not empty by the time it was checked, so it fell through to the tab split and raisedexpected at least 9 tab-separated fields, found 1. Both engines now trim before anything inspects the line.A coordinate past
i64was 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_filenamecould not find the canonical example.FBgn0031208.gff– the fixture every gffutils tutorial opens – is vendored undertests/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. It then failed for a different reason everywhere else:tests/is not inside the package, so nopip installcould reach the corpus at all, while the oracle ships its own examples and the migration guide advertised the two as equivalent. The corpus (35 files, 114 KB, with the vendored gffutils fixtures’ MIT notice alongside them) now travels inside the wheel, and the error names the directory it searched. Note that.gitignoreblanket-ignores*.gff3/*.gtf/*.faand maturin collects the package tree through gitignore – so the first build of this shipped 9 of the 35 files, silently, none of them the ones the documented examples open. A test now fails if any packaged fixture is ignored."missing" in dbraised instead of returningFalse.gffutils.FeatureDBdefines neither__contains__nor__iter__, so Python falls back to iterating via__getitem__, which raises on the first missing key – theinoperator 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 thatWHEREclause throws its column numbering out — so pairingST_Intersectswith the multipart recheck aborted the planner withINTERNAL Error: Failed to bind column reference "file_order". Every region query against a RefSeq- or MANE-shaped corpus failed, on bothregion()and the_batchedpath.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.
EXPLAINconfirms the plan still containsRTREE_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_relationcarried its own copy of the cache-vs-dynamic decision, and that copy was missing the overflow check: forlevel=Noneit asked only whether the closure was empty, never whether the hierarchy ran deeper than the cache. On a corpus deeper thanmax_depthit therefore chose the closure cache, which only reachesmax_depth.Measured on a six-level hierarchy with
max_depth=2:children()returned all six descendants andchildren_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_batchedhad 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 fromgene → mRNA → exonandgene → exonsurvives — sochildren(gene, level=None)kept returning the exons of a transcript that no longer existed. The closure is now rebuilt fromedges, which is whatupdate()already did.update()andadd_relations()left the dispatcher reading stale corpus statistics._closure_max_depthand_n_multipartare read once when a handle opens and trusted for its lifetime, but both mutators rebuilt the closure without refreshing either the instance attributes or themetarows — 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.DataIteratornever 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_UrlIteratorand_FeatureIteratorsat unreachable beneath it, their docstrings describing a dispatch that did not exist.gffutils.DataIteratoraccepts all of these. The dispatch now exists;_UrlIteratoralso unlinks its download (it usedNamedTemporaryFile(delete=False)and never removed it, leaking a full copy of the annotation per call) and gainedclose()plus context-manager support._FeatureIterator.__iter__returned the underlying list’s own iterator, bypassing__next__and silently droppingtransform.cargo testcould not link on macOS.extension-modulewas enabled unconditionally inrust/Cargo.tomland 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 everycargo testdied in a wall of “symbol(s) not found for architecture arm64” — on the platformCONTRIBUTING.mdtells contributors to run it. maturin still supplies the feature for the wheel..github/workflows/testpypi-release.ymlcould not be loaded by GitHub Actions. Itsverifyjob declaredname:andruns-on:twice. PyYAML’ssafe_loadtolerates 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.pynow parses every workflow with a duplicate-key-strict loader.The sdist shipped a
MANIFEST.innaming five files it did not contain. maturin does not readMANIFEST.in—pyproject.tomlsays so — soCODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CITATION.cffandMIGRATION.mdwere 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
corpuspytest marker was declared and carried by no test, sopytest -m corpusselected 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. Thenativeandrtreemarkers, also unused, are removed — both conditions are handled where they arise.hypothesisis no longer a declared test dependency; nothing imported it.The release workflow could publish from a red tree.
ci.ymlruns on pushes tomainandrelease/*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 averifyjob that builds the tagged commit, asserts the native extension is present, runs the tests, and refuses if the tag does not matchgffbase.__version__.Neither release workflow could publish anything. Both run
tools/release_policy.pyas their first job, on a baresetup-pythonrunner, and the script importspackaging– which a fresh runner does not have and neither workflow installed. The policy step died withModuleNotFoundErroron every run, skipping qualification, artifacts and publish behind it. Every local check passed because the development environment carriespackaging. Found by a build-only rehearsal dispatch from the release branch, before the candidate tag was pushed; tagging first would have spentv0.2.0rc1on it, since a pushed tag is never moved. Both policy jobs now installpackaging==25.0, and a test derives the requirement from the script’s own top-level imports, so a new third-party import there fails the suite rather than the next release.The release qualification had never run, and failed on every platform. A build-only rehearsal of the TestPyPI publisher – dispatched from the release branch with publishing off, before any tag – ran the full qualification matrix for the first time. The library’s own tests passed on Linux, macOS and Windows; every failure was harness, workflow or test environment, and every one had passed locally:
No existing file could be replaced on ext4. The campaign’s atomic replace compared the displaced inode against its pre-exchange
statincludingst_ctime_ns. ext4, tmpfs and btrfs advance an inode’s ctime when they rename it; XFS – this project’s cluster – does not. So on every GitHub Ubuntu runner the comparison failed, the code concluded a concurrent writer had swapped the target, rolled back, and then could not prove the rollback either, because the rollback was a rename too. The bound no-replace rename in the same file already excluded ctime across its own rename, with a comment saying why; the exchange path now follows the same rule (_RENAME_STABLE_FIELDS). Tests simulate ext4’s behaviour on any filesystem, and a genuine swap inside the exchange is still rolled back.Importing the campaign package crashed on Windows.
os.getpgidandos.killpgwere default argument values, evaluated at import, and Windows has neither – so 12 test modules failed at collection, pytest aborted, and no test ran at all. They now resolve at call time.The Linux-only harness’s tests ran on macOS, where the code correctly refuses to run (renameat2,
/procboot and mount identity, process groups): 115 failures and 44 errors. They are now skipped off Linux with that reason.rustfmtwas never installed:with: { components: clippy, rustfmt }is a YAML flow mapping, so the comma ended the entry andrustfmtbecame a stray input. Now block style, and a test rejects unknown toolchain inputs.The package gate built a
linux_x86_64wheel, which PyPI rejects and the inspector refuses. It now builds with--compatibility pypi.Two jobs list their test dependencies by hand and predated
psutiljoining the[test]extra; a test now holds every hand-written list to the extra.actionlintin CI runsshellcheckover every script and flagged anls-into-variable; locally shellcheck was absent, so that layer had never run. A hygiene test needed the gitignoreddocs/build/to exist, which it does on any machine that has built the docs once and nowhere else.A latent one found on the way: two tests call the worker entry point in-process, and its private umask leaked into every later test.
Windows, once it could import the harness, showed five more. Git for Windows checks text files out with CRLF, which rewrote the byte-pinned measurement file and failed its SHA-256 pin –
.gitattributesnow marksbenchmarks/results/*.jsonbinary, and a test holds that rule. The table generator named its source withrelative_to(ROOT), so the provenance footer readbenchmarks\results\...on Windows and four pages “drifted”; it now rendersas_posix(). The result lock already fell back whenfcntlwas missing but calledos.fchmodunconditionally, so it crashed first; it is now guarded the same way. A test fixture built a “hostile” wheel member with a backslash throughzipfile, which rewritesos.septo/on Windows and laundered it – the validator, which parses raw central-directory names, was right all along. And a POSIX path from the Linux-only campaign spec was checked with the hostPath, which calls/staged/...relative on Windows.The artifact stage, reached only once qualification was green, failed three more ways. The sdist job passed
--lockedtomaturin sdist, which compiles nothing and rejects the flag outright. The wheel-install check rantools/release_artifacts.pyon Python 3.10 with no TOML reader (tomllibis 3.11+, andtomliwas never installed), failing every 3.10 cell on every platform. And it installed the wheel into the runner’s own interpreter beforepip check, so the check also judged the runner image’s preinstalled tools – on the Windows 3.12 image, pipx requirespackaging>=26against the job’spackaging==25.0pin. Each is fixed and tested; the install now happens in a fresh venv, as the sdist check already did.
Both release workflows claimed
abi3-py39covering “CPython 3.9-3.13”; the wheels areabi3-py310covering 3.10–3.14.gffbase migrate --coalescecrashed on every invocation. The command passed a path tocoalesce_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_metadatapulled 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 aKeyErroron 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.dialectand.directiveswere methods where their base class has them as properties, with atype: ignorehiding the mypy error. The same expression worked against one iterator and raisedTypeError: 'list' object is not callableagainst another.Removed two dead definitions from the fallback parser (
_parse_coordand_LazyGFFFormatErrorProxy), neither referenced anywhere.FeatureDB.bed12()emitted ablockCountthat counted all block children whileblockSizes/blockStartssilently 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()andFeatureDB.bed12()did unguarded arithmetic on nullable coordinates, raisingTypeError: unsupported operand type(s) for -: 'NoneType' and 'int'instead of something actionable. Both now raise aValueErrornaming the feature.Passing a hand-built
Feature(which hasid is None) todb[...],children(),parents(),delete()orupdate()bound SQL NULL and silently matched nothing. It now raises.Roughly a dozen
con.execute(...).fetchone()[0]call sites would raiseTypeError: 'NoneType' object is not subscriptableon an empty result. They now go throughgffbase._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 –
HashMapin 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 ongms2_example.gff3. Both now tally in insertion order and break ties by first appearance. Guarded bytests/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.directivesis 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_alldid 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 acceptedexclude_componentsand 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 aParentpointing at the merged feature.The discriminator that makes this possible was missing too:
merge()setchildrenunconditionally, so every feature looked merged. A run of one now getsno_children.merge()extended onlyend. With a caller-suppliedmerge_orderthe 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 orderingmerge_allhad 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_intronscomputed introns across transcript boundaries.grandparent_featuretype="gene"was treated as the direct anchor, pooling every isoform’s exons into one sorted list. OnFBgn0031208.gffthat was 1 “intron” where the oracle finds 3, and for any multi-isoform gene the gaps produced were not introns of anything.create_splice_sitesemitted 1 bp sites. A splice site is a dinucleotide, so these named half of one. They were also always typedsplice_siterather than by position in the transcript, and carried no attributes at all.interfeaturesstamped no derivedsource, produced a nonsense feature spanning two different sequences whenever consecutive inputs changed seqid, typed unnamed gaps with a constant instead ofinter_<a>_<b>, never setstrandto.on a mismatch, ignorednumeric_sort, and took a three-argumentattribute_funcwhere the oracle takes one — so any gffutils caller passing a callback got aTypeError.bed12put a trailing comma onblockSizes/blockStarts(making every line differ), acceptedthin_featuretypeand ignored it with no mutual-exclusion error, and on a feature with no CDS set both thickStart and thickEnd tochromStart— rendering it entirely thin, the opposite of what the oracle draws.children_bpswallowed unknown keyword arguments, including the removedignore_strand, returning a plausible number instead of saying no.Three
merge_criteriapredicates were distance tests, not range tests.overlap_end_thresholdand friends computedabs(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_relationdiscarded its callbacks’ return values and wrote nothing back, sochild_func=assign_childset an attribute on a throwaway object. Callbacks were also skipped entirely when ids were passed instead ofFeatures. A batchedadd_relationswas added because the closure is re-derived per call, which would have mademerge_allquadratic.
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.
metaheld the dialect, the format, the R-tree flag and the depths, but nothing said whether a database had been built incompatorstrictmode — so a file on disk could not report how it was made. It now storesmode,validationandon_error, readable asdb.modeand friends. Additive, so no schema version bump; a database written earlier has no key and reads back ascompat, which is what it was.Reading an attribute and writing the feature back out could emit structurally invalid GFF3. Materializing
feature.attributestakes serialization off the raw-bytes fast path, and there was no re-encode step, so percent-escaping was simply lost:Note=hello%20worldcame back asNote=hello world, and — far worse — a value containing;or,came back bare. Ontests/data/upstream/nonasciione 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"rebuildsfeatures.attributes_blobfrom the decodedattributesrows, 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), andgffutils.parser’sQuoter,quoter,_reconstructandquoted_semicolon_patternsare available under their upstream names. Note that it is deliberately noturllib.parse.quote: the space is not a reserved character in GFF3 and non-ASCII is not escaped, soName=CkIIα[Tik]-1survives unchanged wherequote()would have producedCkII%CE%B1[Tik]-1.Porting
_reconstructwholesale rather than only the encoder also fixed three things that were silently wrong on the normalized path:keep_orderanddialect["order"]were ignored,repeated keyswas ignored (soParent=a;Parent=balways collapsed toParent=a,b), and the field separator was flattened to;or;, losing;.Verified two ways. The vendored
attr_test_cases.pytable — upstream’s own ground truth, 18 cases, shipped intests/data/upstream/and until now used by nothing — round-trips exactly. And a new differential test asserts thatto_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_mapwas populated during the R-tree build, which runs after GTF synthesis — so the pass that stamps a synthesized row’sseqid_yandbboxjoined an empty table and those rows kept a NULL envelope. Onensembl_gtf.txtthe 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.closurecould contain duplicate rows. GFF3 permits a DAG — a feature may name severalParents — so the same descendant is reachable by two paths of equal length, and the recursive CTE’sUNION ALLemitted one row per path. Onrandom-chr.gff,children(gene, level=2)returned five features of which only three were distinct. gffutils never had this because itsrelationstable is keyed on exactly that triple.A cyclic
Parentgraph 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 madechildren()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_versionlooked like a v1 database and was dutifully migrated. Ingest now builds beside the target and renames on success, so a failedforce=Trueoverwrite also leaves the original intact; being handed such a file from elsewhere is refused at open.The UCSC
bincolumn in the SQLite export was computed one level off —_BINOFFSETSwas missing its top entry and used 0 where the oracle uses 1, differing fromgffutils.binson ten of eleven representative ranges. Sincegffutils.FeatureDB.region(completely_within=True)filters onbin, an exported database answered those queries with nothing at all.export_sqlitewrote one row per logical feature, so a discontinuous feature was exported with its envelope coordinates rather than its lines. It now flattens throughsegments_allinto the N features gffutils itself would have made, fanning relations out over both endpoints and recording the grouping induplicates.The
attributestable disagreed withFeature.attributesfor 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 bareParent=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 aftergene_name, not an autoincremented fallback), and the rename applied after the edges are built so the hierarchy survives it.merge_strategy="merge"never regeneratedattributes_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.rsvalidated to the NCBI GFF3 specification unconditionally, butcreate_db()is the compatibility entry point and real annotation files break that spec routinely. Undercompatthe 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
##FASTAdirective was parsed as features. A bare>line ends the feature section in gffutils; gffbase only stopped at the directive, soFBgn0031208.gffgained three junk features from its sequence lines.The two engines disagreed on padded coordinates. Python’s
int()strips surrounding whitespace and Rust’sparse::<i64>()does not, so the Rust engine dropped any record with a coordinate like944828while the pure-Python fallback kept it – a silent, engine-dependent difference in which records exist.wormbase_gff2.txtexercises it.Null coordinates now round-trip.
features.start/"end"were declaredNOT NULL, so the Arrow batch builder coerced a.column to0: the feature reopened as0..0and 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_sitesandbed12. They now skip features that have no position, via one shared_with_coordinatesfilter – a feature outside coordinate space is not in the input domain of a coordinate operation, and raising instead would makemerge_all()unusable on any file containing such a row (WormBase emits them).bed12filtered null-coordinate block children after sorting them, so the guard added earlier in this release was unreachable and the sort raisedTypeErrorfirst.merge_strategy="merge"did not actually merge, as far as any caller could tell. It folded the incoming attributes into theattributestable but never regeneratedattributes_blob, andFeature.attributesreads 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 gffbasecrashed on Python 3.9.ParsedFeatureused@dataclass(slots=True), which is Python 3.10+, while the package declaredrequires-python >=3.9, shipped anabi3-py39wheel, and advertised a 3.9 classifier. Every 3.9 install succeeded and then failed on first import withTypeError: dataclass() got an unexpected keyword argument 'slots'.slotsis now applied conditionally, so 3.10+ keeps the per-record memory saving and 3.9 works.gffbase.gffwriterreferenced an undefinedioname in theGFFWriter.__init__type annotation (F821). The module is now imported._pyfallback.parserre-raised a coordinate parse failure without chaining, masking the originalValueError(B904).The
cargo testdoc-test target failed to compile: a module doc comment inrust/src/lib.rsused an indented block that rustdoc interpreted as Rust source. CI only rancargo test --lib, so this was never seen.Removed a dead
parse_coordinrust/src/parser.rs, superseded byparse_coord_strict, which caused adead_codewarning.
Removed¶
Six
PHASE*.mdentries frompyproject.tomlandMANIFEST.inreferring to files deleted in44268ce, plus arecursive-include python/gffbase *.pyimatching no files.The
memmap2Rust dependency, which was declared and never used — noMmapappears 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 ofbenchmarks/, andbenchmarks/common.pyreached 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 underbenchmarks/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 onFBgn0031208.gffline 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
IDwhere gffbase re-emits the source’sID=. 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.gffthe oracle’s behaviour silently loses aParentedge, 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¶
0.1.0’s metadata advertises Python 3.9 support that the artifact cannot deliver. Users who cannot move to 0.2.0 yet should apply the mitigations in the security advisory.
0.1.0 — 2026-05-07¶
Initial public release.