Skip to content

Readers

Driven adapters that read genomic data from external file formats and produce immutable domain objects. Readers sit at the boundary between the domain and the filesystem — they own all format-specific parsing and coordinate conversion.

Overview

Taxonomy

Reader Format Output type
VcfReader VCF / BCF VariantAnnotation per ALT allele
VcfCohortReader Multi-sample VCF / BCF Variant + GenotypeCount per allele
BedRegionReader BED 3–6 GenomicPosition
GtfReader GTF / GFF3 GtfRecord (low-level)
GencodeGeneRepository GENCODE GTF / GFF3 GeneAnnotation (implements GeneRepository)
TabixReader bgzipped + tabix-indexed (base) dict rows — used internally by annotators
ZarrVariantReader vcf2zarr Zarr stores VariantAnnotation
NirvanaReader Nirvana JSON VariantAnnotation with transcripts, classifications, and scores
EmedgeneReader Emedgene (Illumina) Excel report VariantAnnotation

TabixReader — shared foundation

TabixReader is the base class behind every bgzipped file reader and all tabix-based annotators. It provides two access patterns:

  • _query(path, targets) — point lookups by (chrom, pos, ref, alt) key.
  • _query_region(path, regions) — interval scans over GenomicPosition ranges.

Both patterns handle chromosome alias normalization automatically. For parallel workloads, each worker must open its own TabixFile; handles are never shared across threads.

VCF readers

VcfReader handles single-sample and joint-genotyped VCFs. When a sample_id is provided it returns only ALT alleles present in that sample and infers zygosity from the GT field. Without a sample, all ALT alleles in every record are yielded. store_info=True serializes all INFO fields into Metadata.data.

VcfCohortReader is the population-level counterpart. It reads a multi-sample VCF for a defined sample subset and computes per-variant GenotypeCount statistics (ref/ref, ref/alt, alt/alt counts). Two API levels are available: iter_region_counts for raw counts, and a higher-level method that returns VariantAnnotation objects with inline frequency annotations.

Coordinate convention

All readers convert to 1-based inclusive before constructing GenomicPosition:

  • BED (0-based half-open) → start + 1; end stays as-is
  • pysam .fetch() (0-based half-open) → start + 1
  • GTF / GENCODE — already 1-based inclusive, no conversion needed
  • Zarr variant_position — already 1-based

GENCODE provider

GencodeGeneRepository implements the GeneRepository port directly, making it drop-in compatible with any service that depends on that interface. It parses a GENCODE GTF (or GFF3) and builds GeneAnnotation objects with full transcript structure. Filtering options: transcript support level, MANE Select tag, and specific biotypes.

Optional dependency

All readers backed by pysam use a lazy import with a clear error message if the package is absent:

try:
    import pysam
except ImportError as exc:
    raise ImportError("pysam is required: uv pip install 'iris[genomics]'") from exc

VCF

iris.adapters.driven.readers.vcf

VCF/BCF file reader using pysam, producing VariantAnnotation objects.

VcfReader

Reads VCF/BCF files (.vcf, .vcf.gz, .bcf) using pysam.

When sample_id is provided, only ALT alleles called in that sample are returned and zygosity is inferred from the GT field. When omitted, all ALT alleles in every record are returned without genotype filtering.

With store_info=True, all INFO fields are serialized into Metadata.data. QUAL and non-PASS FILTER values are always stored when present.

read

read(path)

Read a VCF/BCF file and return a list of VariantAnnotation objects.

Source code in src/iris/adapters/driven/readers/vcf.py
def read(self, path: str | Path) -> list[VariantAnnotation]:
    """Read a VCF/BCF file and return a list of VariantAnnotation objects."""
    pysam = require_pysam()
    results: list[VariantAnnotation] = []
    with pysam.VariantFile(str(path)) as vcf:
        asm = self.assembly or _detect_assembly(vcf)
        sample_idx = self._resolve_sample(vcf)
        for record in vcf:
            results.extend(self._process_record(record, asm, sample_idx))
    return results

VCF Cohort

iris.adapters.driven.readers.vcf_cohort

Multi-sample VCF/BCF cohort reader for population-level statistics.

VcfCohortReader

Reads a multi-sample VCF/BCF for a defined sample subset, computing population-level statistics per variant and genomic region.

