Metadata-Version: 2.4
Name: gff3-validator
Version: 0.1.0
Summary: One-stop GFF3 validator: GFF3 1.26 syntax, structure and Sequence Ontology rules, plus optional FAIR-bioHeaders (FHGFF3) header checks
License: MIT
License-File: LICENSE
Keywords: GFF3,validator,Sequence Ontology,genome annotation,FAIR
Author: Adam Wright
Author-email: adam.wright@oicr.on.ca
Requires-Python: >=3.9,<4.0
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Provides-Extra: headers
Requires-Dist: fair-bioheaders (>=0.4,<0.5) ; extra == "headers"
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
Project-URL: Bug Tracker, https://github.com/FAIR-bioHeaders/gff3-validator/issues
Project-URL: Documentation, https://github.com/FAIR-bioHeaders/gff3-validator/blob/main/docs/rules.md
Project-URL: Homepage, https://github.com/FAIR-bioHeaders/gff3-validator
Project-URL: Repository, https://github.com/FAIR-bioHeaders/gff3-validator
Project-URL: Specification, https://github.com/FAIR-bioHeaders/FHR-Specification/blob/main/specs/009-gff3-validator/spec.md
Description-Content-Type: text/markdown

# gff3-validator

A one-stop validator for [GFF3](https://github.com/The-Sequence-Ontology/Specifications/blob/master/gff3.md)
annotation files, built by FAIR-bioHeaders with the Sequence Ontology (SO) group.

> **Status: early release (0.1.0).** The rule catalogue is a draft under
> review with the Sequence Ontology group
> ([FHR-Specification #62](https://github.com/FAIR-bioHeaders/FHR-Specification/issues/62)).
> The core syntax, attribute, directive and structure rules that the GFF3
> specification settles are implemented, and the optional biology rules run
> with `--genome`; Sequence Ontology and header rules are not yet. Install
> with `pip install gff3-validator`, or use it in the browser at
> https://fair-bioheaders.github.io/gff3-validator/. Rule ids, levels and
> output formats may still change before 1.0.

## Goal

One place, one name, three interfaces (web page, CLI, library) that tell you
whether a GFF3 file is valid against the GFF3 specification (version 1.26) and
the Sequence Ontology, and that explain every problem in plain language with
the rule id, the line and a suggested fix. When a file carries a FAIR-bioHeaders
(FHGFF3) provenance header, the validator also checks it and its link to the
genome. See [spec 009](https://github.com/FAIR-bioHeaders/FHR-Specification/blob/main/specs/009-gff3-validator/spec.md)
(copied in [specs/001-gff3-validator](specs/001-gff3-validator/spec.md)).

Plain GFF3 without a header is a first-class input: core validation does not
depend on FAIR-bioHeaders.

## Rule catalogue

Every check is a numbered rule in [rules/catalogue.yaml](rules/catalogue.yaml),
rendered as [docs/rules.md](docs/rules.md). Each rule has an id, level
(error, warning, info), layer (core, FHGFF3, biology), a reference into
GFF3 1.26 or SO, a bad example and a fix, its implementation status and its SO
review state. Open questions for SO are collected in
[docs/questions-for-SO.md](docs/questions-for-SO.md). A suggested fix is
guidance only; the validator never rewrites a file.

[docs/EVALUATION.md](docs/EVALUATION.md) reviews the 2024 FHGFF3 prototype, the
LinkML GFF3 model and existing validators (GenomeTools, AGAT, GFF3toolkit, the
SO-era Perl validator, NCBI table2asn), with a side-by-side run on small broken
files.

### Implemented rules

<!-- implemented-rules:start (generated by scripts/render_rules.py; do not edit) -->
68 of 88 catalogue rules are implemented:

- `GFF-SYN-001` (error): First line is the ##gff-version 3 directive
- `GFF-SYN-002` (error): Only one ##gff-version directive
- `GFF-SYN-003` (error): Feature lines have nine tab-separated columns
- `GFF-SYN-004` (error): Undefined columns are ".", not empty
- `GFF-SYN-005` (error): No unescaped control characters
- `GFF-SYN-006` (warning): File is UTF-8
- `GFF-SYN-007` (info): Possible end-of-line comment
- `GFF-SYN-008` (error): Percent-encoding is well formed
- `GFF-SYN-009` (warning): Characters encoded that need not be
- `GFF-SYN-010` (error): seqid uses the allowed characters
- `GFF-SYN-012` (error): type is defined
- `GFF-SYN-013` (error): start and end are integers
- `GFF-SYN-014` (error): start is at least 1
- `GFF-SYN-015` (error): start is not greater than end
- `GFF-SYN-016` (error): score is a number or "."
- `GFF-SYN-017` (error): strand is +, -, . or ?
- `GFF-SYN-018` (error): phase is 0, 1, 2 or "."
- `GFF-SYN-019` (error): CDS features have a phase
- `GFF-ATT-001` (error): Attributes are tag=value pairs separated by semicolons
- `GFF-ATT-002` (error): Tags and values are not empty
- `GFF-ATT-003` (info): Empty attribute pair or trailing semicolon
- `GFF-ATT-004` (error): Each tag appears once per line
- `GFF-ATT-005` (error): Reserved characters are escaped in tags and values
- `GFF-ATT-006` (error): Single-valued reserved tags have one value
- `GFF-ATT-007` (warning): Unknown reserved (upper-case) tag
- `GFF-ATT-008` (warning): Tag differs from a reserved tag only by case
- `GFF-ATT-009` (info): Quoted attribute value
- `GFF-ATT-010` (error): Target has the form "target_id start end [strand]"
- `GFF-ATT-011` (error): Gap is a list of operations
- `GFF-ATT-012` (warning): Gap is given with Target
- `GFF-ATT-013` (warning): Gap lengths agree with the feature and Target lengths
- `GFF-ATT-014` (error): Dbxref and Ontology_term values are DBTAG:ID
- `GFF-ATT-016` (warning): Is_circular is "true"
- `GFF-DIR-001` (error): ##sequence-region has the form: seqid start end
- `GFF-DIR-002` (error): One ##sequence-region per seqid
- `GFF-DIR-003` (error): ### closes all forward references
- `GFF-DIR-004` (error): Only FASTA after ##FASTA
- `GFF-DIR-005` (warning): FASTA section starts with ##FASTA
- `GFF-DIR-006` (warning): FASTA records are well formed
- `GFF-DIR-007` (warning): ##species is an NCBI Taxonomy URL
- `GFF-DIR-008` (warning): ##genome-build has a source and a build name
- `GFF-DIR-009` (info): Ontology directives are recorded but not fetched
- `GFF-DIR-010` (info): Unknown directive
- `GFF-STR-001` (error): IDs are unique, except for the lines of one discontinuous feature
- `GFF-STR-002` (warning): Lines of one feature share seqid and strand
- `GFF-STR-003` (warning): Lines of one feature have the same Parent
- `GFF-STR-004` (error): Parent refers to an ID in the file
- `GFF-STR-005` (error): Derives_from refers to an ID in the file
- `GFF-STR-006` (error): No Parent cycles
- `GFF-STR-007` (warning): No Derives_from cycles
- `GFF-STR-008` (error): Features are within their ##sequence-region
- `GFF-STR-009` (info): seqid without ##sequence-region
- `GFF-STR-010` (warning): seqids appear in the ##FASTA section
- `GFF-STR-011` (error): Features fit within the embedded sequence
- `BIO-001` (error): seqid is in the genome
- `BIO-002` (error): Features fit within the genome sequence
- `BIO-003` (warning): ##sequence-region agrees with the genome
- `BIO-004` (warning): CDS phases are consistent
- `BIO-005` (warning): CDS segments lie within exons
- `BIO-006` (warning): CDS starts with a start codon
- `BIO-007` (warning): CDS ends with a stop codon
- `BIO-008` (warning): No internal stop codons
- `BIO-009` (info): CDS length is a multiple of three
- `BIO-010` (warning): Strand is consistent within a gene
- `BIO-011` (info): Biology checks skipped
- `HDR-001` (error): FHGFF3 header present when required
- `HDR-002` (info): FHGFF3 header present but not validated
- `HDR-003` (info): Header checks skipped (--no-header)
<!-- implemented-rules:end -->

## Planned interfaces

- **CLI** `gff3-validate FILE [--genome genome.fa [--translation-table N]] [--format text|json|html|sarif]`, reading
  plain, gzip/BGZF or stdin (`-`) input. Exit codes: 0 no errors, 1 errors
  found, 2 usage error or unreadable input (the run is then incomplete).
- **Library** `gff3_validator.validate(path)` returning findings with rule id,
  level, line, column, message and fix.
- **Web page** running the same engine in the browser through Pyodide, so
  files never leave the computer (see [Use it in your browser](#use-it-in-your-browser)).
- **Reports** in text, JSON, HTML (one self-contained file) and SARIF 2.1.0
  (for code-scanning annotations in CI).
- **Repository profiles** (NCBI, Ensembl, Alliance) layered on the core rules,
  later and only with each repository's review.
- **Distribution** on PyPI and Bioconda, a Galaxy wrapper, an FHR-Nextflow
  module and a container, after the first reviewed release.

Headers: a FAIR-bioHeaders header is validated when present; `--require-header`
makes it mandatory and `--no-header` skips header checks. Header checks need the
optional extra, `pip install "gff3-validator[headers]"` (FAIR-bioHeaders
toolkit 0.4). FHGFF3 support in the toolkit is not released yet, so the
skeleton only detects `#~` header lines and reports that header validation is
not implemented.

## Current skeleton

```bash
poetry install
poetry run gff3-validate tests/fixtures/valid/canonical_gene.gff3
poetry run gff3-validate --format json annotation.gff3.gz
```

The reader streams line by line and stops reading features at `##FASTA`;
FASTA records are read for their ids and lengths only, never kept. Per-line
rules keep no state. Findings for problems that can occur on most lines
(GFF-SYN-006, GFF-SYN-009, GFF-ATT-003, GFF-ATT-009) are reported once, at
the first occurrence, with a count, so they cannot crowd errors out of the
`--max-findings` cap.

### Memory

The structure rules (IDs, Parent and Derives_from references, cycles, bounds)
need state. Memory is O(number of distinct IDs) plus O(number of seqids):

- each ID gets an integer index and four packed array entries (first line,
  seqid/type/strand combination, first Parent, Parent-list hash); features with
  several Parents or with Derives_from keep the extra edges in sparse maps;
- features without an ID are not remembered after their line, except for a
  reference to an ID not yet defined;
- forward references are kept per referenced ID (first line and count), not
  per line, and are resolved when the ID appears or at the end of the file;
  `###` only advances a counter, so a reference that crosses it is detected
  when the ID is finally defined (GFF-DIR-003);
- per seqid: its first line, furthest start and end, and at most 100 lines
  beyond its `##sequence-region` end (then a count);
- Parent and Derives_from cycles are found at the end of the file by an
  iterative depth-first search (one byte of state per ID, no recursion).

On a synthetic file of 3.1 million lines (143,000 genes with two mRNAs, five
exons and four CDS segments each, 2.1 million distinct IDs) a run takes about
22 s and 380 MiB peak RSS on Python 3.13, and 40 s and 430 MiB on Python 3.9
(roughly 170 to 200 bytes per ID). The opt-in test regenerates such a file and
reports the numbers:

```bash
GFF3_VALIDATOR_PERFORMANCE_TEST=1 poetry run pytest -s tests/test_performance.py
# GFF3_VALIDATOR_PERFORMANCE_LINES=3000000 sets the size (default 2000000)
```

### Biology checks (`--genome`)

```bash
gff3-validate --genome genome.fa.gz annotation.gff3
gff3-validate --genome genome.fa --translation-table 11 bacterium.gff3
```

The biology rules (BIO-001 to BIO-011) run only with `--genome`; without it
the report lists the biology layer as not checked. They compare seqids and
lengths with the genome, check CDS phases, CDS within exons, start, stop and
internal stop codons, coding length and strand consistency. Codon checks use
an NCBI translation table: 1 by default (the table is never inferred from the
organism; bacteria, archaea and plastids need `--translation-table 11`).
Partial CDS have no standard marker in GFF3, so the codon rules are warnings.
Translation exceptions (selenocysteine, pyrrolysine, readthrough, stops
completed by polyadenylation) are exempt when the codon is covered by a
`recoded_codon` child of the CDS (or a subtype), following the SO/NCBI
discussion in SO-Ontologies#658 (pending SO confirmation), or by a legacy
`transl_except` attribute.

The genome is a FASTA file, plain or gzip/BGZF (detected from the magic bytes;
not stdin). Leading `;` lines, such as a FAIR-bioHeaders `;~` header, are
skipped. The validator reads the file once to build an in-memory index like
samtools faidx (name, length, offset, bases and bytes per line; memory
O(number of sequences)) and then reads CDS bases on demand, in 64 kb blocks
with a 2 MiB cache, so no chromosome is loaded whole. A gzip genome is first
decompressed to an anonymous temporary file in `TMPDIR`, which needs as much
free disk space as the uncompressed genome and is removed at the end. A genome
that cannot be indexed reliably (sequence before the first `>`, duplicate
names, lines of different lengths inside a record, non-sequence characters)
stops the run with exit code 2.

CDS segments (four integers per line), the exons of each transcript and the
recoded codons of each CDS are kept until the next `###` or the end of the
file, then judged and dropped. With `###` after each gene this is a few
genes' worth; without `###` it is about 40 bytes per CDS line and 20 bytes per
exon, plus about 300 bytes per transcript. On a synthetic file of 524,000
lines (23,809 genes on both strands, 47,618 CDS of 1,066 codons) with a
231 MiB genome, a run takes about 9 s and 97 MiB peak RSS on Python 3.13
(14 s and 104 MiB on 3.9); without `###` lines, 140 MiB. The opt-in
performance test includes this case
(`GFF3_VALIDATOR_PERFORMANCE_GENOME_LINES` sets its size).

## Use it in your browser

<https://fair-bioheaders.github.io/gff3-validator/> (once GitHub Pages is
enabled for this repository) runs the validator inside the page: the
`gff3-validator` wheel built from this repository, in Python compiled to
WebAssembly ([Pyodide](https://pyodide.org/)). Drop or pick a `.gff3` or
`.gff3.gz` file, optionally add a genome FASTA for the biology checks and a
translation table, choose how to treat a FAIR-bioHeaders header, and the page
shows the HTML report and offers it, the JSON and the SARIF report for
download. The reports are the ones `gff3-validate --format html|json|sarif`
writes for the same file and options; CI checks this on every fixture
(`web/test/parity.mjs`).

**Privacy.** Your files are read by the page and never uploaded. After the
page has loaded, the only requests are for the Python runtime and PyYAML from
one pinned Pyodide release on `cdn.jsdelivr.net` and for the validator wheel
from the page's own site. The page's Content-Security-Policy (a meta tag; the
validator runs in a Web Worker started from a `blob:` URL, which inherits it)
allows connections only to the site itself and to that Pyodide release, so the
page could not send a file elsewhere even by mistake. `pyodide.js` and
`pyodide-lock.json` are loaded with Subresource Integrity (sha384, in
[web/pyodide.json](web/pyodide.json)); the lock file pins the sha256 of
PyYAML, and the page checks the wheel against the sha256 in its
`manifest.json`. The rest of the Pyodide runtime (`pyodide.asm.mjs`, the
WebAssembly binary and the standard library) is loaded by `pyodide.js` from
the same pinned release without SRI, because Pyodide offers no way to pass
hashes for them.

**Size limits.** The annotation is read from disk in 1 MiB slices and is never
held in memory; memory grows with the number of feature IDs, as on the command
line. Measured in headless Chrome (Pyodide 314.0.7, Python 3.14) on the
synthetic files of the performance test:

| Input | Time | WebAssembly memory |
|---|---|---|
| 1.1 million lines, 90 MB plain (1.05 million IDs) | 18 s | 155 MiB |
| 3.3 million lines, 28 MB gzip (270 MB uncompressed, 3.15 million IDs) | 56 s | 387 MiB |
| 524,000 lines (42 MB) with a 231 MiB genome, plain or gzip | 18 s | 90 to 108 MiB, plus the genome |

That is two to three times slower than the CLI. A full vertebrate annotation
(about 3 million lines) works. Memory grows by about 120 bytes per ID and
WebAssembly memory cannot exceed 4 GiB (browsers may stop a tab sooner), so
files several times larger than those measured here (not tested) need the
command line. The genome is copied into the page's memory (a gzip genome is also
decompressed there), so genomes of a few hundred MB are fine and multi-GB
genomes are not; use `gff3-validate --genome` for those.

Build and try the page locally:

```bash
poetry run python scripts/build_web.py      # builds the wheel and web/dist
python -m http.server -d web/dist 8000      # then open http://localhost:8000/
```

`.github/workflows/pages.yml` builds `web/dist`, runs the parity check and
deploys to GitHub Pages on every push to `main` (and on demand). To bump
Pyodide, run `poetry run python scripts/build_web.py --pin VERSION` (it
downloads `pyodide.js` and `pyodide-lock.json` of that release, computes the
SRI hashes, checks that PyYAML is included and rewrites `web/pyodide.json`),
then `cd web/test && npm install --save-exact pyodide@VERSION`, rebuild and run
`node web/test/parity.mjs --python "poetry run python"`.

## Development

See [CONTRIBUTING.md](CONTRIBUTING.md). The rule catalogue is the source of
truth: edit `rules/catalogue.yaml`, then run `python scripts/render_rules.py` to
regenerate `docs/rules.md`, the packaged copy and the list above (CI checks for
drift with `--check`).

## License

[MIT](LICENSE). Copyright (c) 2026 Adam Wright and David Molik
(FAIR-bioHeaders contributors).

