Skip to content

Commit fa664fd

Browse files
iskandrBenoitdw
andcommitted
Add write_gtf: serialize a DataFrame back to GTF (fixes #21)
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
1 parent 03668a3 commit fa664fd

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

gtfparse/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
parse_gtf_pandas,
2323
read_gtf,
2424
)
25+
from .write_gtf import write_gtf
2526

2627
__version__ = "2.7.1"
2728

@@ -37,4 +38,5 @@
3738
"parse_gtf_and_expand_attributes",
3839
"parse_gtf_pandas",
3940
"read_gtf",
41+
"write_gtf",
4042
]

gtfparse/write_gtf.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import logging
14+
from collections.abc import Iterable
15+
from pathlib import Path
16+
from typing import TYPE_CHECKING, Optional, Union
17+
18+
import polars
19+
20+
if TYPE_CHECKING:
21+
import pandas
22+
23+
logger = logging.getLogger(__name__)
24+
25+
# The eight tab-separated columns that precede the attribute field of a GTF
26+
# line, in the order they must be written. Any other column in a DataFrame is
27+
# treated as an expanded attribute (see read_gtf's expand_attribute_column).
28+
GTF_FIXED_COLUMNS = [
29+
"seqname",
30+
"source",
31+
"feature",
32+
"start",
33+
"end",
34+
"score",
35+
"strand",
36+
"frame",
37+
]
38+
39+
# GTF uses a single dot to denote a missing value in the fixed columns.
40+
MISSING_VALUE = "."
41+
42+
43+
def _format_fixed_value(value) -> str:
44+
"""Render a fixed-column value, using '.' for missing values."""
45+
if value is None:
46+
return MISSING_VALUE
47+
return str(value)
48+
49+
50+
def _format_attributes(row: dict, attribute_columns: list[str], raw_column: Optional[str]) -> str:
51+
"""
52+
Build the GTF attribute field for a single row.
53+
54+
If ``raw_column`` is given (an unexpanded 'attribute' column) its value is
55+
emitted verbatim. Otherwise each expanded attribute column is serialized as
56+
``key "value";`` and the pairs are joined with a single space, matching the
57+
format produced by common GTF writers (and parsed by read_gtf).
58+
59+
Attributes whose value is None are omitted, since that is how read_gtf
60+
represents a key that was absent on a given row. Note that a value must be
61+
*None* to be skipped -- falsy-but-present values such as 0 or the empty
62+
string are written out, so they survive a read/write round trip.
63+
"""
64+
if raw_column is not None:
65+
raw = row[raw_column]
66+
return "" if raw is None else str(raw)
67+
68+
parts = []
69+
for column_name in attribute_columns:
70+
value = row[column_name]
71+
if value is None:
72+
continue
73+
parts.append('%s "%s";' % (column_name, value))
74+
return " ".join(parts)
75+
76+
77+
def write_gtf(
78+
df: Union[polars.DataFrame, "pandas.DataFrame"],
79+
path: Union[str, Path],
80+
header_lines: Optional[Iterable[str]] = None,
81+
) -> None:
82+
"""
83+
Write a DataFrame of genomic features back out to a GTF file.
84+
85+
This is the inverse of :func:`read_gtf`. A DataFrame produced by
86+
``read_gtf`` (whether the attribute column was expanded or not) can be
87+
written back out and re-read to recover an equivalent DataFrame.
88+
89+
Parameters
90+
----------
91+
df : polars.DataFrame or pandas.DataFrame
92+
Feature rows to write. Must contain the fixed GTF columns
93+
(seqname, source, feature, start, end, score, strand, frame).
94+
Any additional columns are written as attributes, except a column
95+
literally named 'attribute', which is treated as a pre-formatted
96+
attribute string and emitted verbatim.
97+
98+
path : str or pathlib.Path
99+
Destination file path. Any existing file is overwritten.
100+
101+
header_lines : iterable of str, optional
102+
Lines to write at the top of the file before any feature rows, e.g.
103+
``["##description: example", "##provider: GENCODE"]``. Each is written
104+
verbatim on its own line, so include a leading '#' if you want it to be
105+
parsed back as a comment.
106+
"""
107+
# Accept a pandas DataFrame too, since read_gtf(result_type="pandas")
108+
# returns one; convert to polars so the row iteration below is uniform.
109+
if not isinstance(df, polars.DataFrame):
110+
df = polars.from_pandas(df)
111+
112+
columns = df.columns
113+
fixed_columns = [name for name in GTF_FIXED_COLUMNS if name in columns]
114+
missing_fixed = [name for name in GTF_FIXED_COLUMNS if name not in columns]
115+
if missing_fixed:
116+
raise ValueError(
117+
"DataFrame is missing required GTF column(s): %s" % ", ".join(missing_fixed)
118+
)
119+
120+
# A column named 'attribute' is the raw, unexpanded attribute string;
121+
# everything else that isn't a fixed column is an expanded attribute.
122+
raw_column = "attribute" if "attribute" in columns else None
123+
attribute_columns = [
124+
name for name in columns if name not in GTF_FIXED_COLUMNS and name != "attribute"
125+
]
126+
127+
n_rows = 0
128+
with open(path, "w") as output_file:
129+
if header_lines is not None:
130+
for line in header_lines:
131+
output_file.write("%s\n" % line)
132+
for row in df.iter_rows(named=True):
133+
fixed_fields = [_format_fixed_value(row[name]) for name in fixed_columns]
134+
attribute_field = _format_attributes(row, attribute_columns, raw_column)
135+
output_file.write("%s\t%s\n" % ("\t".join(fixed_fields), attribute_field))
136+
n_rows += 1
137+
138+
logger.info("Wrote %d GTF rows to %s", n_rows, path)