Two levels of API are provided:

iter_region_counts Low-level: yields list[tuple[Variant, GenotypeCount]] per region. Each entry is one ALT allele. Use this when you need raw counts for downstream analyses (HWE, missingness, stratified AF, etc.).

iter_regions High-level convenience: yields list[VariantAnnotation] per region, with a single AlleleFrequency per variant populated from the counts.

The BCF file is opened once per iter_region_counts / iter_regions call regardless of the number of regions, and sample indices are resolved once from the header.

ATTRIBUTE DESCRIPTION
sample_ids

Frozenset of sample identifiers to include in the cohort. All IDs must be present in the VCF header.

TYPE: frozenset[str]

population

Label written to AlleleFrequency.population (default "cohort").

TYPE: str

min_an

Minimum allele number required to emit a variant. Variants where all cohort samples are missing are dropped (default 1).

TYPE: int

assembly

Assembly name written to GenomicPosition.assembly. Auto-detected from the VCF header when None.

TYPE: str | None

Example
from iris.domain.genomics.utils import chrom_blocks
from iris.adapters.driven.readers.vcf_cohort import VcfCohortReader

reader = VcfCohortReader(sample_ids=frozenset(my_79k_ids), population="cohort_79k")
blocks = chrom_blocks("1", 248_956_422, block_size=10_000_000)

for block_vas in reader.iter_regions("cohort.bcf", blocks):
    exporter.export(block_vas, fh)

iter_region_counts

iter_region_counts(path, regions)

Yield raw genotype counts per region block.

Each yielded list contains one (Variant, GenotypeCount) tuple per ALT allele that passes min_an. Multiallelic records produce one tuple per ALT. Blocks with no passing variants yield an empty list.

PARAMETER DESCRIPTION
path

Path to a bgzipped, tabix/CSI-indexed VCF or BCF file.

TYPE: str | Path

regions

Genomic regions defining the blocks. Regions spanning multiple chromosomes are handled efficiently (one file open, grouped internally by chromosome).

TYPE: Iterable[GenomicPosition]

YIELDS DESCRIPTION
list[tuple[Variant, GenotypeCount]]

One list of (Variant, GenotypeCount) per input region.

Source code in src/iris/adapters/driven/readers/vcf_cohort.py
def iter_region_counts(
    self, path: str | Path, regions: Iterable[GenomicPosition]
) -> Iterator[list[tuple[Variant, GenotypeCount]]]:
    """Yield raw genotype counts per region block.

    Each yielded list contains one ``(Variant, GenotypeCount)`` tuple per
    ALT allele that passes ``min_an``.  Multiallelic records produce one
    tuple per ALT.  Blocks with no passing variants yield an empty list.

    Args:
        path: Path to a bgzipped, tabix/CSI-indexed VCF or BCF file.
        regions: Genomic regions defining the blocks.  Regions spanning
            multiple chromosomes are handled efficiently (one file open,
            grouped internally by chromosome).

    Yields:
        One list of ``(Variant, GenotypeCount)`` per input region.
    """
    pysam = require_pysam()
    region_list = list(regions)
    with pysam.VariantFile(str(path)) as vcf:
        asm = self.assembly or _detect_assembly(vcf)
        contigs: frozenset[str] = frozenset(vcf.header.contigs)
        sample_indices = self._resolve_samples(vcf)
        by_chrom = group_by_chromosome(region_list)
        for chrom in sorted(by_chrom, key=chromosome_sort_key):
            for region in by_chrom[chrom]:
                yield self._count_region(vcf, region, sample_indices, asm, contigs)

iter_regions

iter_regions(path, regions)

Yield VariantAnnotation objects with AlleleFrequency per region block.

Convenience wrapper around iter_region_counts. Each variant carries one AlleleFrequency entry for self.population.

PARAMETER DESCRIPTION
path

Path to a bgzipped, tabix/CSI-indexed VCF or BCF file.

TYPE: str | Path

regions

Genomic regions defining the blocks.

TYPE: Iterable[GenomicPosition]

YIELDS DESCRIPTION
list[VariantAnnotation]

One list of VariantAnnotation per input region.

