Python API¶
fqxv ships a read-only Python package for consuming .fqxv archives
directly from Python — no subprocess, no intermediate FASTQ file. Compression
stays in the CLI; the Python side reads, and with estimate()
measures without writing anything.
uv pip install fqxv
The wheels are abi3 (one per platform, CPython ≥ 3.9) and carry the native
codecs, so there is no separate Rust toolchain to install. To build from a
checkout instead:
uv pip install maturin
maturin develop # from crates/fqxv-python/
Iterating records¶
fqxv.open() streams records in the order the archive stores them — the original
file order for every order-preserving archive, which is the default and includes
every paired archive. It works on every layout, including the
globally-reordered ones (--order any, --max, --order shuffle), where
single-end reads come back clustered rather than in file order; check
fqxv.inspect(path).keep_order if that matters. Decoding runs on a background
thread behind a bounded channel, so a plain archive streams in constant memory;
a globally-reordered archive has mutually dependent streams and is decoded
whole-file, so it stays resident while you iterate.
import fqxv
for rec in fqxv.open("reads.fqxv"):
print(rec.name, rec.sequence, rec.quality) # all bytes
print(len(rec)) # sequence length
Each Record exposes name (the header with the leading @ stripped),
sequence, and quality as bytes. Bytes — not str — because reads are
stored losslessly and read names are not guaranteed to be UTF-8.
In-memory input works anywhere a path does — pass bytes instead of a filename:
data = open("reads.fqxv", "rb").read()
n = sum(1 for _ in fqxv.open(data))
So does any file-like object with .read() — an HTTP response or an AWS SDK
body streams straight in, with no staging file and no HTTP code in fqxv:
import boto3
body = boto3.client("s3").get_object(Bucket=bucket, Key=key)["Body"]
for rec in fqxv.open(body):
...
File-like sources work for the forward-only entry points — open(),
decompress_to_path(), decompress_to_bytes(). Everything else (inspect,
open_index, the projections, read_block, verify) seeks, so it takes a path
or bytes.
open() also works as a context manager, and breaking out early is safe (the
decode thread is stopped cleanly):
with fqxv.open("reads.fqxv") as reader:
for rec in reader:
if rec.sequence.startswith(b"ACGT"):
break # no hang; the decoder is torn down on exit
Pass threads= to control the decode pool (default 0 = all cores).
Decoding only some streams¶
Many consumers never read all three streams — k-mer counters and classifiers
want only sequences, an ID audit only names. open() takes a streams=
selection: one stream name or an iterable of names out of "names",
"sequence", "quality" (the short forms "name"/"seq"/"qual" also
work). Deselected fields come back as empty bytes, and their coded streams
are skipped, never entropy-decoded — the decode cost of everything you did
not ask for disappears.
# Sequence-only pass: k-mer counting / classification input, straight from the archive.
for rec in fqxv.open("reads.fqxv", streams=("seq",)):
count(rec.sequence) # rec.name == rec.quality == b""
ids = [r.name for r in fqxv.open("reads.fqxv", streams="names")]
# streams=() decodes nothing: count reads at framing speed.
n = sum(1 for _ in fqxv.open("reads.fqxv", streams=()))
How much skipping buys depends on where the archive's decode compute sits: sequence-only decode measured ~12× faster on an ONT long-read archive (the sequence-conditioned quality model dominates decode there) and ~1.3–1.6× single-threaded on short-read Illumina; at high thread counts the short-read win shows up as freed CPU time rather than wall time. Two caveats:
- Long-read quality needs the sequence. On long-read archives quality is coded against the bases, so selecting quality still decodes the sequence stream internally (it is returned empty unless selected). Names and sequence have no such dependency.
- Skipped streams are not verified. Every stream that is decoded keeps
its integrity checks, but a skipped stream's content digest cannot be checked
(its content is never reconstructed). Run
fqxv.verify()when you need the whole archive vouched for.
Selection works on every layout, including globally-reordered archives — unlike the column projections below, which need the plain layout's footer index but can also skip reading the deselected bytes (the streaming selection reads the whole file and skips the decode).
FASTA output¶
decompress_to_path() and decompress_to_bytes() take fasta=True — the CLI's
decompress --fasta as an API: single-line FASTA (>name + sequence), with the
quality stream skipped entirely, at the same speedups as above.
fqxv.decompress_to_path("reads.fqxv", "reads.fasta", fasta=True)
fa = fqxv.decompress_to_bytes("reads.fqxv", fasta=True)
Whole-archive convenience¶
# Write interleaved FASTQ to a file; returns the read count.
n = fqxv.decompress_to_path("reads.fqxv", "reads.fastq")
# ...or get the FASTQ bytes directly.
raw = fqxv.decompress_to_bytes("reads.fqxv")
# Metadata only — no payload decode.
info = fqxv.inspect("reads.fqxv")
print(info.reads, info.blocks, info.format_version, info.platform)
inspect() returns an Info with reads, blocks, group_size,
reordered, keep_order, regenerated_names, plus_normalized,
format_version, seq_order, quality_binning, names_bytes,
sequence_bytes, quality_bytes, platform, whole_file_crc,
required_features, and member_labels.
format_version is the container format version packed as
(major << 8) | minor — 256 for format 1.0 — and is independent of the
fqxv package version. member_labels holds the original per-slot labels
(["R1", "R2"], ["2", "4"], …) when the archive recorded them, and is empty
otherwise. platform is the human label ("Illumina", "Oxford Nanopore",
"PacBio", "MGI/BGI", "unknown"); seq_order, quality_binning, and
required_features are the raw header codes (quality_binning 0 = lossless);
whole_file_crc is None for the footer-less globally-reordered layout.
Column projection & random access¶
An .fqxv archive carries a footer index with per-stream byte offsets and CRCs.
That makes it Parquet-shaped: you can fetch and decode a
single column, or a single row group, without touching the rest. Read names are
typically under 1% of the archive, so an ID-only pass reads ~100× less.
# Just the read names (IDs) — a fraction of the archive.
ids = fqxv.read_names("reads.fqxv")
# Just the sequences, skipping quality entirely.
seqs = fqxv.read_sequences("reads.fqxv") # list[bytes], one per read
# Restrict to specific row groups.
first_group = fqxv.read_sequences("reads.fqxv", groups=[0])
read_names, read_sequences, and read_qualities each return a flat
list[bytes] across the requested groups (all groups when groups is omitted),
verifying every fetched stream against its stored CRC before decoding. Long-read
quality is coded against the sequence, so read_qualities also reads the
sequence column for those groups; names and sequences stand alone.
Inspect the index and decode a whole row group as records:
idx = fqxv.open_index("reads.fqxv")
print(idx.total_reads, idx.num_groups)
for g in idx.groups():
print(g.block_offset, g.read_count)
block0 = fqxv.read_block("reads.fqxv", 0) # list[Record]
groups() returns GroupLoc objects with block_offset and read_count.
Index also carries the per-stream primitives a remote client drives:
stream_range(group, stream) → (start, end) (half-open),
stream_crc(group, stream), and verify_stream(group, stream, coded), where
stream is "names", "sequence", or "quality".
Reordered archives
Projection and open_index require the plain (per-block) layout. A
globally-reordered archive (--order any, --max, --order shuffle) has no
footer index — its streams are mutually dependent — so these raise
fqxv.FqxvError. Use fqxv.open() to iterate those (with streams= if you
only need a subset). Check
fqxv.inspect(path).reordered if you need to branch. Compressing with a
smaller --block-reads makes projection finer-grained on the plain layout.
Estimating a compression ratio¶
fqxv.estimate() projects an archive's size from a FASTQ input (not an
archive) by coding a bounded leading sample with the real codecs — the library
behind fqxv compress --estimate. gzip/BGZF input is decoded transparently, and
nothing is written.
est = fqxv.estimate("reads.fastq.gz") # level=5 by default
print(est.ratio, est.archive_bytes, est.exhausted)
# Mates that would compress into one archive: pass a list (or tuple).
est = fqxv.estimate(["R1.fastq.gz", "R2.fastq.gz"], level=9)
Estimate carries sample_reads, sample_bases, raw_bytes, names_bytes,
sequence_bytes, quality_bytes, archive_bytes, exhausted, platform, and
ratio. The byte counts describe the sample; ratio (raw ÷ archive) is
scale-invariant and holds for the whole file. exhausted is True when the whole
input fit in the sample, so the numbers are exact rather than an extrapolation
base. platform is the lowercase token detected from the sample ("illumina",
"nanopore", "pacbio", "mgi", "unknown") — not the same spelling as
Info.platform, which is the human label.
Keyword arguments: level (1-9, default 5), quality_binning ("lossless",
"bin8", "bin4", "bin2", "ont", "hifi"), sample_reads (default
1048576), and threads. A grouped estimate splits sample_reads across its
inputs and rejects a cross-platform mix. Reordering and interleaving are not
modelled, so the real archive comes out this size or smaller.
Verifying an archive¶
fqxv.verify("reads.fqxv") # returns None; raises if the archive is bad
Checks the header, footer, and stored whole-file CRC in one linear pass (the globally-reordered layout, which has no footer, is checked by a full decode). This is the tool for "is this archive intact": a streaming read of a truncated archive can end early and silently, since the block region carries no running read count.
Reading over HTTP¶
fqxv.remote reads an archive over HTTP using the standard library only (no
dependencies). Streaming feeds the response straight into the decoder; projection
fetches the footer index from the archive tail and then only the columns you ask
for, with Range requests.
import fqxv.remote as remote
for rec in remote.stream("https://host/reads.fqxv"): # or a presigned S3 URL
... # streams; no full download
arc = remote.open_index("https://host/reads.fqxv") # one tail GET
names = arc.names() # ~1% of the file, CRC-checked
print(arc.bytes_fetched, "of", arc.size)
n = remote.download("https://host/reads.fqxv", "reads.fastq") # read count
RemoteArchive exposes .names(), .sequences(), .qualities(), and
.records() — each taking an optional groups= — plus .index, .size, and
.bytes_fetched. The module-level shortcuts are open_index, read_names,
read_sequences, read_qualities, stream, and download; all accept
headers= for an Authorization header on a private object. stream() also
takes streams= (decode only some streams while
streaming — the archive body is still transferred) and download() takes
fasta=True.
To drive the same thing from a different client — an async httpx/aiohttp
session issuing range fetches concurrently — call the IO-free primitives
directly:
index, need = fqxv.parse_index_suffix(tail, file_size) # tail = bytes=-65536 body
start, end = index.stream_range(0, "names") # then GET bytes=start-(end-1)
index.verify_stream(0, "names", coded)
names = fqxv.decode_names_bytes(coded)
parse_index_suffix(suffix, file_len) returns (Index, None) when the fetched
tail reached the footer, or (None, need_at_least) when it fell short — refetch
that many trailing bytes and call again; the second length is exact, so it costs
at most one extra round trip. Long-read quality is coded against the sequence:
quality_needs_sequence_bytes(coded) reports that, and
decode_qualities_bytes(coded, seq) takes the group's concatenated decoded bases.
Errors¶
Decode and I/O failures raise exceptions: a missing or unreadable file raises
OSError; a corrupt or truncated archive, an unsupported format version or
feature, and any projection on a reordered archive raise fqxv.FqxvError. A
source of the wrong type raises TypeError, and a bad argument value (an
unknown quality_binning or stream name, an empty source list) raises
ValueError.
try:
fqxv.verify("suspect.fqxv")
except fqxv.FqxvError as e:
print("archive problem:", e)
except OSError as e:
print("cannot read it:", e)
API reference¶
| Function | Returns | Notes |
|---|---|---|
open(source, *, threads=0, streams=None) |
Reader |
Iterator of Record; every layout. streams= decodes a subset — deselected fields are b"", their streams skipped |
decompress_to_path(source, dest, *, threads=0, fasta=False) |
int |
Read count; interleaved FASTQ, or single-line FASTA with fasta=True (quality skipped) |
decompress_to_bytes(source, *, threads=0, fasta=False) |
bytes |
Interleaved FASTQ, or single-line FASTA with fasta=True |
inspect(source) |
Info |
Header + footer metadata |
open_index(source) |
Index |
Footer row-group index (plain layout) |
read_names(source, groups=None) |
list[bytes] |
Names for the groups (or all) |
read_sequences(source, groups=None) |
list[bytes] |
Sequences for the groups (or all) |
read_qualities(source, groups=None) |
list[bytes] |
Qualities for the groups (or all) |
read_block(source, group) |
list[Record] |
Decode one whole row group |
estimate(source, *, level=5, quality_binning="lossless", sample_reads=1048576, threads=0) |
Estimate |
Projected size/ratio from a FASTQ; source may be a list of mates |
verify(source, *, threads=0) |
None |
Integrity check; raises if the archive is bad |
Random-access primitives — no I/O of their own, for driving a custom (e.g. async)
remote client; fqxv.remote is built on them:
| Function | Returns | Notes |
|---|---|---|
parse_index_suffix(suffix, file_len) |
(Index \| None, int \| None) |
Footer index from a fetched tail, else the bytes needed |
decode_names_bytes(coded) |
list[bytes] |
Decode one fetched names column |
decode_sequences_bytes(coded) |
list[bytes] |
Decode one fetched sequence column |
decode_qualities_bytes(coded, seq=None) |
list[bytes] |
Decode one fetched quality column |
quality_needs_sequence_bytes(coded) |
bool |
Whether that column needs seq |
source is a path (str / os.PathLike) or bytes; open,
decompress_to_path, and decompress_to_bytes also accept a file-like object
with .read(). Record has .name / .sequence / .quality (bytes) and
len(); Index has .total_reads / .num_groups / .whole_file_crc,
.groups(), and .stream_range() / .stream_crc() / .verify_stream();
GroupLoc has .block_offset / .read_count.