tests/test_write_gtf.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import polars
2+
import pytest
3+
from polars.testing import assert_frame_equal
4+
5+
from gtfparse import read_gtf, write_gtf
6+
7+
from .data import data_path
8+
9+
REFSEQ_GTF_PATH = data_path("refseq.ucsc.small.gtf")
10+
ENSEMBL_GTF_PATH = data_path("ensembl_grch37.head.gtf")
11+
12+
13+
def _assert_round_trips(source_path, tmp_path, expand_attribute_column=True):
14+
"""read -> write -> read should recover an equivalent DataFrame."""
15+
original = read_gtf(source_path, expand_attribute_column=expand_attribute_column)
16+
out_path = tmp_path / "round_trip.gtf"
17+
write_gtf(original, out_path)
18+
recovered = read_gtf(str(out_path), expand_attribute_column=expand_attribute_column)
19+
# categorical_as_str avoids spurious mismatches from category orderings
20+
assert_frame_equal(original, recovered, categorical_as_str=True)
21+
22+
23+
def test_round_trip_expanded_refseq(tmp_path):
24+
_assert_round_trips(REFSEQ_GTF_PATH, tmp_path, expand_attribute_column=True)
25+
26+
27+
def test_round_trip_expanded_ensembl(tmp_path):
28+
# Ensembl file exercises many attribute columns and null score values
29+
_assert_round_trips(ENSEMBL_GTF_PATH, tmp_path, expand_attribute_column=True)
30+
31+
32+
def test_round_trip_unexpanded(tmp_path):
33+
# A raw, unexpanded 'attribute' column should be written verbatim
34+
_assert_round_trips(REFSEQ_GTF_PATH, tmp_path, expand_attribute_column=False)
35+
36+
37+
def test_round_trip_from_pandas(tmp_path):
38+
# write_gtf should also accept a pandas DataFrame (result_type="pandas")
39+
original_polars = read_gtf(ENSEMBL_GTF_PATH)
40+
original_pandas = read_gtf(ENSEMBL_GTF_PATH, result_type="pandas")
41+
out_path = tmp_path / "from_pandas.gtf"
42+
write_gtf(original_pandas, out_path)
43+
recovered = read_gtf(str(out_path))
44+
assert_frame_equal(original_polars, recovered, categorical_as_str=True)
45+
46+
47+
def test_falsy_attribute_values_are_preserved(tmp_path):
48+
"""Attributes whose value is 0 or "" must be written; only None is omitted."""
49+
df = polars.DataFrame(
50+
{
51+
"seqname": ["chr1"],
52+
"source": ["test"],
53+
"feature": ["gene"],
54+
"start": [1],
55+
"end": [100],
56+
"score": [None],
57+
"strand": ["+"],
58+
"frame": [None],
59+
"gene_id": ["G1"],
60+
"zero_attr": ["0"],
61+
"empty_attr": [""],
62+
"missing_attr": [None],
63+
}
64+
)
65+
out_path = tmp_path / "falsy.gtf"
66+
write_gtf(df, out_path)
67+
line = out_path.read_text().strip()
68+
assert 'zero_attr "0"' in line
69+
assert 'empty_attr ""' in line
70+
# a None-valued attribute is omitted entirely
71+
assert "missing_attr" not in line
72+
73+
74+
def test_missing_value_fixed_columns_use_dot(tmp_path):
75+
"""None values in the fixed columns are serialized as '.'."""
76+
df = polars.DataFrame(
77+
{
78+
"seqname": ["chr1"],
79+
"source": ["test"],
80+
"feature": ["gene"],
81+
"start": [1],
82+
"end": [100],
83+
"score": [None],
84+
"strand": ["+"],
85+
"frame": [None],
86+
"gene_id": ["G1"],
87+
}
88+
)
89+
out_path = tmp_path / "dots.gtf"
90+
write_gtf(df, out_path)
91+
fields = out_path.read_text().strip().split("\t")
92+
assert fields[5] == "." # score
93+
assert fields[7] == "." # frame
94+
95+
96+
def test_header_lines_are_written(tmp_path):
97+
df = read_gtf(REFSEQ_GTF_PATH)
98+
out_path = tmp_path / "with_header.gtf"
99+
header = ["##description: test", "##provider: gtfparse"]
100+
write_gtf(df, out_path, header_lines=header)
101+
lines = out_path.read_text().splitlines()
102+
assert lines[0] == "##description: test"
103+
assert lines[1] == "##provider: gtfparse"
104+
# header comment lines are ignored by read_gtf, so it still round-trips
105+
recovered = read_gtf(str(out_path))
106+
assert_frame_equal(df, recovered, categorical_as_str=True)
107+
108+
109+
def test_missing_required_column_raises(tmp_path):
110+
df = polars.DataFrame({"seqname": ["chr1"], "gene_id": ["G1"]})
111+
with pytest.raises(ValueError, match="missing required GTF column"):
112+
write_gtf(df, tmp_path / "bad.gtf")

0 commit comments

Comments
 (0)