Source code in src/iris/adapters/driven/readers/vcf_cohort.py
def iter_regions(
    self, path: str | Path, regions: Iterable[GenomicPosition]
) -> Iterator[list[VariantAnnotation]]:
    """Yield VariantAnnotation objects with AlleleFrequency per region block.

    Convenience wrapper around ``iter_region_counts``.  Each variant
    carries one ``AlleleFrequency`` entry for ``self.population``.

    Args:
        path: Path to a bgzipped, tabix/CSI-indexed VCF or BCF file.
        regions: Genomic regions defining the blocks.

    Yields:
        One list of ``VariantAnnotation`` per input region.
    """
    for block in self.iter_region_counts(path, regions):
        yield [
            VariantAnnotation(
                variant=variant,
                allele_frequencies=(count.to_allele_frequency(self.population),),
            )
            for variant, count in block
        ]

BED

iris.adapters.driven.readers.bed

BED file reader producing GenomicPosition objects.

BedRegionReader

Bases: RegionReader

Reads BED files (BED3–BED6) and produces GenomicPosition objects.

BED coordinates are 0-based half-open [start, end); they are converted to 1-based inclusive as stored by GenomicPosition.

read

read(handle, *, assembly=None)

Read BED records from handle and return them as GenomicPosition objects.

Source code in src/iris/adapters/driven/readers/bed.py
def read(self, handle: TextIO, *, assembly: str | None = None) -> list[GenomicPosition]:
    """Read BED records from handle and return them as GenomicPosition objects."""
    regions = []
    for lineno, raw in enumerate(handle, start=1):
        line = raw.strip()
        if not line or line.startswith(("#", "track", "browser")):
            continue
        cols = line.split("\t")
        if len(cols) < 3:
            logger.warning("BED line %d skipped: fewer than 3 columns — %r", lineno, line)
            continue
        try:
            region = self._parse_row(cols, assembly)
        except (ValueError, IndexError) as exc:
            logger.warning("BED line %d skipped: %s", lineno, exc)
            continue
        regions.append(region)
    return regions

GTF

iris.adapters.driven.readers.gtf

Low-level streaming parser for GTF and GFF3 annotation files.

GtfRecord

A single parsed record from a GTF or GFF3 file.

Coordinates are 1-based inclusive (GENCODE/GTF native). Multi-value GTF attributes (e.g. tag) are stored as frozenset[str].

get

get(key, default=None)

Return the value of an attribute, or default if absent.

Source code in src/iris/adapters/driven/readers/gtf.py
def get(self, key: str, default: Any = None) -> Any:
    """Return the value of an attribute, or *default* if absent."""
    return self.attributes.get(key, default)

get_tags

get_tags()

Return the tag set for this record (empty frozenset if no tags).

Source code in src/iris/adapters/driven/readers/gtf.py
def get_tags(self) -> frozenset[str]:
    """Return the tag set for this record (empty frozenset if no tags)."""
    raw = self.attributes.get("tag")
    if raw is None:
        return frozenset()
    if isinstance(raw, frozenset):
        return raw
    return frozenset({str(raw)})

GtfReader

Streaming parser for GTF and GFF3 files (plain text or gzip-compressed).

Both GENCODE GTF and GFF3 formats are supported. Format detection is based on file extension (.gff / .gff3 → GFF3; everything else → GTF). Compressed files must end in .gz.

Usage::

reader = GtfReader()
for record in reader.read("gencode.v46.annotation.gtf.gz"):
    if record.feature == "gene":
        print(record.get("gene_name"))

read

read(path)

Yield one :class:GtfRecord per data line, skipping comment lines.

Source code in src/iris/adapters/driven/readers/gtf.py
def read(self, path: Path | str) -> Iterator[GtfRecord]:
    """Yield one :class:`GtfRecord` per data line, skipping comment lines."""
    path = Path(path)
    fmt = self._detect_format(path)
    opener = gzip.open if path.name.endswith(".gz") else open
    with opener(path, "rt", encoding="utf-8") as fh:
        for line in fh:
            line = line.rstrip("\n")
            if not line or line.startswith("#"):
                continue
            yield self._parse_line(line, fmt)

Tabix

iris.adapters.driven.readers.tabix

Tabix query primitives: point lookups and region scans over bgzipped indexed files.

TabixReader

Base class for reading bgzipped, tabix-indexed files via pysam.

