Skip to content

Add write_gtf: serialize a DataFrame back to GTF (fixes #21)#72

Merged
iskandr merged 5 commits into
masterfrom
feature/21-write-gtf
Jul 8, 2026
Merged

Add write_gtf: serialize a DataFrame back to GTF (fixes #21)#72
iskandr merged 5 commits into
masterfrom
feature/21-write-gtf

Conversation

@iskandr

@iskandr iskandr commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds write_gtf, the inverse of read_gtf, closing #21 (the library could read GTF but not write it). A DataFrame from read_gtf — whether attributes were expanded into per-key columns or left as a raw attribute string — can be written back out and re-read to recover an equivalent DataFrame.

from gtfparse import read_gtf, write_gtf

df = read_gtf("input.gtf")
write_gtf(df, "output.gtf", header_lines=["##provider: gtfparse"])

Credit

This adopts and finishes @Benoitdw's work in #46, which I've closed in favor of this branch. Original authorship is preserved via a Co-authored-by trailer.

What changed vs. #46

Building on the original approach, this fixes several correctness/compat issues that were blocking it:

  • Python 3.9 compatibility — the original used str | Path union syntax, which requires Python 3.10+; the project targets >=3.9. Switched to typing.Union / typing.Optional.
  • No silent data loss — the original dropped any attribute whose value was falsy (and (row[field])), silently losing 0, "", False. Attributes are now omitted only when the value is None (how read_gtf marks an absent key), so falsy-but-present values survive the round trip.
  • Missing valuesNone in the fixed columns is serialized as ., and null scores/frames round-trip correctly.
  • Canonical format — the attribute field now ends with a trailing ;, matching Ensembl/GENCODE output and read_gtf's parser.
  • Unexpanded round-trip — a column literally named attribute (from expand_attribute_column=False) is emitted verbatim, so that mode round-trips too.
  • pandas input — accepts a pandas DataFrame as well as polars (result_type="pandas").
  • Validation — raises ValueError if a required fixed column is missing.

Testing

tests/test_write_gtf.py (8 tests) covers:

  • read → write → read round trips for RefSeq and Ensembl fixtures, expanded and unexpanded
  • pandas-input round trip
  • the falsy-value regression (0 / "" preserved, None omitted)
  • missing values (.) in fixed columns
  • header lines
  • the missing-required-column ValueError

Full suite: 67 passed. ruff check + ruff format --check clean. Verified the module parses under the Python 3.9 grammar.

Fixes #21.

https://claude.ai/code/session_014fNxSBm5zvdsDNwN3nhmpS

Adds the inverse of read_gtf. A DataFrame produced by read_gtf -- whether
the attribute column was expanded into per-key columns or left as a raw
'attribute' string -- can be written back out and re-read to recover an
equivalent DataFrame.

This builds on the approach in #46 by Benoitdw, with fixes:

- Python 3.9 compatibility: use typing.Union / typing.Optional instead of
  the `str | Path` union syntax, which requires Python 3.10+ (the project
  targets >=3.9).
- No silent data loss: attribute values are omitted only when None (the
  way read_gtf represents an absent key), not when merely falsy. Values
  like 0 or the empty string are now written and survive a round trip.
- Missing values in the fixed columns are serialized as '.', and null
  scores/frames round-trip correctly.
- The attribute field ends with a trailing ';', matching the canonical
  GTF format emitted by Ensembl/GENCODE and re-parsed by read_gtf.
- A column literally named 'attribute' (unexpanded read) is emitted
  verbatim, so expand_attribute_column=False round-trips too.
- Accepts a pandas DataFrame as well as polars (result_type="pandas").
- Raises ValueError if any required fixed column is missing.

Tests cover round trips for RefSeq and Ensembl fixtures (expanded and
unexpanded), pandas input, header lines, missing-value handling, the
falsy-value regression, and the missing-column error.

Co-authored-by: Benoitdw <bw@oncodna.com>
Claude-Session: https://claude.ai/code/session_014fNxSBm5zvdsDNwN3nhmpS
@coveralls

coveralls commented Jul 8, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 28970590726

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.5%) to 95.618%

Details

  • Coverage increased (+0.5%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 251
Covered Lines: 240
Line Coverage: 95.62%
Coverage Strength: 0.96 hits per line

💛 - Coveralls

iskandr and others added 4 commits July 8, 2026 15:21
…tions

Addresses the review findings on the first draft:

- Faithful missing-value handling. read_gtf represents an absent attribute
  as null (numeric *_version columns) or the empty string (string columns)
  and cannot tell absent from present-but-empty. The writer now omits both
  null and "" so that read -> write -> read reproduces the parsed frame
  exactly, including column order, on multi-feature GTFs (GENCODE). A
  non-empty falsy value such as "0" is still written (the original #46 bug).

- Vectorized. The attribute field and full line are built with polars
  expressions (concat_str/format) instead of per-row Python; ~200k rows x
  6 attributes now writes in ~0.1s.

- gzip output. A path ending in .gz is gzip-compressed, mirroring read_gtf
  which transparently reads gzipped GTFs.

- Dropped the redundant fixed-column filter; fixed columns are always
  written in canonical order (the missing-column guard runs first).

- Documented that "\"" and ";" in attribute values cannot round-trip
  (a limitation shared with read_gtf, which strips quotes and splits on ";").

Tests now cover both directions across RefSeq/Ensembl/GENCODE/StringTie
fixtures: write(read(...)) recovers the parsed frame and is byte-idempotent
(a fixed point); read(write(...)) recovers a DataFrame's values. Plus gzip,
pandas input, the "0"-vs-empty-vs-null semantics, "." for missing fixed
values, header lines, the structural-character limitation, and the
missing-column error.

Co-authored-by: Benoitdw <bw@oncodna.com>
Claude-Session: https://claude.ai/code/session_014fNxSBm5zvdsDNwN3nhmpS
Follow-ups from review of #72:

- Detect the gzip suffix case-insensitively (.GZ as well as .gz).
- Note in the docstring that a literal tab or newline in an attribute
  value is unrepresentable too, alongside the existing " and ; caveat.
- Add tests for the zero-row DataFrame (writes only header lines), a
  DataFrame with only the fixed columns (valid 9-field line, empty
  attribute), and case-insensitive .GZ detection.

Co-authored-by: Benoitdw <bw@oncodna.com>
Claude-Session: https://claude.ai/code/session_014fNxSBm5zvdsDNwN3nhmpS
Minor release: adds write_gtf, the inverse of read_gtf, for serializing a
DataFrame of genomic features back to GTF (#21).

Claude-Session: https://claude.ai/code/session_014fNxSBm5zvdsDNwN3nhmpS
@iskandr iskandr merged commit cb3788e into master Jul 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Read_gtf but no write_gtf?

2 participants