You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
fgpyo.fasta.sequence_dictionary.SequenceDictionary. Use this when working with sequence dictionaries (@SQ lines) instead of poking at SAM headers directly.
fromfgpyo.fasta.sequence_dictionaryimportSequenceDictionary, Keyssd=SequenceDictionary.from_sam(sam_header) # from a pysam AlignmentHeadersd=SequenceDictionary.from_fasta(Path("ref.fasta")) # via the .dict / .fai sidecar# Lookup by index, name, or aliasentry=sd[0]
entry=sd["chr1"]
entry[Keys.MD5] # any standard SAM @SQ fieldentry[Keys.ASSEMBLY] ="GRCh38"delentry[Keys.SPECIES]
# Round-trip back to a SAM headerheader=sd.to_sam_header()
Keys enum contains the standard @SQ fields (SN, LN, M5, AS, SP, UR, etc.) plus their long-name aliases.
There's also a sequence_dictionary(fasta_path) helper that loads (or generates) the .dict and returns a list of dicts — fine for ad-hoc use, but SequenceDictionary is the typed entry point.
FastxZipped — zipped iteration over paired FASTX streams
fgpyo.fastx.FastxZipped. Replaces ad-hoc zip() over two pysam.FastxFiles, with mismatched-name detection and proper cleanup. Works with FASTA and FASTQ, and any number of input streams.
Calling FastaBuilder.to_file(path).fasta etc.to_file returns the path written; the indexes are siblings (<path>.fai, <path>.replace(.fasta, .dict)).
Iterating a FastxZipped without a with block. It opens lazily in __enter__; using outside a context manager skips proper cleanup.
Storing yielded FastxRecord instances when persist=False. They get invalidated on the next iteration. Copy .name / .sequence / .quality out, or pass persist=True.
Reaching for raw pysam.FastaFile to mock a small reference.FastaBuilder produces fully-indexed FASTA in 3 lines and is the convention in tests across the codebase.
fgmetric (PyPI: fgmetric, on GitHub at fg-labs/fgmetric) is a Pydantic-backed Metric class for typed delimited files. Prefer fgmetric.Metric over fgpyo.util.Metric — fgmetric's validation, error messages, list/Counter handling, and Pydantic ecosystem (custom validators / serializers, aliases) are stronger.
Requires Python ≥ 3.12. Pydantic ≥ 2.11.4 is pulled in transitively.
Subclass and go
frompathlibimportPathfromfgmetricimportMetric, MetricWriterclassAlignmentMetric(Metric):
read_name: strmapping_quality: intis_duplicate: bool=False# Write — context manager only; header is emitted on enter.withMetricWriter(AlignmentMetric, Path("out.tsv")) asw:
w.write(AlignmentMetric(read_name="r1", mapping_quality=60))
w.write(AlignmentMetric(read_name="r2", mapping_quality=30, is_duplicate=True))
# Read — streaming iterator. Strings are coerced to int/float/bool by Pydantic.forminAlignmentMetric.read(Path("out.tsv")):
...
No @attr.s, no @dataclass — Metric inherits from Pydantic BaseModel, so fields are declared the Pydantic way. Field(alias=...), custom validators, and @field_serializer all work.
API signatures
classMetric(BaseModel, ABC):
collection_delimiter: ClassVar[str] =","# used for list[T] fields@classmethoddefread(
cls,
path: Path,
delimiter: str="\t",
fieldnames: Sequence[str] |None=None, # supply for headerless files
) ->Iterator[Self]: ...
Fields annotated as list[T] are read from a delimited substring and serialized back the same way. The within-field delimiter defaults to , and is controlled by the collection_delimiter class variable (must be a single character).
Round-trips are lossy if list elements themselves contain the delimiter — pick a delimiter your data never contains.
Counter[StrEnum] fields pivot wide ↔ long
A single field typed Counter[E] (where E: StrEnum) is pivoted out to one column per enum member on write, and rolled back up into a Counter on read. Missing members default to count 0. No CounterPivotTable mixin is needed — Metric already inherits it.
fromcollectionsimportCounterfromenumimportStrEnumfromfgmetricimportMetricclassBase(StrEnum):
A="A"C="C"G="G"T="T"classBaseCountMetric(Metric):
position: intcounts: Counter[Base]
# Input TSV:# position A C G T# 1 10 5 3 2## Reads as: BaseCountMetric(position=1,# counts=Counter({Base.A: 10, Base.C: 5, Base.G: 3, Base.T: 2}))# Writes back to the same wide layout.
Constraints (enforced at class-init):
At most one Counter field per model.
The type parameter must be a StrEnum subclass (not int, not a Literal, not a non-Str enum).
Counter[E] | None is not allowed — required field.
Optional fields, empty strings, defaults
Empty string in an input column whose field annotation is T | None is converted to None automatically.
Empty string in a required field surfaces as a pydantic.ValidationError (with field name + reason).
Defaults work as in Pydantic — count: int = 0 etc. — and missing optional columns fall back to those defaults on read.
Common mistakes
MyMetric.write(path, *rows). fgmetric has no class-level write method. Use MetricWriter as a context manager. (This is the most common drift from fgpyo's Metric.)
MetricWriter(MyMetric, Path("out.tsv.gz")) expecting auto-gzip. It will write uncompressed text into a file with a .gz suffix. fgmetric does not detect or apply compression. If you need gzip output: write to a .tsv temp path and gzip after, or open gzip.open(...)/fgpyo.io.to_writer(...) yourself and write rows via the underlying csv.DictWriter (not via MetricWriter).
@attr.s / @dataclass on the subclass.Metric is a Pydantic BaseModel — declare fields directly with type annotations and (optionally) Field(...). Adding attrs decorators breaks Pydantic's field discovery.
MyMetric(name="x", count="3")-style coercion test in code. Pydantic does coerce, but lean on .read() (which is fed strings by csv.DictReader) rather than constructing models from string-typed Python values yourself.
Forgetting with MetricWriter(...) as w:. Header is written on __enter__; using the writer outside a with block (or forgetting close()) can leave the header unwritten or the file open.
from fgpyo.util.metric import Metric. That's the legacy import. Use from fgmetric import Metric, MetricWriter.
Stacking CounterPivotTable ahead of Metric in the bases list. Unnecessary — Metric already inherits the pivot mixin.
Differences vs fgpyo.util.Metric (quick map)
You're used to (fgpyo)
fgmetric equivalent
@attr.s(frozen=True, kw_only=True, auto_attribs=True, slots=True) on the subclass
Just class M(Metric): name: str; n: int (Pydantic)
MyMetric.write(path, *rows)
No class-level write — with MetricWriter(M, path) as w: w.writeall(rows)
cls.format_value(value) override for serialization
Pydantic @field_serializer / @model_serializer
MyMetric.header()
No public equivalent. The internal cls._header_fieldnames() is private; if you need the header before writing, instantiate a MetricWriter and the header is written automatically.
Not supported — write a fresh file, or filter the iterable yourself.
Legacy fallback: fgpyo.util.Metric
If a project pins fgmetric out or forbids it, fall back to fgpyo.util.Metric — same conceptual shape, attrs-based, with gzip support and Metric.write(path, *rows) / MetricWriter. The fgpyo Metric pattern lives in the project's existing code; reach for it only when fgmetric is unavailable.
Validators (asserted at construction; AssertionError, not ValueError):
end > start
thick_start and thick_end are both set or both None
block fields all set together; lengths match block_count; first block_start is 0; last block ends at end
BedRecord.from_interval(interval) round-trips an Interval back to a BedRecord. BedStrand has only Positive / Negative — unstranded round-trips through Interval collapse to Positive.
There is no unstranded variant; use BedRecord.strand = None for that.
BedSource — streaming reader
pybedlite.BedSource (also exposed as pybed.reader(path)). ContextManager[BedSource], Iterable[BedRecord].
importpybedliteaspybedfrompathlibimportPathwithpybed.reader(Path("regions.bed")) assrc:
forrecinsrc:
print(rec.chrom, rec.start, rec.end, rec.name)
src.num_fields# set after first parse — e.g. 6 for BED6
Behavior:
Skips blank lines and lines starting with #, browser, track.
Asserts at least 3 columns per row.
Field-aware parsing: . becomes None; item_rgb parsed as (r, g, b); block_sizes / block_starts parsed as list[int].
Iterating without with auto-opens / auto-closes on exhaustion.
Leave num_fields=None to inherit from the first record's bed_field_num.
Mismatched widths raise ValueError unless truncate=True (record has more fields) or add_missing=True (record has fewer; pads with .).
Interval — lightweight stranded interval
pybedlite.overlap_detector.Interval. Use this when you don't need the BED12 surface area but want something hashable that satisfies StrandedSpan.
frompybedlite.overlap_detectorimportIntervaliv=Interval("chr1", 100, 200)
iv2=Interval("chr1", 150, 250, negative=True, name="r2")
iv.overlap(iv2) # 50 (0 if different refname or no overlap)iv.length() # 100# UCSC 1-based-closed → 0-based half-openInterval.from_ucsc("chr1:127140001-127140050(-)")
# Interval(refname='chr1', start=127140000, end=127140050, negative=True)Interval.from_bedrecord(rec)
Validators (raise ValueError): start >= 0, end > start. Round-tripping through BedRecord.from_interval collapses unstranded to BedStrand.Positive.
OverlapDetector — interval-tree overlap queries
pybedlite.overlap_detector.OverlapDetector. Generic over any object that satisfies the Span (refname/start/end) or StrandedSpan (also negative) protocol — BedRecord and Interval both qualify out of the box.
frompathlibimportPathfrompybedlite.overlap_detectorimportOverlapDetector, Interval# From a list of typed intervals or BedRecordsdetector=OverlapDetector([
Interval("chr1", 0, 100),
Interval("chr1", 150, 250, negative=True),
Interval("chr2", 500, 600),
])
detector.add(Interval("chr1", 200, 400))
# Or directly from a BED file (returns OverlapDetector[BedRecord])detector=OverlapDetector.from_bed(Path("targets.bed"))
# Queries — `interval` is anything satisfying Span (refname/start/end)query=Interval("chr1", 90, 210)
detector.overlaps_any(query) # Truedetector.get_overlaps(query) # list of stored items overlapping the querydetector.get_enclosing_intervals(query) # items that fully contain the querydetector.get_enclosed(query) # items fully contained inside the querylen(detector) # 4list(detector) # all stored items
Any hashable user-defined attrs class with refname, start, end (and optionally negative) can be stored in an OverlapDetector directly — no inheritance required. BedRecord.refname is an alias for chrom, so it satisfies Span despite using chrom as its underlying field name.
Common mistakes
Importing OverlapDetector from top-level pybedlite. It lives in pybedlite.overlap_detector.
Calling the reader BedReader. It's BedSource (or pybedlite.reader(path)).
Adding intervals to an OverlapDetector between queries. Indexes rebuild on every add — load all intervals first, then query in a tight loop.
Treating BedRecord.score as 0..1000 only. It's a plain int | None; pybedlite does not clamp.
Expecting Interval to track unstranded. It only has negative: bool; round-tripping unstranded BedRecord ↔ Interval lossy-converts to BedStrand.Positive.
Using BedRecord.strand == "+". It's a BedStrand enum (or None); compare with BedStrand.Positive or use rec.negative.
Catching ValueError from a malformed BedRecord. Construction validates with assert — that's AssertionError (and is disabled by python -O). Validate inputs before constructing if you need controlled error handling.
SamBuilder.default_sd(), SamBuilder.default_rg(), and builder.header give you the active dict / RG / pysam AlignmentHeader.
add_pair — keyword-only
builder.add_pair(
*,
name: str|None=None, # auto-named if Nonebases1: str|None=_UNSET, # _UNSET = random; pass `None` to omit sequencebases2: str|None=_UNSET,
quals1: list[int] |None=_UNSET, # _UNSET = base_quality; `None` to omitquals2: list[int] |None=_UNSET,
chrom: str|None=None, # both reads same contigchrom1: str|None=None, # OR: separate per-read contigschrom2: str|None=None,
start1: int=sam.NO_REF_POS,
start2: int=sam.NO_REF_POS,
cigar1: str|None=None, # default = all-M of read lengthcigar2: str|None=None,
mapq1: int|None=None,
mapq2: int|None=None,
strand1: str="+", # default FR orientationstrand2: str="-",
attrs: dict[str, Any] |None=None, # extra SAM tags applied to both records
) ->tuple[AlignedSegment, AlignedSegment]
add_pair does not take secondary / supplementary kwargs — use add_single(read_num=1, supplementary=True, ...) / read_num=2 for those records and reuse the same name= to attach them to a template.
tpl.set_mate_info() # reset mate flags + MC/MQ across the templatetpl.set_tag("RX", umi) # set/remove a tag on every recordtpl.write_to(out_writer, primary_only=False)
tpl.validate() # asserts r1/r2 flags / supps consistent# Build from an explicit list (e.g. inside a test)tpl=Template.build(records, validate=True)
Template.iterator(...) requires query-name-grouped input (sorted by qname or sorted-by-name groupby). Coord-sorted input will produce broken templates.
fromfgpyo.samimportCigar, CigarOp, CigarElementcig=Cigar.from_cigarstring("5S50M2D5M10S")
cig.length_on_query() # 70 (5+50+5+10) — METHOD, needs parenscig.length_on_target() # 57 (50+2+5) — METHOD, needs parenscig.elements# tuple[CigarElement, ...]cig.reversed() # Cigar with element order reversedcig.coalesce() # merge adjacent same-op runscig.truncate_to_query_length(50) # clip from the right by query basescig.truncate_to_target_length(50)
str(cig) # back to "5S50M2D5M10S"Cigar.from_cigartuples(rec.cigartuples) # from pysam tuples# Per-element — these are @property, no parenselem=cig.elements[1] # CigarElement(length=50, operator=CigarOp.M)elem.length_on_query# 50 if op consumes query else 0 (PROPERTY)elem.length_on_target# 50 if op consumes reference else 0 (PROPERTY)CigarOp.M.consumes_query# TrueCigarOp.S.consumes_reference# FalseCigarOp.I.is_indel# TrueCigarOp.S.is_clipping# True
CigarOp enum: M, I, D, N, S, H, P, EQ, X. Each carries .consumes_query, .consumes_reference, .is_indel, .is_clipping.
SupplementaryAlignment — SA-tag parser
fromfgpyo.samimportSupplementaryAlignment# From a single comma-stringsa=SupplementaryAlignment.parse("chr1,123,-,100M,60,0")
sa.reference_name, sa.start, sa.is_forward, sa.cigar, sa.mapq, sa.nm# From the full SA tag valuesas=SupplementaryAlignment.parse_sa_tag(rec.get_tag("SA"))
# Or directly from a record (handles missing tag → empty list)sas=SupplementaryAlignment.from_read(rec)
The _by_query variants clip a fixed number of query bases off the start/end of the alignment; _by_ref variants clip until N reference bases are dropped. All four mutate the record in place and invalidate alignment-dependent tags.
Passing a coord-sorted BAM to Template.iterator. It groups by adjacency only — input must be query-name-grouped.
Calling add_pair(secondary=True). Not a valid kwarg; add_pair only emits a primary pair. Use add_single(read_num=..., secondary=True, ...) and reuse the same name= to attach to a template.
Passing bases=None expecting random bases. That produces a record with no sequence. Omit the kwarg (use the _UNSET default) for random.
Writing CIGAR length math by hand.Cigar.length_on_query() / .length_on_target() already exists.
Calling cig.length_on_query without parens. Methods on Cigar, properties on CigarElement — easy to mix up. cig.length_on_query returns a bound method object, not an int.
Using pysam.AlignmentFile(str(path), "rb") and switching on .endswith(".cram").fgpyo.sam.reader(path) already does this.
fromfgpyo.read_structureimportReadStructure, ReadSegment, SegmentTypers=ReadStructure.from_string("75T8B8M+T") # last "+T" = variable-length templaters.segments# tuple[ReadSegment, ...]
[str(s) forsinrs] # ["75T", "8B", "8M", "+T"]# Slice raw bases / qualities by segmentsubreads=rs.extract("A"*158+"G"*4)
subreads_q=rs.extract_with_quals(bases, quals)
# Each subread has .bases (and .quals for the with_quals variant), plus the originating ReadSegment.# Filter segments by kindtemplates=rs.template_segments()
sample_bcs=rs.sample_barcode_segments()
mol_bcs=rs.molecular_barcode_segments()
cell_bcs=rs.cell_barcode_segments()
skips=rs.skip_segments()
# Helper to make an existing structure end with a variable-length last segmentrs.with_variable_last_segment()
SegmentType enum:
Code
Member
Meaning
T
Template
Template / insert bases
B
SampleBarcode
Sample barcode
M
MolecularBarcode
UMI / molecular barcode
C
CellBarcode
Cell barcode
S
Skip
Bases to discard
ReadSegment exposes .offset, .length (None for variable +), .kind, .has_fixed_length, and .extract(bases) / .extract_with_quals(bases, quals).
PeekableIterator / is_sorted — fgpyo.collections
fromfgpyo.collectionsimportPeekableIterator, is_sortedp=PeekableIterator(iter([1, 2, 3, 4]))
p.peek() # 1 (no consumption)p.can_peek() # Truenext(p) # 1p.takewhile(lambdax: x<4) # [2, 3] (consumes through the predicate)p.dropwhile(lambdax: x<5) # PeekableIterator (returns self after skipping)is_sorted([1, 2, 2, 3]) # Trueis_sorted([3, 1]) # False
PeekableIterator is the standard "I need to look ahead to decide whether to consume" tool — use it instead of manually buffering one item.
Use these instead of writing custom complement tables or edit-distance loops.
fgpyo.platform.illumina — read-name UMI helpers
fromfgpyo.platform.illuminaimport (
extract_umis_from_read_name,
copy_umi_from_read_name,
SAM_UMI_DELIMITER, # "-"
)
# Extract from a read nameumi=extract_umis_from_read_name(
"M00111:1:1:1:1:1:1:ACGT+CCGG",
read_name_delimiter=":", # split read name fields hereumi_delimiter="+", # split multi-UMIs here (joined back with "-")strict=False, # True = raise on malformed
)
# umi == "ACGT-CCGG"# Copy onto a record's RX tagok=copy_umi_from_read_name(rec, strict=False, remove_umi=False)
Use these for Illumina UMI extraction instead of writing your own read_name.split(":")[-1] parser — they understand the multi-UMI convention and validate UMI characters.
Common mistakes
Calling ReadStructure.extract with the wrong total length. Fixed-length structures must match exactly; if the read has variable length, use a structure ending in +T / +B / etc. or call rs.with_variable_last_segment().
Using next(it) and a try/except StopIteration to peek.PeekableIterator(it).peek() is the standard idiom and composes with takewhile / dropwhile.
Hand-rolling Counter to compute GC content.fgpyo.sequence.gc_content exists.
Splitting Illumina read names manually for UMIs.extract_umis_from_read_name handles the multi-UMI + → - translation and validates characters.
fgpyo.vcf.builder.VariantBuilder. Use this in tests instead of constructing pysam.VariantFile and VariantHeader manually. The builder synthesizes a header (with the supplied sequence dictionary and sample list), tracks VariantRecord instances, and can produce a real on-disk VCF.
Use when writing Python that handles SAM/BAM/CRAM, FASTA/FASTQ, VCF, or BED files; building test fixtures with bioinformatics records; parsing CIGARs or sequencing read structures; doing genomic interval overlap queries; or modeling typed delimited (TSV/CSV) records. Surfaces fgpyo, pybedlite, and fgmetric features so common primitives (SamBuilder, Template, Cigar, Metric, MetricWriter, BedSource, OverlapDetector) are used instead of hand-rolled equivalents. fgmetric.Metric is preferred over fgpyo.util.Metric.
Using fgpyo, pybedlite, and fgmetric
Three Fulcrum Genomics libraries (fgpyo, pybedlite, fgmetric — all on PyPI) cover most Python bioinformatics primitives. Default to them. Do not hand-roll the equivalents.
If they aren't installed in the project, add them. If a project bans them, do not push.
Metric preference: For typed delimited records, use fgmetric.Metric (Pydantic-backed), not fgpyo.util.Metric (attrs-backed). The fgpyo version is the legacy fallback when fgmetric is unavailable. See references/fgmetric.md.
Built against (verify before trusting specifics)
This skill — including all reference files — was authored against:
Before relying on this skill in a project, check the project's installed versions of these packages (uv pip show fgpyo pybedlite fgmetric, or read pyproject.toml / uv.lock). If any is newer than the version above:
Briefly tell the user the skill is older than their pinned version and that you're going to verify any non-trivial API surface you reach for.
For each specific class / method / kwarg you use from this skill on the new version, sanity-check it against the project's installed source (e.g. python -c "import fgpyo.sam; help(fgpyo.sam.SamBuilder.add_pair)" or read the upstream changelog) before writing code. Don't assume the table below still matches.
If you hit an unexpected AttributeError, TypeError, or ValueError on something this skill recommended, that's the version drift biting — verify against the installed source rather than retrying.
If the project's versions are equal or older, the skill is authoritative.
Reach for, do not re-implement
Symptom in your code
Use instead
Reference
Hand-built pysam.AlignedSegment ceremony for tests
SamBuilder().add_pair(chrom=..., start1=..., start2=...) for primary pairs; .add_single(name=<same>, read_num=1, supplementary=True, ...) to attach supps/secondaries; .to_unsorted_list() to keep records in memory or .to_path(path=tmp_path / "in.bam") for an on-disk BAM
sam.md
Buffered loop grouping records by query name
Template.iterator(reader) (yields Template with .r1, .r2, .r1_supplementals, etc.)
sam.md
Computing CIGAR query/target lengths by hand
Cigar.from_cigarstring(s).length_on_query() / .length_on_target() (methods, not properties)
sam.md
pysam.AlignmentFile(str(p), "rb") switched on extension
fgpyo.sam.reader(path)
sam.md
Parsing SA tag fields by hand
SupplementaryAlignment.from_read(rec)
sam.md
Manually setting mate-info flags / MC / MQ tags
set_mate_info(r1, r2) or Template.set_mate_info()
sam.md
Custom CSV/TSV writer for tabular metric records
Subclass fgmetric.Metric (Pydantic — class M(Metric): name: str; n: int); write with with MetricWriter(M, path) as w: w.write(row) per record (or w.writeall(iterable)); read via M.read(path) (streaming iterator)
For anything in these domains not in the table — load the matching reference file before writing code and check whether fgpyo/pybedlite already covers it.
references/fgmetric.md — fgmetric.Metric (Pydantic-backed typed delimited records) and MetricWriter (streaming context-manager writer). Covers list[T] auto-split, Counter[StrEnum] pivot fields, headerless fieldnames= reads, custom delimiters, and the gotchas that differ from fgpyo.util.Metric (no class-level write, no gzip auto-detect, no threads, no header()). Preferred over fgpyo.util.Metric.
references/io.md — fgpyo.io: auto-gzip readers/writers, path assertions, line helpers. (Tabular typed records belong in fgmetric.md.)
SamBuilder gotchas (inline — no need to load sam.md)
No add_supplementary / add_secondary method. Attach a supplementary or secondary to a pair via add_single(name=<same name as the pair>, read_num=1 or 2, supplementary=True, ...).
add_pair does NOT accept supplementary / secondary kwargs. Its only kwargs are name, bases1/2, quals1/2, chrom / chrom1 / chrom2, start1/2, cigar1/2, mapq1/2, strand1/2, attrs. Use add_single for any non-primary record.
sort_order takes the SamOrder enum, not a string.SamBuilder(sort_order=SamOrder.QueryName) (import from fgpyo.sam import SamOrder). Default is SamOrder.Coordinate. Passing a string raises ValueError.
In-memory vs on-disk: if the function under test takes records, just use the tuples returned from add_pair / add_single (or builder.to_unsorted_list()). Only call .to_path(path=tmp_path / "in.bam") when the function under test actually opens a BAM by path.
add_pair(bases1=None) produces a record with no sequence, not a random sequence. Omit the kwarg (let it default to the _UNSET sentinel) for random bases.
fgmetric gotchas (inline — no need to load fgmetric.md)
fgmetric.Metric has NO class-level write method.MyMetric.write(path, *rows) is a fgpyo.util.Metric pattern; on fgmetric you must use with MetricWriter(MyMetric, path) as w: w.write(row) (or w.writeall(iterable)).
No @attr.s / @dataclass on the subclass.Metric is a Pydantic BaseModel; declare fields with plain type annotations (and optionally Field(...)). Adding attrs decorators breaks Pydantic's field discovery.
No gzip auto-detect. Passing Path("out.tsv.gz") to MetricWriter writes uncompressed text into a .gz filename. Write uncompressed and gzip after, or fall back to fgpyo.util.Metric (which does auto-detect) when gzip output is a hard requirement.
Metric already inherits CounterPivotTable. Subclass as class M(Metric): — do not write class M(CounterPivotTable, Metric):.
MetricWriter must be used as a context manager. The header is written on __enter__; bare construction without with (or without close()) can leave the file open or unflushed.
Headerless TSV/CSV: pass fieldnames=[...] to Metric.read(path, fieldnames=...). Order must match the file's column order.
Cross-cutting conventions
Frozen attrs is the project style: @attr.s(frozen=True, kw_only=True, auto_attribs=True, slots=True). Match this in code consuming these libraries.
0-based half-open coordinates everywhere in pybedlite. Interval.from_ucsc(...) is the only converter from UCSC chr:start-end 1-based-closed strings.
Builders return real on-disk paths.SamBuilder.to_path(), VariantBuilder.to_path(), FastaBuilder.to_file(path) produce files for tests; to_unsorted_list() / to_sorted_list() keep records in memory.