Provides point-lookup and region-scan methods with automatic chromosome prefix resolution and duplicate-line suppression.

The workers parameter (available on all query methods) enables chromosome-partitioned parallel fetching via ThreadPoolExecutor. Each worker opens its own TabixFile; handles are never shared across threads. workers=1 (the default) preserves the original sequential, lazy-iterator behaviour.

Zarr

iris.adapters.driven.readers.zarr

Zarr store reader for vcf2zarr-formatted variant stores.

ZarrVariantReader

Reads a vcf2zarr Zarr store and returns VariantAnnotation objects.

When sample_id is provided, only ALT alleles called in that sample are returned and zygosity is inferred from call_genotype. When omitted, all ALT alleles in every record are returned without genotype filtering.

ClinVar annotations (variant_CLNSIG, variant_CLNREVSTAT) and allele frequencies (variant_AF, variant_AC, variant_AN) are parsed automatically when present. With store_info=True, all remaining variant_* INFO fields are serialized into Metadata.data.

variant_position is assumed 0-based (the vcf2zarr default). Set zero_based=False for stores built with 1-based positions.

read

read(path)

Read a vcf2zarr Zarr store and return VariantAnnotation objects.

Source code in src/iris/adapters/driven/readers/zarr.py
def read(self, path: str | Path) -> list[VariantAnnotation]:
    """Read a vcf2zarr Zarr store and return VariantAnnotation objects."""
    try:
        import zarr  # pyright: ignore[reportMissingImports]
    except ImportError as exc:
        raise ImportError("zarr is required: uv pip install zarr") from exc

    store = zarr.open(str(path), mode="r")
    return self._read_store(store)

GENCODE

iris.adapters.driven.readers.providers.gencode

GENCODE gene annotation reader backed by a GTF or GFF3 file.

Implements :class:~iris.domain.genomics.repositories.GeneRepository so the adapter is drop-in compatible with any port that depends on that interface.

Typical usage::

from pathlib import Path
from iris.adapters.driven.readers.providers.gencode import GencodeGeneRepository

repo = GencodeGeneRepository(
    "gencode.v46.annotation.gtf.gz",
    assembly="GRCh38",
    version="v46",
    tags={"MANE_Select"},  # only MANE Select transcripts
    min_transcript_support_level=2,  # TSL 1 or 2
)

# Single gene (by Ensembl ID or gene name)
ann = repo.get_annotation("BRCA1")

# Subset of genes
anns = repo.find_annotations(["BRCA1", "BRCA2", "TP53"])

# All genes
all_anns = repo.find_all_annotations()

GencodeGeneRepository

GencodeGeneRepository(
    path,
    *,
    assembly=None,
    version=None,
    annotation_sources=None,
    tags=None,
    min_transcript_support_level=None,
)

Bases: GeneRepository

Read-only gene repository backed by a GENCODE GTF or GFF3 annotation file.

All data is loaded lazily on the first access and cached for the lifetime of the instance. Filters are applied during the initial scan so the in-memory cache only contains transcripts that pass them.

PARAMETER DESCRIPTION
path

Path to the annotation file (.gtf, .gff3, or .gz variants thereof).

TYPE: Path | str

assembly

Genome assembly identifier stored in every :class:~iris.domain.genomics.objects.GenomicPosition (e.g. "GRCh38").

TYPE: str | None DEFAULT: None

version

GENCODE release label stored in :class:~iris.domain.generic.abstracts.Metadata (e.g. "v46").

TYPE: str | None DEFAULT: None

annotation_sources

If provided, only include transcripts whose GTF source column (second column) is in this set. Typical values are "HAVANA" (manual) and "ENSEMBL" (automatic).

TYPE: Collection[str] | None DEFAULT: None

tags

If provided, only include transcripts that carry at least one of these tags (e.g. {"MANE_Select", "basic"}).

TYPE: Collection[str] | None DEFAULT: None

min_transcript_support_level

If provided, only include transcripts whose transcript_support_level is ≤ this integer (1 = highest confidence, 5 = lowest). Transcripts with no TSL annotation are excluded when this filter is active.

TYPE: int | None DEFAULT: None

Source code in src/iris/adapters/driven/readers/providers/gencode.py
def __init__(
    self,
    path: Path | str,
    *,
    assembly: str | None = None,
    version: str | None = None,
    annotation_sources: Collection[str] | None = None,
    tags: Collection[str] | None = None,
    min_transcript_support_level: int | None = None,
) -> None:
    self._path = Path(path)
    self._assembly = assembly
    self._version = version
    self._annotation_sources: frozenset[str] | None = (
        frozenset(annotation_sources) if annotation_sources is not None else None
    )
    self._tags: frozenset[str] | None = frozenset(tags) if tags is not None else None
    self._min_tsl = min_transcript_support_level
    self._reader = GtfReader()

    # Populated lazily by _ensure_loaded()
    self._index: dict[str, GeneAnnotation] | None = None
    self._name_to_id: dict[str, str] = {}  # gene_name → versioned gene_id
    self._alias_to_id: dict[str, str] = {}  # unversioned gene_id → versioned gene_id

from_registry classmethod

from_registry(
    source="gencode",
    registry=None,
    *,
    assembly=None,
    version=None,
    tags=None,
    min_transcript_support_level=None,
)

Build a GencodeGeneRepository resolving source from the iris DataRegistry.

Source code in src/iris/adapters/driven/readers/providers/gencode.py
@classmethod
def from_registry(
    cls,
    source: str = "gencode",
    registry=None,
    *,
    assembly: str | None = None,
    version: str | None = None,
    tags: Collection[str] | None = None,
    min_transcript_support_level: int | None = None,
) -> "GencodeGeneRepository":
    """Build a GencodeGeneRepository resolving *source* from the iris DataRegistry."""
    from iris.config import get_registry

    r = registry or get_registry()
    return cls(
        r.resolve_path(source),
        assembly=assembly or r.config.assembly,
        version=version,
        tags=tags,
        min_transcript_support_level=min_transcript_support_level,
    )

get

get(identifier)

Return the :class:~iris.domain.genomics.entities.Gene for identifier.

identifier may be a versioned Ensembl ID (ENSG00000012048.23), an unversioned Ensembl ID (ENSG00000012048), or a gene symbol (BRCA1).

Source code in src/iris/adapters/driven/readers/providers/gencode.py
def get(self, identifier: str) -> Gene | None:
    """Return the :class:`~iris.domain.genomics.entities.Gene` for *identifier*.

    *identifier* may be a versioned Ensembl ID (``ENSG00000012048.23``),
    an unversioned Ensembl ID (``ENSG00000012048``), or a gene symbol
    (``BRCA1``).
    """
    ann = self.get_annotation(identifier)
    return ann.gene if ann is not None else None

get_annotation

get_annotation(identifier)

Return a fully annotated gene for identifier, or None.

Source code in src/iris/adapters/driven/readers/providers/gencode.py
def get_annotation(self, identifier: str) -> GeneAnnotation | None:
    """Return a fully annotated gene for *identifier*, or ``None``."""
    self._ensure_loaded()
    assert self._index is not None
    if identifier in self._index:
        return self._index[identifier]
    resolved = self._alias_to_id.get(identifier) or self._name_to_id.get(identifier)
    return self._index.get(resolved) if resolved else None

find_by_position

find_by_position(position)

Return all genes whose genomic span overlaps position.

Source code in src/iris/adapters/driven/readers/providers/gencode.py
def find_by_position(self, position: GenomicPosition) -> Sequence[Gene]:
    """Return all genes whose genomic span overlaps *position*."""
    self._ensure_loaded()
    assert self._index is not None
    return [
        ann.gene
        for ann in self._index.values()
        if ann.gene.position is not None and position.overlaps(ann.gene.position)
    ]

find_by_phenotype

find_by_phenotype(phenotype)

Not supported by GTF annotation files; always returns an empty list.

Source code in src/iris/adapters/driven/readers/providers/gencode.py
def find_by_phenotype(self, phenotype: Phenotype) -> Sequence[Gene]:
    """Not supported by GTF annotation files; always returns an empty list."""
    return []

find_all_annotations

find_all_annotations()

Return all :class:~iris.domain.genomics.entities.GeneAnnotation objects.

Source code in src/iris/adapters/driven/readers/providers/gencode.py
def find_all_annotations(self) -> list[GeneAnnotation]:
    """Return all :class:`~iris.domain.genomics.entities.GeneAnnotation` objects."""
    self._ensure_loaded()
    assert self._index is not None
    return list(self._index.values())

find_annotations

find_annotations(identifiers)

Return annotations for the given gene identifiers.

Identifiers may be versioned/unversioned Ensembl IDs or gene symbols. Unknown identifiers are silently skipped.

Source code in src/iris/adapters/driven/readers/providers/gencode.py
def find_annotations(self, identifiers: Collection[str]) -> list[GeneAnnotation]:
    """Return annotations for the given gene identifiers.

    Identifiers may be versioned/unversioned Ensembl IDs or gene symbols.
    Unknown identifiers are silently skipped.
    """
    return [a for i in identifiers if (a := self.get_annotation(i)) is not None]

Nirvana

iris.adapters.driven.readers.providers.nirvana

Nirvana JSON annotation file reader producing VariantAnnotation objects.

NirvanaReader

Reads a Nirvana JSON annotation file and yields VariantAnnotation objects for one sample.

read

read(*, assembly, source)

Parse the Nirvana JSON file and return VariantAnnotation objects.

Source code in src/iris/adapters/driven/readers/providers/nirvana.py
def read(self, *, assembly: str, source: str) -> list[VariantAnnotation]:
    """Parse the Nirvana JSON file and return VariantAnnotation objects."""
    header, positions = self._parse_sections()

    try:
        sample_index = header["samples"].index(self.sample_id)
    except ValueError:
        raise ValueError(
            f"Sample {self.sample_id!r} not found in file. "
            f"Available samples: {header['samples']}"
        )

    results: list[VariantAnnotation] = []
    for raw in positions:
        p = json.loads(raw)
        sample = p["samples"][sample_index]
        gt = sample.get("genotype", "")

        if not _is_callable_genotype(gt):
            continue
        if sample.get("genotypeQuality", 0) < 0:
            continue

        for v in p.get("variants", []):
            results.append(_build_variant_annotation(v, p, assembly, source, gt))

    for annotator in self.annotators:
        results = annotator.annotate(results)

    return results

Emedgene

iris.adapters.driven.readers.providers.emedgene

Emedgene (Illumina) Excel report reader producing VariantAnnotation objects.

Reads the main variant sheet (Sheet1 / first sheet) and the optional 'Applied Filters' sheet, capturing all 59 columns exported by Emedgene.

EmedgeneReader

Reads an Emedgene (Illumina) Excel report and yields VariantAnnotation objects.

Reads the main variant sheet and the 'Applied Filters' sheet (when present). Applied filter metadata is attached to every row so callers can inspect what filters were active when the report was exported.

Requires openpyxl (uv pip install openpyxl).

read

read(path)

Read an Emedgene Excel report and return VariantAnnotation objects.

Source code in src/iris/adapters/driven/readers/providers/emedgene.py
def read(self, path: str | Path) -> list[VariantAnnotation]:
    """Read an Emedgene Excel report and return VariantAnnotation objects."""
    try:
        import openpyxl
    except ImportError as exc:
        raise ImportError(
            "openpyxl is required to read Emedgene files: uv pip install openpyxl"
        ) from exc

    wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)

    # Resolve main sheet — fall back to first sheet if Sheet1 is absent
    main_sheet = self.sheet_name if self.sheet_name in wb.sheetnames else wb.sheetnames[0]
    if main_sheet != self.sheet_name:
        logger.warning(
            "Sheet %r not found in %s; using %r instead.",
            self.sheet_name,
            Path(path).name,
            main_sheet,
        )

    ws = wb[main_sheet]
    rows = list(ws.iter_rows(values_only=True))
    if not rows:
        return []

    # Applied Filters sheet — shared metadata for all rows in this report
    applied_filters = _read_applied_filters(wb)

    headers = [str(h) if h is not None else "" for h in rows[0]]
    results: list[VariantAnnotation] = []
    for lineno, row_values in enumerate(rows[1:], start=2):
        row: dict[str, Any] = dict(zip(headers, row_values))
        try:
            results.append(_build_variant_annotation(row, applied_filters=applied_filters))
        except Exception as exc:
            logger.warning("Emedgene row %d skipped: %s", lineno, exc)

    return results