Source code for sfsutils.io_handlers

"""
Handles the reading of VCF, VCF-Zarr, tree-sequence, GFF and FASTA files.
"""

__author__ = "Janek Sendrowski"
__contact__ = "sendrowski.janek@gmail.com"
__date__ = "2023-05-29"

import atexit
import gzip
import hashlib
import io
import logging
import os
import shutil
import tempfile
import warnings
from abc import ABC, abstractmethod
from collections import Counter
from functools import cached_property
from typing import Any, List, Iterable, Iterator, TextIO, Dict, Optional, Set, Tuple, Union, Sequence, \
    Protocol, runtime_checkable
from urllib.parse import urlparse

import numpy as np
import pandas as pd
import requests
from Bio import SeqIO
from Bio.SeqIO.FastaIO import FastaIterator
from Bio.SeqRecord import SeqRecord
from pandas.errors import SettingWithCopyWarning
from tqdm import tqdm

from .settings import Settings

#: The DNA bases
bases = ["A", "C", "G", "T"]

# logger
logger = logging.getLogger('sfsutils')


[docs] @runtime_checkable class Site(Protocol): """ Structural interface for a single streamed variant site: the abstraction layer over the input backends. Both :class:`cyvcf2.Variant` (the VCF backend) and the concrete :class:`~sfsutils.io_handlers.Variant` emitted by the tree-sequence and VCF-Zarr backends satisfy it structurally, so the parser, filtrations, annotations and stratifications are typed against :class:`~sfsutils.io_handlers.Site` alone rather than a union of the concrete backend types. """ #: The contig. CHROM: str #: The 1-based position. POS: int #: The reference allele. REF: str #: The alternate alleles. ALT: List[str] #: The INFO field. INFO: Dict[str, object] #: The per-sample genotype strings (e.g. ``"A/T"``), as for ``cyvcf2.Variant.gt_bases``. gt_bases: 'np.ndarray' #: The per-haplotype allele indices into ``[REF] + ALT``, of shape ``(n_samples, ploidy)`` and integer #: dtype, with ``-1`` for a missing call. Optional: it is ``None`` (or absent) on backends that carry #: the genotypes as strings only, in which case consumers read :attr:`gt_bases` instead. allele_indices: Optional['np.ndarray'] #: Whether the site is an SNP. is_snp: bool #: Whether the site is an MNP. is_mnp: bool #: Whether the site is an indel. is_indel: bool #: Whether the site is a deletion. is_deletion: bool #: Whether the site is a structural variant. is_sv: bool
def get_called_bases(genotypes: Sequence[str]) -> np.ndarray: """ Get the called bases from a list of calls. :param genotypes: Array of genotypes in the form of strings. :return: Array of called bases. """ # join genotypes joined_genotypes = ''.join(genotypes).replace('|', '/') # convert to numpy array of characters char_array = np.array(list(joined_genotypes)) # return only characters that are in the bases list return char_array[np.isin(char_array, bases)] #: The bases as a set, for fast membership tests in the per-site filtration hot path _base_set = frozenset(bases) def get_distinct_called_bases(genotypes: Sequence[str]) -> set: """ Get the set of distinct called bases. Equivalent to ``set(get_called_bases(genotypes))`` but avoids building the intermediate arrays, which matters because the filtrations run this on every site. :param genotypes: Array of genotypes in the form of strings. :return: The distinct called bases. """ return set(''.join(genotypes)) & _base_set def get_distinct_called_alleles(genotypes: Sequence[str]) -> set: """ Get the set of distinct called alleles, keeping multi-character alleles intact. :param genotypes: Array of genotypes in the form of strings. :return: The distinct called alleles. """ return { allele for genotype in genotypes for allele in genotype.replace('|', '/').split('/') if allele != '' and set(allele) <= _base_set } def get_called_alleles(genotypes: Sequence[str]) -> np.ndarray: """ Get the distinct called alleles from a list of calls. Multi-character alleles stay intact, so an MNP contributes one allele per haplotype rather than one per base. :param genotypes: Array of genotypes in the form of strings. :return: Array of distinct called alleles. """ return np.array(sorted(get_distinct_called_alleles(genotypes)), dtype=object) class SiteAlleles: """ Numeric view of a site's genotypes: the per-haplotype allele indices paired with the site's allele strings. The backends hold the calls as indices already, so reading them here spares both the join into genotype strings and the split back out of them, which dominates the per-site cost of the filtrations and of the down-projection. An allele is called only where its string consists of DNA bases alone, matching :func:`get_called_bases` and :func:`get_distinct_called_alleles`: ``N``, ``*``, ``<NON_REF>`` and a missing call contribute nothing. Counting haplotypes and counting genotype characters coincide only where every allele of the site is a single character, which :attr:`single_character` reports. """ def __init__(self, indices: np.ndarray, alleles: Sequence[str]): """ Initialize the view. :param indices: The per-haplotype allele indices, of shape ``(n_samples, ploidy)``, with ``-1`` for a missing call. :param alleles: The site's allele strings, the reference first. """ #: The site's allele strings, the reference first self.alleles: List[str] = list(alleles) #: The per-haplotype allele indices self.indices: np.ndarray = indices #: Whether every allele of the site is a single character self.single_character: bool = all(len(a) == 1 for a in self.alleles) #: Whether the allele at each index is called, with a trailing slot absorbing the missing and #: out-of-range calls so that validity is a single lookup self._called: np.ndarray = np.zeros(len(self.alleles) + 1, dtype=bool) for i, allele in enumerate(self.alleles): self._called[i] = allele != '' and set(allele) <= _base_set #: The site the last view was built for, alongside that view. Every filtration and the parser itself #: ask for the view of the site they are handed, and the site is handed on to the next of them #: unchanged, so it is built once and shared. The site is held rather than its identity alone, since #: the sites are transient and the address of one that has been released is handed out again. _cached: Tuple[Optional['Site'], Optional['SiteAlleles']] = (None, None) @classmethod def from_site(cls, variant: 'Site') -> Optional['SiteAlleles']: """ The view for a site, where its backend provides the numeric calls, reusing the view of the site this was last called for. :param variant: The site. :return: The view, or ``None`` where the backend carries the genotypes as strings only. """ # read the pair in one go: it is replaced as a whole, so the site and the view cannot be observed # out of step with one another site, view = cls._cached if site is variant: return view view = cls._build(variant) cls._cached = (variant, view) return view @classmethod def _build(cls, variant: 'Site') -> Optional['SiteAlleles']: """ Build the view for a site. :param variant: The site. :return: The view, or ``None`` where the backend carries the genotypes as strings only. """ indices = getattr(variant, 'allele_indices', None) if indices is None: # cyvcf2 holds the calls on its Genotypes object, whose last column carries the phase flag genotype = getattr(variant, 'genotype', None) if genotype is None: return None indices = np.asarray(genotype.array()) if indices.ndim != 2 or indices.shape[1] < 2: return None indices = indices[:, :-1] return cls(indices, [variant.REF or ''] + list(variant.ALT)) #: Stands in for a mask the view has not been asked about, which ``None`` cannot: ``None`` is itself #: the mask selecting every sample _unbinned: Any = object() #: The mask the counts in :attr:`_binned_bins` were computed for. Every filtration and the parser #: itself ask the same view for the counts under the same mask object, which is built once for the #: whole pass, so the index array is walked once per site rather than once per consumer. Held on the #: class so that a view asked for one set of counts carries nothing of its own. The mask itself is #: held rather than its identity alone, so that a mask released since and another built at its #: address cannot be taken for it. A joint parse, whose populations carry a mask each, asks for each #: of them once per site, so a single slot covers every access pattern the parser has _binned_mask: Any = _unbinned #: The counts for :attr:`_binned_mask` _binned_bins: Optional[np.ndarray] = None def _bins(self, mask: Optional[np.ndarray]) -> np.ndarray: """ The number of haplotypes of the selected samples carrying each allele index, reusing the counts already computed for the same mask. :param mask: The boolean samples mask, or ``None`` for every sample. :return: The per-index counts, allele ``i`` in bin ``i + 2``. """ if self._binned_mask is mask: return self._binned_bins bins = self._count(mask) self._binned_bins = bins self._binned_mask = mask return bins def _count(self, mask: Optional[np.ndarray]) -> np.ndarray: """ The number of haplotypes of the selected samples carrying each allele index, shifted by the two negative sentinels so that a missing call (``-1``) and the fill of a shorter genotype (``-2``) land in the two leading bins and an out-of-range call past the alleles. Binning the raw indices spares the clamp and the intermediate arrays it needs, which matters because the parser runs this on every site. :param mask: The boolean samples mask, or ``None`` for every sample. :return: The per-index counts, allele ``i`` in bin ``i + 2``. """ indices = self.indices if mask is None else self.indices[mask] # the calls come in the narrowest dtype that holds the site's alleles, in which the shift of the # sentinels would wrap the highest allele index round to a negative code; bincount widens to # intp regardless, so widening here first costs nothing codes = np.asarray(indices, dtype=np.intp).ravel() + 2 # a sentinel beyond the two the VCF-Zarr spec defines would index before the leading bin if codes.size and codes.min() < 0: codes = codes[codes >= 0] return np.bincount(codes, minlength=len(self.alleles) + 3) def n_called(self, mask: Optional[np.ndarray] = None) -> int: """ The number of called haplotypes among the selected samples. :param mask: The boolean samples mask, or ``None`` for every sample. :return: The number of called haplotypes. """ n = len(self.alleles) bins = self._bins(mask) return int(bins[2:n + 2] @ self._called[:n]) def counts(self, mask: Optional[np.ndarray] = None) -> Dict[str, int]: """ The number of called haplotypes carrying each allele among the selected samples. Alleles that no selected haplotype carries are absent from the mapping, and two indices sharing an allele string are summed into one entry. :param mask: The boolean samples mask, or ``None`` for every sample. :return: The per-allele haplotype counts. """ bins = self._bins(mask) observed: Dict[str, int] = {} for i, allele in enumerate(self.alleles): count = int(bins[i + 2]) if self._called[i] else 0 if count: observed[allele] = observed.get(allele, 0) + count return observed def distinct(self, mask: Optional[np.ndarray] = None) -> Set[str]: """ The distinct alleles called among the selected samples. :param mask: The boolean samples mask, or ``None`` for every sample. :return: The distinct called alleles. """ bins = self._bins(mask) return {allele for i, allele in enumerate(self.alleles) if bins[i + 2] and self._called[i]} def get_major_base(genotypes: Sequence[str]) -> str | None: """ Get the major base from a list of calls. :param genotypes: Array of genotypes in the form of strings. :return: Major base. """ # get the called bases bases = get_called_bases(genotypes) if len(bases) > 0: return Counter(bases).most_common()[0][0] def _is_snp(ref: str, alt: Sequence[str]) -> bool: """ Whether a site is a single-nucleotide polymorphism, under the rule htslib applies and cyvcf2 surfaces through ``is_snp``: a single-character reference, which may be ``N`` or another IUPAC code, together with at least one alternate allele, each of them a single base. The spanning deletion ``*``, the missing allele ``.`` and a symbolic ``<...>`` allele are thereby excluded, and the non-VCF backends classify a site exactly as the same records read through cyvcf2 do. :param ref: The reference allele. :param alt: The alternate alleles. :return: Whether the site is a single-nucleotide polymorphism. """ return len(ref) == 1 and len(alt) > 0 and all(a in _base_set for a in alt) def is_monomorphic_snp(variant: Site) -> bool: """ Whether the given variant is a monomorphic SNP. :param variant: The site :return: Whether the site is a monomorphic SNP """ return (not (variant.is_snp or variant.is_mnp or variant.is_indel or variant.is_deletion or variant.is_sv) and not variant.ALT and variant.REF in bases) def count_indexed_sites(vcf: str) -> int | None: """ Read the number of records of a VCF from its tabix or CSI index, which htslib stores alongside the per-contig offsets, so the file itself does not have to be decompressed a second time. :param vcf: The path to the input :return: The number of records, or ``None`` where the file carries no readable index """ # only look for an index next to a local file: a URL or a directory (a VCF-Zarr store) has none, and # probing for one would cost a request if not any(os.path.exists(vcf + extension) for extension in ('.tbi', '.csi')): return None try: from cyvcf2 import VCF except ImportError: return None try: reader = VCF(vcf) try: n = int(reader.num_records) finally: reader.close() except Exception as e: logger.debug(f"Could not read the record count of '{vcf}' from its index: {e}") return None # an index written without the record-count metadata reports zero, which is indistinguishable from an # empty file, so the caller falls back to counting return n if n > 0 else None def count_sites( vcf: str | Iterable['cyvcf2.Variant'], max_sites: int = np.inf, desc: str = 'Counting sites' ) -> int: """ Count the number of sites in the input. Where the input is an indexed VCF, the count comes from the index rather than from a pass over the records. :param vcf: The path to the input or an iterable of variants :param max_sites: Maximum number of sites to consider :param desc: Description for the progress bar :return: Number of sites """ # if we don't have a file path, we can just count the number of variants if not isinstance(vcf, str): return len(list(vcf)) indexed = count_indexed_sites(vcf) if indexed is not None: return int(min(indexed, max_sites)) i = 0 with open_file(vcf) as f: with tqdm(disable=Settings.disable_pbar, desc=desc) as pbar: for line in f: if not line.startswith('#'): i += 1 pbar.update() # stop counting if max_sites was reached if i >= max_sites: break return i def download_if_url(path: str, cache: bool = True, desc: str = 'Downloading file') -> str: """ Download the file if it is a URL. :param path: The path to the file. :param cache: Whether to cache the file. :param desc: Description for the progress bar :return: The path to the downloaded file or the original path. """ if FileHandler.is_url(path): # download the file and return path return FileHandler.download_file(path, cache=cache, desc=desc) return path def open_file(file: str) -> TextIO: """ Open a file, either gzipped or not. :param file: File to open :return: stream """ if file.endswith('.gz'): return gzip.open(file, "rt") return open(file, 'r')
[docs] class FileHandler: """ Base class for the handlers that read a local or remote file. Provides the shared plumbing: resolving a URL or a gzipped path to a readable local file, optional caching of downloads, and per-contig name aliasing. """ #: The logger instance _logger = logger.getChild(__qualname__) #: Cache of gzipped file path -> its decompressed temporary copy, so a rewind reuses the copy _unzipped: Dict[str, str] = {}
[docs] def __init__(self, cache: bool = True, aliases: Dict[str, List[str]] = {}): """ Create a new FileHandler instance. :param cache: Whether to cache files that are downloaded from URLs :param aliases: The contig aliases. """ #: Whether to cache files that are downloaded from URLs self.cache: bool = cache #: The contig mappings self._alias_mappings, self.aliases = self._expand_aliases(aliases)
@staticmethod def _expand_aliases(alias_dict: Dict[str, List[str]]) -> Tuple[Dict[str, str], Dict[str, List[str]]]: """ Expand the contig aliases. """ # map alias to primary alias mappings = {} # map primary alias to all aliases aliases = {} for contig, alias_list in alias_dict.items(): all_aliases = alias_list + [contig] aliases[contig] = all_aliases for alias in all_aliases: mappings[alias] = contig return mappings, aliases
[docs] @staticmethod def is_url(path: str) -> bool: """ Check if the given path is a URL. :param path: The path to check. :return: ``True`` if the path is a URL, ``False`` otherwise. """ try: result = urlparse(path) return all([result.scheme, result.netloc]) except ValueError: return False
[docs] def download_if_url(self, path: str) -> str: """ Download the file if it is a URL. :param path: The path to the file. :return: The path to the downloaded file or the original path. """ return download_if_url(path, cache=self.cache, desc=f'{self.__class__.__name__}>Downloading file')
[docs] @staticmethod def unzip_if_zipped(file: str): """ If the given file is gzipped, unzip it and return the path to the unzipped file. If the file is not gzipped, return the path to the original file. :param file: The path to the file. :return: The path to the unzipped file, or the original file if it was not gzipped. """ # check if the file extension is .gz if file.endswith('.gz'): # reuse a previous decompression of the same file: a rewind would otherwise decompress the # whole reference again and leak another full-size temporary copy cached = FileHandler._unzipped.get(file) if cached is not None and os.path.exists(cached): return cached suffix = os.path.splitext(file[:-3])[1] or '.tmp' fd, unzipped = tempfile.mkstemp(suffix=suffix) logger.info(f'Unzipping {file} to {unzipped}') with gzip.open(file, 'rb') as f_in: with os.fdopen(fd, 'wb') as f_out: shutil.copyfileobj(f_in, f_out) FileHandler._unzipped[file] = unzipped atexit.register(lambda p=unzipped: os.path.exists(p) and os.remove(p)) return unzipped return file
[docs] @staticmethod def get_filename(url: str): """ Return the file extension of a URL. :param url: The URL to get the file extension from. :return: The file extension. """ return os.path.basename(urlparse(url).path)
[docs] @staticmethod def hash(s: str) -> str: """ Return a truncated SHA1 hash of a string. :param s: The string to hash. :return: The SHA1 hash. """ return hashlib.sha1(s.encode()).hexdigest()[:12]
[docs] @classmethod def download_file(cls, url: str, cache: bool = True, desc: str = 'Downloading file') -> str: """ Download a file from a URL. :param cache: Whether to cache the file. :param url: The URL to download the file from. :param desc: Description for the progress bar :return: The path to the downloaded file. """ # extract the file extension from the URL filename = FileHandler.get_filename(url) # create a temporary file path path = tempfile.gettempdir() + '/' + FileHandler.hash(url) + '.' + filename # check if the file is already cached if cache and os.path.exists(path): cls._logger.info(f'Using cached file at {path}') return path cls._logger.info(f'Downloading file from {url}') # start the stream response = requests.get(url, stream=True) # check if the request was successful response.raise_for_status() # create a temporary file with the original file extension with tempfile.NamedTemporaryFile(delete=False) as tmp: total_size = int(response.headers.get('content-length', 0)) chunk_size = 8192 with tqdm(total=total_size, unit='B', unit_scale=True, desc=desc, disable=Settings.disable_pbar) as pbar: # write the file to disk for chunk in response.iter_content(chunk_size=chunk_size): if chunk: tmp.write(chunk) pbar.update(len(chunk)) # rename the file to the original file extension os.rename(tmp.name, path) if cache: cls._logger.info(f'Cached file at {path}') return path
[docs] def get_aliases(self, contig: str) -> List[str]: """ Get all aliases for the given contig alias including the primary alias. :param contig: The contig. :return: The aliases. """ if contig in self._alias_mappings: return self.aliases[self._alias_mappings[contig]] return [contig]
[docs] class FASTAHandler(FileHandler): """ Handler for a FASTA reference. Reads the reference sequence used by the degeneracy, synonymy and CpG logic, indexed by record offset so a contig can be fetched without scanning the whole file, and resolving contig name aliases against the variant source. """
[docs] def __init__(self, fasta: str | None, cache: bool = True, aliases: Dict[str, List[str]] = {}): """ Create a new FASTAHandler instance. :param fasta: The path to the FASTA file. :param cache: Whether to cache files that are downloaded from URLs :param aliases: The contig aliases. """ FileHandler.__init__(self, cache=cache, aliases=aliases) #: The path to the FASTA file. self.fasta: str = fasta #: The current contig. self._contig: SeqRecord | None = None #: The alias sets the FASTA carries no contig for, so a site on an absent contig costs one #: set lookup rather than a scan over the whole file self._absent: Set[frozenset] = set()
@cached_property def _local(self) -> str | None: """ The path to the FASTA on the local filesystem, downloaded and decompressed where necessary, so that it can be seeked into. :return: The local path. """ if self.fasta is None: return None return self.unzip_if_zipped(self.download_if_url(self.fasta)) @cached_property def _offsets(self) -> Dict[str, int]: """ The byte offset of every contig's header line, in file order. Only the header lines are looked at, so building this costs one sequential pass and no sequence parsing, and it turns a lookup into a seek rather than into a scan from wherever the previous one stopped. :return: The offset of each contig. """ offsets: Dict[str, int] = {} if self._local is None: return offsets with open(self._local, 'rb') as f: offset = 0 for line in f: if line[:1] == b'>': # the record id is the header up to the first whitespace, as Biopython parses it parts = line[1:].split() name = parts[0].decode() if parts else '' # a duplicated name resolves to its first record, as a forward scan would offsets.setdefault(name, offset) offset += len(line) return offsets @cached_property def _handle(self) -> TextIO: """ The handle the contigs are read through, held open across lookups so that seeking to a contig does not reopen the file. :return: The handle. """ return open(self._local, 'r') @cached_property def _ref(self) -> FastaIterator | None: """ Get the reference reader. :return: The reference reader. """ if self.fasta is None: return return self.load_fasta(self.fasta)
[docs] def load_fasta(self, file: str) -> FastaIterator: """ Load a FASTA file into a dictionary. :param file: The path to The FASTA file path, possibly gzipped or a URL :return: Iterator over the sequences. """ self._logger.info("Loading FASTA file") # download and unzip if necessary local_file = self.unzip_if_zipped(self.download_if_url(file)) return SeqIO.parse(local_file, 'fasta')
[docs] def get_contig(self, aliases, rewind: bool = True, notify: bool = True) -> SeqRecord: """ Get the contig from the FASTA file. The contig is looked up in the header index and read by seeking to it, so the sites may visit the contigs in any order at the same cost. Note that ``pyfaidx`` would be more efficient here, but there were problems when running it in parallel. :param aliases: The contig aliases. :param rewind: Unused, the lookup does not depend on the position of a cursor. :param notify: Unused, the lookup does not depend on the position of a cursor. :return: The contig. :raises LookupError: Where the FASTA carries none of the aliases. """ # if the contig is already loaded, we can just return it if self._contig is not None and self._contig.id in aliases: return self._contig key = frozenset(aliases) if key not in self._absent: offsets = self._offsets for alias in aliases: offset = offsets.get(alias) if offset is not None: self._handle.seek(offset) self._contig = next(SeqIO.parse(self._handle, 'fasta')) return self._contig self._absent.add(key) raise LookupError(f'None of the contig aliases {aliases} were found in the FASTA file.')
[docs] def get_contig_names(self) -> List[str]: """ Get the names of the contigs in the FASTA file. :return: The contig names. """ if self.fasta is None: return [] return list(self._offsets)
def _rewind(self): """ Rewind the fasta iterator. """ # check the instance cache directly: hasattr() would fire the cached_property and open (and # for a URL download and decompress) the very reference we are about to discard if '_ref' in self.__dict__: # noinspection all del self._ref
class _GFFAnnotationLines(io.TextIOBase): """ The annotation records of a GFF file, ending at the ``##FASTA`` pragma. GFF3 permits the sequences the annotation refers to be appended after that pragma, and those lines carry a single field each, which a block-wise read would otherwise hand the parser as a block of its own. """ #: The pragma introducing the appended sequences _pragma: str = '##FASTA' def __init__(self, path: str): """ Open the file. :param path: The path to the GFF file. """ #: The underlying stream self._file: TextIO = open_file(path) #: Whether the pragma (or the end of the file) has been reached self._exhausted: bool = False #: The last, possibly incomplete, line of what has been read so far. Only whole lines are handed #: out, so the pragma cannot be split across two reads and each read starts at a line boundary self._held: str = '' def readable(self) -> bool: """ Whether the stream can be read. :return: Always ``True``. """ return True def _cut(self, data: str) -> Optional[int]: """ Where the appended sequences begin. :param data: The text read, starting at a line boundary. :return: The offset of the pragma, or ``None`` where the text does not reach it. """ if data.startswith(self._pragma): return 0 found = data.find('\n' + self._pragma) return None if found < 0 else found + 1 def read(self, size: Optional[int] = -1) -> str: """ The annotation records, up to roughly the requested number of characters. :param size: The number of characters to read, or a negative number for all of them. :return: The records read, ending in a complete line, or the empty string past the last record. """ while not self._exhausted: chunk = self._file.read() if size is None or size < 0 else self._file.read(size) data = self._held + chunk cut = self._cut(data) if cut is not None or not chunk: self._exhausted = True self._held = '' return data if cut is None else data[:cut] end = data.rfind('\n') + 1 self._held = data[end:] # a read landing inside a single line hands out nothing, so the next one is taken right away if end: return data[:end] return '' def close(self) -> None: """ Close the underlying stream. """ self._file.close() io.TextIOBase.close(self)
[docs] class GFFHandler(FileHandler): """ Handler for a GFF/GTF annotation. Loads the coding-sequence records the degeneracy and synonymy stratifications and the coding-sequence filtration rely on, keyed per contig and per transcript. """ #: The number of GFF lines read at a time, which bounds the memory the attributes column occupies _gff_block: int = 50000
[docs] def __init__(self, gff: str | None, cache: bool = True, aliases: Dict[str, List[str]] = {}): """ Constructor. :param gff: The path to the GFF file. :param cache: Whether to cache the file. :param aliases: The contig aliases. """ FileHandler.__init__(self, cache=cache, aliases=aliases) #: The logger self._logger = logger.getChild(self.__class__.__name__) #: The GFF file path self.gff = gff
@cached_property def _cds(self) -> pd.DataFrame | None: """ The coding sequences. :return: Dataframe with coding sequences. """ if self.gff is None: return return self._load_cds() @staticmethod def _cds_of_block(block: pd.DataFrame) -> pd.DataFrame: """ The coding sequences of one block of GFF lines, with their coordinates typed and their parent transcript extracted. :param block: The block of GFF lines. :return: The coding sequences of the block. """ # filter for coding sequences cds = block[block['type'] == 'CDS'] # drop rows with NA values cds = cds.dropna() # convert start and end to int cds = cds.assign(start=cds['start'].astype(int), end=cds['end'].astype(int)) # the transcript each coding sequence belongs to. Codons that span a CDS boundary are completed from the # adjacent CDS, which is only meaningful within one transcript: the nearest CDS by coordinate may belong # to an unrelated gene, possibly on the opposite strand. GFF3 spells this Parent=, GTF transcript_id "..". cds['parent'] = cds['attributes'].str.extract( r'(?:^|;)\s*(?:Parent|transcript_id)[=\s]"?([^;"]+)', expand=False) # drop type and attributes columns cds = cds.drop(columns=['type', 'attributes']) # the coding sequences of the transcripts of one gene repeat the same coordinates, so most of a # block is already redundant; the surviving rows are deduplicated again across the blocks, which # keeps the same first occurrence a single-pass read would return cds.drop_duplicates(subset=['seqid', 'start', 'end']) def _load_cds(self) -> pd.DataFrame: """ Load coding sequences from a GFF file. :return: The DataFrame. """ self._logger.info(f'Loading GFF file') # download and unzip if necessary local_file = self.unzip_if_zipped(self.download_if_url(self.gff)) # column labels for GFF file col_labels = ['seqid', 'source', 'type', 'start', 'end', 'score', 'strand', 'phase', 'attributes'] dtypes = dict( seqid='category', type='category', start=float, # temporarily load as float to handle NA values end=float, # temporarily load as float to handle NA values strand='category', phase='category' ) used = ['seqid', 'type', 'start', 'end', 'strand', 'phase', 'attributes'] categorical = ('seqid', 'strand', 'phase') categories: Dict[str, Dict[str, None]] = {column: {} for column in categorical} kept = [] # a whole-genome annotation is dominated by the attributes of the many non-CDS records, so the # file is read a block of lines at a time and only the coding sequences of each block, with their # attributes already reduced to the parent transcript, are carried over # the columns to keep are resolved against the field count of each block rather than of the file, # so a block of lines carrying fewer fields than the nine of the format aborts the whole read. # GFF3 appends the sequences the annotation refers to, one field per line, which past a certain # length make up such a block: the read ends where they begin with _GFFAnnotationLines(local_file) as lines: blocks = pd.read_csv( lines, sep='\t', comment='#', names=col_labels, dtype=dtypes, usecols=used, chunksize=self._gff_block ) for block in blocks: # a category the block does not carry is not in its own dtype, so the categories of every # block are collected: a contig without any coding sequence keeps its category and thereby # its (empty) group in a per-contig count, as it has when the file is read in one pass for column in categorical: categories[column].update(dict.fromkeys(block[column].cat.categories)) kept.append(self._cds_of_block(block)) df = pd.concat(kept, ignore_index=True) if kept else self._cds_of_block( pd.DataFrame(columns=used)) # the categories are ordered by name rather than by the block they first appear in, so that the # frame, which is sorted on the contig, does not depend on where the blocks fall for column in categorical: df[column] = pd.Categorical(df[column], categories=sorted(categories[column])) # remove duplicates df = df.drop_duplicates(subset=['seqid', 'start', 'end']) # sort by seqid and start df.sort_values(by=['seqid', 'start'], inplace=True) return df def _count_target_sites(self, remove_overlaps: bool = False, contigs: List[str] = None) -> Dict[str, int]: """ Count the number of target sites in a GFF file. :param remove_overlaps: Whether to remove overlapping coding sequences. :param contigs: The contigs to consider. :return: The number of target sites per chromosome/contig. """ cds = self._add_lengths( cds=self._load_cds(), remove_overlaps=remove_overlaps, contigs=contigs ) # group by 'seqid' and calculate the sum of 'length' target_sites = cds.groupby('seqid', observed=False)['length'].sum().to_dict() # filter explicitly for contigs if necessary # as seqid is a categorical variable, groups were retained even if they were filtered out if contigs is not None: target_sites = {k: v for k, v in target_sites.items() if k in contigs} return target_sites
[docs] @staticmethod def remove_overlaps(df: pd.DataFrame) -> pd.DataFrame: """ Remove overlapping coding sequences. :param df: The coding sequences. :return: The coding sequences without overlaps. """ with warnings.catch_warnings(): warnings.simplefilter("ignore", category=SettingWithCopyWarning) # shift within each contig, so the last CDS of one contig is not compared against the first # CDS of the next (whose start coordinate restarts low) and spuriously dropped as overlapping df['overlap'] = df.groupby('seqid')['start'].shift(-1) <= df['end'] df = df[~df['overlap']] return df.drop(columns=['overlap'])
@staticmethod def _add_lengths(cds: pd.DataFrame, remove_overlaps: bool = False, contigs: List[str] = None) -> pd.DataFrame: """ Compute coding sequences lengths. :param cds: The coding sequences. :param remove_overlaps: Whether to remove overlapping coding sequences. :param contigs: The contigs to consider. :return: The coding sequences with lengths. """ # filter for contigs if necessary if contigs is not None: cds = cds[cds['seqid'].isin(contigs)] # remove duplicates cds = cds.drop_duplicates(subset=['seqid', 'start']) # remove overlaps if remove_overlaps: cds = GFFHandler.remove_overlaps(cds) # catch warning when adding a new column to a slice of a DataFrame with warnings.catch_warnings(): warnings.simplefilter("ignore", category=SettingWithCopyWarning) # create a new column for the difference between 'end' and 'start' cds['length'] = cds['end'] - cds['start'] + 1 return cds
[docs] class VCFHandler(FileHandler): """ Base class for the handlers that read a variant source. Opens the source (a VCF file, a VCF-Zarr store or a tskit tree sequence) as a streamed :class:`VariantReader` and exposes the sample names, contig names and site count the parser and filters need. """
[docs] def __init__( self, vcf: "str | 'tskit.TreeSequence'", info_ancestral: str = 'AA', max_sites: int = np.inf, seed: int | None = 0, cache: bool = True, aliases: Dict[str, List[str]] = {} ): """ Create a new variant handler instance. :param vcf: The variant source: a VCF path (gzipped or a URL), a VCF-Zarr store (.vcz/.zarr), or a tskit tree sequence (a .trees path or a TreeSequence object) :param info_ancestral: The tag in the INFO field that contains the ancestral allele :param max_sites: Maximum number of sites to consider :param seed: Seed for the random number generator. Use ``None`` for no seed. :param cache: Whether to cache files that are downloaded from URLs :param aliases: The contig aliases. """ FileHandler.__init__(self, cache=cache, aliases=aliases) #: The variant source (a path or a tskit TreeSequence object) self.vcf = os.fspath(vcf) if isinstance(vcf, os.PathLike) else vcf #: The tag in the INFO field that contains the ancestral allele self.info_ancestral: str = info_ancestral #: Maximum number of sites to consider self.max_sites: int = int(max_sites) if not np.isinf(max_sites) else np.inf #: Seed for the random number generator self.seed: Optional[int] = int(seed) if seed is not None else None #: Random generator instance self.rng = np.random.default_rng(seed=seed)
@cached_property def _reader(self): """ Get the variant reader for the configured source (VCF, VCF-Zarr, or tree sequence). :return: The variant reader. """ return self._open_reader() def _rewind(self): """ Rewind the variant iterator, releasing the open reader. """ # check the instance cache directly: hasattr() would fire the cached_property and open the very # reader we are about to discard. The cache holds the only reference, so close before dropping it, # or the handle survives the rewind with nothing left to release it if '_reader' in self.__dict__: self._reader.close() # noinspection all del self._reader @staticmethod def _is_tree_sequence(source) -> bool: """ Whether the source is a tskit tree sequence (a ``TreeSequence`` object or a ``.trees`` path). :param source: The variant source. :return: Whether it is a tree sequence. """ if isinstance(source, str): return source.endswith('.trees') return type(source).__name__ == 'TreeSequence' and type(source).__module__.split('.')[0] == 'tskit' @staticmethod def _is_zarr_store(source) -> bool: """ Whether the source is a VCF-Zarr store path (a ``.vcz`` or ``.zarr`` directory). :param source: The variant source. :return: Whether it is a VCF-Zarr store. """ return isinstance(source, str) and source.rstrip('/').endswith(('.vcz', '.zarr')) def _open_reader(self): """ Open the appropriate variant reader for the configured source. A tskit tree sequence and a VCF-Zarr store are read through :class:`TskitVariantReader` and :class:`~sfsutils.io_handlers.ZarrVariantReader`; everything else is read from VCF via cyvcf2. :return: The variant reader. """ if self._is_tree_sequence(self.vcf): return TskitVariantReader(self._load_tree_sequence(self.vcf)) if self._is_zarr_store(self.vcf): return ZarrVariantReader(self.vcf, info_ancestral=self.info_ancestral) # a pre-built VariantReader passed directly as the source. We require a VariantReader rather # than an arbitrary iterable of sites because the parser needs a re-iterable source that also # exposes ``samples``, ``seqnames`` and ``count_sites()``; a bare iterable (or a cyvcf2.VCF # object) provides none of these and would fail later with an opaque error. if not isinstance(self.vcf, str): if isinstance(self.vcf, VariantReader): return self.vcf raise TypeError( f"Unsupported variant source of type '{type(self.vcf).__name__}'. A non-path source must be a " f"VariantReader (e.g. TskitVariantReader or ZarrVariantReader), which exposes 'samples', " f"'seqnames' and 'count_sites()' and is re-iterable. A bare iterable of sites or a cyvcf2.VCF " f"object is not supported; pass a VCF path/URL or wrap your sites in a VariantReader instead." ) return self.load_vcf() @staticmethod def _load_tree_sequence(source): """ Resolve a tree-sequence source (a ``TreeSequence`` object or a ``.trees`` path) to a loaded tree sequence. :param source: The tree-sequence source. :return: The loaded tree sequence. """ if not isinstance(source, str): return source try: import tskit except ImportError: raise ImportError( "Reading tree sequences in sfsutils requires the optional 'tskit' package. " "Please install sfsutils with the 'arg' extra: pip install \"sfsutils-popgen[arg]\"" ) return tskit.load(source)
[docs] def load_vcf(self) -> 'cyvcf2.VCF': """ Open a VCF file for streaming. :return: The VCF reader. """ try: from cyvcf2 import VCF except ImportError: raise ImportError( "VCF support in sfsutils requires the optional 'cyvcf2' package. " "Please install sfsutils with the 'vcf' extra: pip install \"sfsutils-popgen[vcf]\"" ) self._logger.info("Loading VCF file") return VCF(self.download_if_url(self.vcf))
@property def contig_lengths(self) -> Optional[Dict[str, int]]: """ The declared length (bp) of each contig of the source: the ``##contig`` headers of a VCF, the ``contig_length`` array of a VCF-Zarr store, or the length of the genome of a tree sequence. Only the contigs whose length is known are present, and a source declaring none gives ``None``. The span of the observed variants understates the region on a sparsely covered contig, so this is what a spectrum extrapolating monomorphic sites should be sized against. :return: The per-contig lengths, or ``None`` where the source declares none. """ reader = self._reader if isinstance(reader, VariantReader): return reader.contig_lengths # cyvcf2 reports a contig whose header declares no length as length 0 lengths = {contig: int(length) for contig, length in zip(reader.seqnames, getattr(reader, 'seqlens', [])) if int(length) > 0} return lengths or None @cached_property def n_sites(self) -> int: """ Get the number of sites in the input. :return: Number of sites """ return self.count_sites()
[docs] def count_sites(self) -> int: """ Count the number of sites in the source. :return: Number of sites """ # tree sequences and VCF-Zarr stores expose their site count directly if self._is_tree_sequence(self.vcf) or self._is_zarr_store(self.vcf): return int(min(self._reader.count_sites(), self.max_sites)) # a pre-built VariantReader passed directly as the source. VariantReader.count_sites() opens a # fresh iteration pass, so it does not exhaust the source and the later parse() pass still sees # every site (a plain generator would have been consumed here). Non-VariantReader sources are # rejected in _open_reader, so self._reader is guaranteed to be a VariantReader. if not isinstance(self.vcf, str): return int(min(self._reader.count_sites(), self.max_sites)) return count_sites( vcf=self.download_if_url(self.vcf), max_sites=self.max_sites, desc=f'{self.__class__.__name__}>Counting sites' )
[docs] def get_pbar(self, desc: str = "Processing sites", total: int | None = 0) -> tqdm: """ Return a progress bar for the number of sites. :param desc: Description for the progress bar :param total: Total number of items :return: tqdm """ return tqdm( total=self.n_sites if total == 0 else total, disable=Settings.disable_pbar, desc=desc )
class MultiHandler(VCFHandler, FASTAHandler, GFFHandler): """ Handle variant sources, FASTA and GFF files. """ def __init__( self, source: "str | os.PathLike | 'tskit.TreeSequence' | VariantReader | None" = None, fasta: str | None = None, gff: str | None = None, info_ancestral: str = 'AA', max_sites: int = np.inf, seed: int | None = 0, cache: bool = True, aliases: Dict[str, List[str]] = {}, vcf: "str | os.PathLike | 'tskit.TreeSequence' | VariantReader | None" = None ): """ Create a new MultiHandler instance. :param source: The variant source: a VCF path (gzipped or a URL), a VCF-Zarr store (.vcz/.zarr), a tskit tree sequence (a .trees path or a TreeSequence object), or a pre-built :class:`~sfsutils.io_handlers.VariantReader`. :param fasta: The path to the FASTA file. :param gff: The path to the GFF file. :param info_ancestral: The tag in the INFO field that contains the ancestral allele :param max_sites: Maximum number of sites to consider :param seed: Seed for the random number generator. Use ``None`` for no seed. :param cache: Whether to cache files that are downloaded from URLs :param aliases: The contig aliases. :param vcf: Deprecated alias for ``source``, kept for backward compatibility. Provide either ``source`` or ``vcf``, not both. :raises ValueError: If both ``source`` and ``vcf`` are given, or if neither is given. """ source = self._resolve_source(source, vcf) # initialize vcf handler VCFHandler.__init__( self, vcf=source, info_ancestral=info_ancestral, max_sites=max_sites, seed=seed, cache=cache, aliases=aliases ) # initialize fasta handler FASTAHandler.__init__( self, fasta=fasta, cache=cache, aliases=aliases ) # initialize gff handler GFFHandler.__init__( self, gff=gff, cache=cache, aliases=aliases ) #: The variant source. Alias of :attr:`vcf`, which is kept for backward compatibility. self.source = self.vcf @staticmethod def _resolve_source(source, vcf): """ Reconcile the ``source`` parameter with its deprecated ``vcf`` alias. :param source: The variant source. :param vcf: The deprecated ``vcf`` alias. :return: The resolved variant source. :raises ValueError: If both ``source`` and ``vcf`` are given, or if neither is given. """ if source is not None and vcf is not None: raise ValueError( "Provide either 'source' or the deprecated 'vcf' alias, not both." ) if source is None and vcf is None: raise ValueError( "A variant source must be provided via 'source' (or the deprecated 'vcf' alias)." ) if vcf is not None: warnings.warn( "The 'vcf' argument is deprecated; use 'source' instead.", DeprecationWarning, stacklevel=3 ) return source if source is not None else vcf def _require_fasta(self, class_name: str): """ Raise an exception if no FASTA file was provided. :param class_name: The name of the class that requires a FASTA file. """ if self.fasta is None: raise ValueError(f'{class_name} requires a FASTA file to be specified.') def _require_gff(self, class_name: str): """ Raise an exception if no GFF file was provided. :param class_name: The name of the class that requires a GFF file. """ if self.gff is None: raise ValueError(f'{class_name} requires a GFF file to be specified.') def _rewind(self): """ Rewind the fasta and vcf handler. """ FASTAHandler._rewind(self) VCFHandler._rewind(self)
[docs] class NoTypeException(BaseException): """ Exception thrown when no type can be determined. """ pass
[docs] class Variant: """ Minimal concrete implementation of the :class:`~sfsutils.io_handlers.Site` interface: a duck-typed stand-in for a :class:`cyvcf2.Variant` exposing the subset of its interface that the parser, filtrations, annotations and stratifications rely on: ``CHROM``, ``POS``, ``REF``, ``ALT``, ``INFO``, the ``is_*`` type flags and the per-sample ``gt_bases``, alongside the numeric ``allele_indices`` these backends hold natively. Non-VCF backends (tree sequences, VCF-Zarr stores) emit these objects. """ #: Whether the variant is an SNP is_snp: bool = False #: Whether the variant is an MNP is_mnp: bool = False #: Whether the variant is an indel is_indel: bool = False #: Whether the variant is a deletion is_deletion: bool = False #: Whether the variant is a structural variant is_sv: bool = False #: The per-haplotype allele indices, absent unless the backend supplies them allele_indices: Optional[np.ndarray] = None
[docs] def __init__( self, ref: str, pos: int, chrom: str, gt_bases: Sequence[str] | np.ndarray | None = None, alt: Sequence[str] | None = None, is_snp: bool = False, is_mnp: bool = False, info: Dict[str, object] | None = None, allele_indices: np.ndarray | None = None, phased: bool | Sequence[bool] | np.ndarray | None = None, ): """ Initialize the variant. :param ref: The reference allele. :param pos: The position. :param chrom: The contig. :param gt_bases: The per-sample genotype strings (e.g. ``"A/T"``), as for ``cyvcf2.Variant.gt_bases``. :param alt: The alternate alleles. :param is_snp: Whether the site is a single-nucleotide polymorphism. :param is_mnp: Whether the site is a multi-nucleotide polymorphism. :param info: The INFO field. :param allele_indices: The per-haplotype allele indices into ``[ref] + alt``, of shape ``(n_samples, ploidy)``, with ``-1`` for a missing call. Where ``gt_bases`` is omitted the genotype strings are assembled from these on demand. :param phased: Whether each sample's genotype is phased, either per sample or for the site as a whole, which decides the separator of the assembled genotype strings. """ #: The reference allele self.REF: str = ref #: The position self.POS: int = int(pos) #: The contig self.CHROM: str = chrom #: The alternate alleles self.ALT: List[str] = list(alt) if alt is not None else [] # supplied genotype strings shadow the cached property that would otherwise assemble them if gt_bases is not None: self.gt_bases = np.asarray(gt_bases) if allele_indices is not None: self.allele_indices = allele_indices #: Whether each sample's genotype is phased self._phased: bool | Sequence[bool] | np.ndarray | None = phased #: Whether the site is an SNP self.is_snp: bool = is_snp #: Whether the site is an MNP self.is_mnp: bool = is_mnp #: Info field self.INFO: Dict[str, object] = dict(info) if info else {}
@cached_property def gt_bases(self) -> np.ndarray: """ The per-sample genotype strings, assembled from the allele indices. Most consumers read the indices instead, so a backend that holds the calls numerically supplies those alone and the strings are built only where something actually asks for them. :return: The genotype strings, one per sample. """ indices = self.allele_indices if indices is None: return np.array([], dtype=object) alleles = [self.REF] + list(self.ALT) n_alleles = len(alleles) if self._phased is None: separators = ['/'] * len(indices) elif np.ndim(self._phased) == 0: separators = ['|' if self._phased else '/'] * len(indices) else: separators = ['|' if p else '/' for p in self._phased] return np.array([ separators[i].join(alleles[a] if 0 <= a < n_alleles else '.' for a in row) for i, row in enumerate(indices) ], dtype=object)
class DummyVariant(Variant): """ Synthetic monomorphic reference site used by the :class:`~sfsutils.parser.TargetSiteCounter` to represent a sampled target site. It exposes the full :class:`Site` interface; every sample is homozygous for the reference allele. The per-sample ``gt_bases`` array is built lazily (there may be many such sites and most consumers never read the genotypes) via a cached property. """ def __init__(self, ref: str, pos: int, chrom: str, n_samples: int = 0, ploidy: int = 2): """ Initialize the dummy variant. :param ref: The reference allele. :param pos: The position. :param chrom: The contig. :param n_samples: The number of samples, so ``gt_bases`` aligns with the parser's sample masks (default ``0`` for a genotype-less placeholder used where the genotypes are not read). :param ploidy: The ploidy of each sample's genotype. """ super().__init__(ref=ref, pos=pos, chrom=chrom) self._n_samples: int = int(n_samples) self._ploidy: int = int(ploidy) @cached_property def gt_bases(self) -> np.ndarray: """ The per-sample genotypes: every sample homozygous for the reference allele. :return: The genotype strings, one per sample. """ return np.array(['/'.join([self.REF] * self._ploidy)] * self._n_samples, dtype=object) @cached_property def allele_indices(self) -> np.ndarray: """ The per-haplotype allele indices: every haplotype carries the reference allele. :return: The allele indices, of shape ``(n_samples, ploidy)``. """ return np.zeros((self._n_samples, self._ploidy), dtype=int)
[docs] class VariantReader(Iterable, ABC): """ Common streaming interface over a variant source. Concrete readers wrap a VCF-Zarr store or a tskit tree sequence and yield :class:`~sfsutils.io_handlers.Variant` objects in file order, so the parser can consume any input format through a single ``for variant in reader`` loop. Readers are re-iterable: each ``iter(reader)`` starts a fresh pass over the source. """ @property @abstractmethod def samples(self) -> List[str]: """ The sample names, in genotype-column order. :return: The sample names. """ pass @property @abstractmethod def seqnames(self) -> List[str]: """ The contig (sequence) names present in the source, matching ``cyvcf2.VCF.seqnames``. :return: The contig names. """ pass @abstractmethod def __iter__(self) -> Iterator[Variant]: """ Iterate over the sites of the source. :return: An iterator over variants. """ pass @property def sequence_length(self) -> Optional[float]: """ The total length (bp) of the source region, when the source knows it (as a tree sequence does). Used to estimate the site density when extrapolating monomorphic sites from a target-site count. Returns ``None`` when no reliable length is available (e.g. a VCF-Zarr store), in which case the observed variant span is used instead. :return: The sequence length, or ``None``. """ return None @property def contig_lengths(self) -> Optional[Dict[str, int]]: """ The declared length (bp) of each contig of the source, where the source declares them. Only the contigs whose length is known are present. Used to size the region a spectrum extrapolates over, which the span of the observed variants understates on a sparsely covered contig. :return: The per-contig lengths, or ``None`` where the source declares none. """ return None
[docs] def add_info_to_header(self, data: dict): """ Declare an INFO field, registering it with the underlying VCF header. Does nothing for non-VCF sources, which have no header. :param data: The INFO field definition. """ pass
[docs] def count_sites(self) -> int: """ Count the number of sites in the source. :return: The number of sites. """ return sum(1 for _ in self)
[docs] def close(self): """ Release any resources held by the reader. """ pass
[docs] class TskitVariantReader(VariantReader): """ Stream variants from a tskit tree sequence (e.g. an inferred ARG or an msprime simulation). Sample haplotype nodes are grouped into diploid (or higher-ploidy) samples by individual, exactly as :meth:`tskit.TreeSequence.write_vcf` does, so parsing a ``.trees`` file yields the same spectrum as parsing the VCF written from it. tskit stores the ancestral state as allele ``0``, which becomes the reference allele, so the parser recovers the correct polarisation with ``skip_non_polarized=False``. """
[docs] def __init__(self, ts: 'tskit.TreeSequence', contig: str = '1'): """ Initialize the reader. :param ts: The tree sequence. :param contig: The contig name to report (tree sequences have no contig concept). """ #: The tree sequence self._ts = ts #: The reported contig name self._contig = str(contig) sample_nodes = list(int(n) for n in ts.samples()) #: Genotype-column index for each sample node self._node_col = {node: i for i, node in enumerate(sample_nodes)} sample_set = set(sample_nodes) # group sample nodes into VCF-style samples by individual (matching write_vcf); fall back to one # haploid sample per node when the tree sequence has no individuals individuals = [ind for ind in ts.individuals() if sample_set.intersection(int(n) for n in ind.nodes)] if individuals: self._sample_names = [f"tsk_{ind.id}" for ind in individuals] self._groups = [[int(n) for n in ind.nodes if int(n) in sample_set] for ind in individuals] else: self._sample_names = [f"tsk_{i}" for i in range(len(sample_nodes))] self._groups = [[node] for node in sample_nodes] ploidy = max((len(group) for group in self._groups), default=0) #: Genotype column of each sample's haplotypes, of shape ``(n_samples, ploidy)``. A sample of #: lower ploidy than the site's widest is padded with ``-1``, which reads back as a missing call #: and so contributes nothing, exactly as its shorter genotype string does self._cols = np.full((len(self._groups), ploidy), -1, dtype=np.intp) for i, group in enumerate(self._groups): self._cols[i, :len(group)] = [self._node_col[node] for node in group] #: Whether the samples differ in ploidy self._ragged: bool = any(len(group) != ploidy for group in self._groups)
@property def samples(self) -> List[str]: """ The sample names. :return: The sample names. """ return list(self._sample_names) @property def seqnames(self) -> List[str]: """ The contig name (tree sequences have a single synthetic contig). :return: The contig names. """ return [self._contig] @property def tree_sequence(self) -> 'tskit.TreeSequence': """ The underlying tree sequence, used by :class:`TskitVariantWriter` to write a site-subset ``.trees``. :return: The tree sequence. """ return self._ts @property def sequence_length(self) -> float: """ The length of the tree-sequence genome, i.e. the region over which sites are distributed. This is the true extent even when the observed (polymorphic) sites do not reach the ends. :return: The sequence length. """ return float(self._ts.sequence_length) @property def contig_lengths(self) -> Dict[str, int]: """ The length of the single synthetic contig the tree sequence is reported under. :return: The per-contig lengths. """ return {self._contig: int(self._ts.sequence_length)}
[docs] def count_sites(self) -> int: """ The number of sites in the tree sequence. :return: The number of sites. """ return int(self._ts.num_sites)
def __iter__(self) -> Iterator[Variant]: """ Iterate over the sites of the tree sequence. :return: An iterator over variants. """ # the tskit Site is materialised afresh on every access, decoding the metadata of each of its # mutations, so the positions are taken from the table column instead positions = self._ts.sites_position for var in self._ts.variants(): alleles = var.alleles genotypes = var.genotypes is_snp = _is_snp(alleles[0], [a for a in alleles[1:] if a]) codes = np.asarray(genotypes)[self._cols] if all(alleles): # the tskit numbering is already that of ``[REF] + ALT`` and its missing sentinel is the # one the allele indices use, so the calls carry over as they are allele_indices = codes else: # an empty allele is dropped from ALT, so the tskit allele numbering is re-indexed onto # ``[REF] + ALT``; a dropped allele reads back as a missing call, as its genotype string does remap = np.full(len(alleles) + 1, -1, dtype=np.intp) remap[0] = 0 next_index = 1 for j, allele in enumerate(alleles[1:], start=1): if allele: remap[j] = next_index next_index += 1 allele_indices = remap[np.where((codes >= 0) & (codes < len(alleles)), codes, len(alleles))] if self._ragged: # a sample of lower ploidy than the widest has no call in its trailing columns, which the # fill sentinel marks apart from a call that is genuinely missing allele_indices = np.where(self._cols >= 0, allele_indices, -2) # a sample of lower ploidy than the widest carries a padding column, which would show up as an # extra missing allele in the assembled genotype string, so those are written out here instead gt_bases = np.array([ "|".join( alleles[genotypes[self._node_col[node]]] if genotypes[self._node_col[node]] >= 0 and alleles[genotypes[self._node_col[node]]] else "." for node in group ) for group in self._groups ], dtype=object) if self._ragged else None position = float(positions[var.index]) variant = Variant( ref=alleles[0], pos=int(position) + 1, # tskit positions are 0-based, VCF POS is 1-based chrom=self._contig, gt_bases=gt_bases, alt=[a for a in alleles[1:] if a], is_snp=is_snp, allele_indices=allele_indices, # tree-sequence haplotypes within an individual are ordered, hence phased, as in write_vcf phased=True, ) # carry the exact (possibly non-integer) tskit position so TskitVariantWriter can identify the # site without relying on the lossy integer POS, which collides on continuous-genome sequences variant._tskit_position = position yield variant
[docs] class ZarrVariantReader(VariantReader): """ Stream variants from a VCF-Zarr store in the `vcf2zarr <https://sgkit-dev.github.io/bio2zarr>`_ (VCZ) layout. Genotypes are read in variant chunks to bound memory. An ancestral-allele INFO field, if encoded as a ``variant_<tag>`` array, is surfaced under ``INFO`` so the usual polarisation logic applies. """ #: The number of variants read per batch where the store does not declare its own chunk grid _default_chunk_size: int = 1000
[docs] def __init__(self, path: str, info_ancestral: str = 'AA', chunk_size: Optional[int] = None): """ Initialize the reader. :param path: The path to the VCF-Zarr store. :param info_ancestral: The INFO tag holding the ancestral allele. :param chunk_size: The number of variants read per batch, or ``None`` to follow the store's own chunking along the ``variants`` axis. """ try: import zarr except ImportError: raise ImportError( "VCF-Zarr support in sfsutils requires the optional 'zarr' package. " "Please install sfsutils with the 'zarr' extra: pip install \"sfsutils-popgen[zarr]\"" ) #: The Zarr store root self._root = zarr.open(path, mode='r') #: The INFO tag holding the ancestral allele self._info_ancestral = info_ancestral #: The number of variants read per batch self._chunk_size = int(chunk_size) if chunk_size else self._store_chunk_size() #: The sample names self._sample_ids = [self._decode(s) for s in self._root['sample_id'][:]] #: The contig names self._contig_ids = [self._decode(c) for c in self._root['contig_id'][:]]
def _store_chunk_size(self) -> int: """ The store's own chunk length along the ``variants`` axis, which every variant and call array shares. A batch that straddles the stored chunks makes each of them be fetched and decompressed once per batch overlapping it, so the batch follows the grid rather than a fixed length. :return: The number of variants to read per batch. """ for name in ('call_genotype', 'variant_position'): if name in self._root: chunks = getattr(self._root[name], 'chunks', None) if chunks: return max(1, int(chunks[0])) return self._default_chunk_size @staticmethod def _decode(value) -> str: """ Decode a Zarr string scalar (bytes or str) to ``str``. :param value: The value. :return: The decoded string. """ return value.decode() if isinstance(value, bytes) else str(value) #: The allele entries a VCF-Zarr store uses to pad a site to the widest allele count, and which #: tskit2zarr also writes for a mutation to the empty allele _empty_alleles = frozenset(('', '.')) #: Marker for an entry the store fills a site with, as opposed to one the site is missing. The VCF-Zarr #: spec keeps the two apart throughout: fill pads a site to the widest value count of the field and is #: no part of the field's value, where missing is a value the site carries and does not know _fill_marker = object() #: The NaN bit patterns the VCF-Zarr spec gives the fill marker of a float array, by width. Any other #: NaN, the spec's missing marker among them, is a value the site is missing rather than padding _float_fill_bits: Dict[int, int] = {4: 0x7F800002, 8: 0x7FF0000000000002} @classmethod def _float_entry(cls, value): """ One entry of a float ``variant_<key>`` array, telling the two NaN markers apart. :param value: The stored entry. :return: The value, ``None`` where it is missing, or :attr:`_fill_marker` where it is padding. """ entry = np.asarray(value) if not np.isnan(entry): return float(entry) # the payload distinguishing the markers is lost on a widening cast, so the bits are read in the # width the array itself is stored in bits = int(entry.view(f'u{entry.dtype.itemsize}')) return cls._fill_marker if bits == cls._float_fill_bits.get(entry.dtype.itemsize) else None @classmethod def _info_entry(cls, value, kind: str): """ One entry of a ``variant_<key>`` array as an INFO value, with the types cyvcf2 surfaces (str/float/int/bool). :param value: The stored entry. :param kind: The dtype kind of the array it comes from. :return: The value, ``None`` where the entry is missing, or :attr:`_fill_marker` where it is the padding of a site carrying fewer values than the widest. """ if kind in ('U', 'S', 'O', 'T'): decoded = cls._decode(value) if decoded == '': return cls._fill_marker return None if decoded == '.' else decoded if kind == 'f': return cls._float_entry(value) if kind == 'b': # a bool array encodes a VCF Flag: surface it only when set, as cyvcf2 does (an absent flag # is stored False and must not read back as present-False) return True if bool(value) else None # the VCF-Zarr missing (-1) and fill (-2) sentinels of an integer array, which every writer # emits for a field a site does not carry entry = int(value) if entry == -2: return cls._fill_marker return None if entry == -1 else entry @classmethod def _info_scalar(cls, value, kind: str): """ One entry of a one-dimensional ``variant_<key>`` array as an INFO value, with the store's missing markers reported as absent. A one-dimensional array holds one value per site, so it carries no padding and the two markers coincide. :param value: The stored entry. :param kind: The dtype kind of the array it comes from. :return: The value, or ``None`` where the store marks the entry as absent. """ entry = cls._info_entry(value, kind) return None if entry is cls._fill_marker else entry @classmethod def _info_row(cls, values, kind: str): """ One row of a two-dimensional ``variant_<key>`` array as an INFO value. A field of ``Number != 1`` is stored one value per column, padded to the widest site, which is collapsed back to the form cyvcf2 hands out: the comma-separated string for a string field, the scalar or the tuple of values for a numeric one. A value the site is missing keeps its position, so that the values surviving it stay with the allele they belong to. :param values: The stored row. :param kind: The dtype kind of the array it comes from. :return: The value, or ``None`` where the site carries no entry at all. """ entries = [cls._info_entry(value, kind) for value in values] # only the padding past the last value of the site is no part of the field while entries and entries[-1] is cls._fill_marker: entries.pop() # padding a writer left between the values rather than after them cannot be told from a value the # site is missing, and reads back as one entries = [None if entry is cls._fill_marker else entry for entry in entries] if all(entry is None for entry in entries): return None if kind in ('U', 'S', 'O', 'T'): # cyvcf2 hands back the field as the VCF spells it, a missing value included return ','.join('.' if entry is None else entry for entry in entries) return entries[0] if len(entries) == 1 else tuple(entries) @classmethod def _decode_alleles(cls, batch) -> List[List[str]]: """ The allele strings of a batch of sites. Decoding the batch in one go spares a call per allele of every site, which is where the bulk of the strings of a store are. :param batch: The stored allele entries of the batch. :return: The allele strings of each site. """ rows = np.asarray(batch).tolist() # a fixed-width unicode array and the zarr-3 variable-length string dtype both hand out ``str`` if getattr(batch, 'dtype', None) is not None and batch.dtype.kind in ('U', 'T'): return rows return [[cls._decode(x) for x in row] for row in rows] @classmethod def _alleles(cls, alleles: List[str], rows: np.ndarray) -> Tuple[List[str], np.ndarray]: """ The allele strings of one site alongside its calls. An empty entry is either the padding a site with fewer alleles than the widest carries or, from a tree sequence, a mutation to the empty allele; both are dropped from ``[REF] + ALT``, so the calls are re-indexed onto the alleles that remain and a call of a dropped allele reads back as missing, exactly as the tree sequence itself streams it. :param alleles: The allele strings of the site, padding included. :param rows: The stored allele indices of the site. :return: The allele strings and the re-indexed allele indices. """ # a store pads every site to the widest allele count it holds anywhere, so one multi-allelic site # gives every other site a trailing empty entry. That padding sits past the last allele of the # site and is therefore referenced by no call, which leaves the numbering of the alleles before it # untouched: dropping it is the whole of the work, and the re-indexing below is the identity k = len(alleles) while k > 1 and alleles[k - 1] in cls._empty_alleles: k -= 1 if not any(a in cls._empty_alleles for a in alleles[:k]): return alleles[:k], rows remap = np.full(len(alleles) + 1, -1, dtype=np.intp) remap[0] = 0 kept = [alleles[0]] if alleles else [] for j, allele in enumerate(alleles[1:], start=1): if allele not in cls._empty_alleles: remap[j] = len(kept) kept.append(allele) remapped = remap[np.where((rows >= 0) & (rows < len(alleles)), rows, len(alleles))] # the fill of a sample below the widest ploidy is distinct from a call that is missing, so it # survives the re-indexing rather than collapsing onto -1 return kept, np.where(rows == -2, -2, remapped) @property def samples(self) -> List[str]: """ The sample names. :return: The sample names. """ return list(self._sample_ids) @property def seqnames(self) -> List[str]: """ The contig names declared in the store. :return: The contig names. """ return list(self._contig_ids) @property def contig_lengths(self) -> Optional[Dict[str, int]]: """ The contig lengths the store declares in its ``contig_length`` array, which vcf2zarr fills from the ``##contig`` headers of the VCF it converts. A contig whose length the store leaves unset is absent from the mapping. A store written by :class:`ZarrVariantWriter` from a source declaring no lengths carries the last position written on each contig instead, so a length read here is a lower bound on the contig rather than a guarantee. :return: The per-contig lengths, or ``None`` where the store carries no lengths at all. """ if 'contig_length' not in self._root: return None lengths = np.asarray(self._root['contig_length'][:], dtype=float) return {contig: int(length) for contig, length in zip(self._contig_ids, lengths) if np.isfinite(length) and length > 0}
[docs] def count_sites(self) -> int: """ The number of sites in the store. :return: The number of sites. """ return int(self._root['variant_position'].shape[0])
def __iter__(self) -> Iterator[Variant]: """ Iterate over the sites of the store. :return: An iterator over variants. """ root = self._root position = root['variant_position'] allele = root['variant_allele'] contig = root['variant_contig'] arrays = list(root.array_keys()) # a store written from a sample-less VCF carries no call arrays at all, and streams as a series of # sites without genotypes, exactly as the same VCF does genotype = root['call_genotype'] if 'call_genotype' in arrays else None phased = root['call_genotype_phased'] if 'call_genotype_phased' in arrays else None # surface every INFO field the writer persisted as a variant_<key> string array (the ancestral # tag, but also e.g. an annotated Degeneracy/Synonymy), skipping the VCF fixed columns that # vcf2zarr stores as reserved variant_* arrays (CHROM/POS/ID/REF+ALT/QUAL/FILTER and their # length/mask companions); otherwise a store re-parsed stratified by an annotated field would # see no INFO, while a plain vcf2zarr store would fabricate INFO from its reserved metadata reserved_arrays = {'variant_position', 'variant_contig', 'variant_allele', 'variant_id', 'variant_id_mask', 'variant_quality', 'variant_filter', 'variant_length'} # a field of Number != 1 (AC/DP4, but also the VEP CSQ and SnpEff ANN of a multi-transcript # annotation) is stored as a 2-D variant_<key> array, one column per value, and is surfaced in # the comma-separated form cyvcf2 hands out info_arrays = {k[len('variant_'):]: root[k] for k in root.array_keys() if k.startswith('variant_') and k not in reserved_arrays and root[k].ndim in (1, 2)} n = position.shape[0] for start in range(0, n, self._chunk_size): end = min(start + self._chunk_size, n) pos_batch = position[start:end] allele_batch = self._decode_alleles(allele[start:end]) contig_batch = contig[start:end] gt_batch = np.asarray(genotype[start:end]) if genotype is not None else None phased_batch = np.asarray(phased[start:end]) if phased is not None else None info_batches = {key: arr[start:end] for key, arr in info_arrays.items()} for i in range(end - start): rows = gt_batch[i] if gt_batch is not None else np.zeros((0, 0), dtype=np.int8) seps = phased_batch[i] if phased_batch is not None else None site_alleles, rows = self._alleles(allele_batch[i], rows) is_snp = _is_snp(site_alleles[0] if site_alleles else '', site_alleles[1:]) # surface INFO with native types matching cyvcf2 (float/int/bool/str), so a numeric field # stored typed (by vcf2zarr, or by our own writer) is not silently a string info = {} for key, batch in info_batches.items(): kind = batch.dtype.kind value = (self._info_scalar(batch[i], kind) if batch.ndim == 1 else self._info_row(batch[i], kind)) if value is not None: info[key] = value yield Variant( ref=site_alleles[0] if site_alleles else '.', pos=int(pos_batch[i]), # vcf2zarr stores the 1-based VCF position chrom=self._contig_ids[int(contig_batch[i])], alt=site_alleles[1:], is_snp=is_snp, info=info, allele_indices=rows, phased=seps, )
[docs] class VariantWriter(ABC): """ Abstract writer mirroring :class:`~sfsutils.io_handlers.VariantReader`: it consumes the same streamed :class:`~sfsutils.io_handlers.Variant` interface and writes it to a concrete on-disk format. The output format follows the output file's extension (see :meth:`VariantWriter.open`). """
[docs] @staticmethod def open( output: str, reader: Union['cyvcf2.VCF', VariantReader], info_ancestral: str = 'AA' ) -> 'VariantWriter': """ Open the variant writer matching the output file's extension: a VCF-Zarr store for ``.vcz``/``.zarr`` (from any input), a tskit tree sequence for ``.trees`` (only when the input is itself a tree sequence, since a genealogy cannot be reconstructed from genotype data), and a VCF otherwise. :param output: The output path; its extension selects the format. :param reader: The open input reader (a cyvcf2 VCF or a :class:`~sfsutils.io_handlers.VariantReader`). :param info_ancestral: The INFO tag holding the ancestral allele, for the VCF-Zarr writer. :return: The writer. :raises ValueError: If a ``.trees`` output is requested from a non-tree-sequence input. """ fmt = _output_format(output) if fmt == 'zarr': return ZarrVariantWriter(output, samples=list(reader.samples), seqnames=list(reader.seqnames), info_ancestral=info_ancestral, contig_lengths=VariantWriter._declared_lengths(reader)) if fmt == 'tskit': if not isinstance(reader, TskitVariantReader): raise ValueError( "Writing a tree sequence (.trees) is only supported when the input is itself a tree sequence: " "a genealogy cannot be reconstructed from genotype data without ARG inference. Use a .vcz/.zarr " "or VCF output instead." ) return TskitVariantWriter(reader.tree_sequence, output) return VCFVariantWriter(output, reader)
@staticmethod def _declared_lengths(reader: Union['cyvcf2.VCF', VariantReader]) -> Dict[str, int]: """ The contig lengths the input declares, so an output store records the region the input covers rather than the span of the variants that survive filtering, which understates it. :param reader: The open input reader. :return: The per-contig lengths the input declares, empty where it declares none. """ if isinstance(reader, VariantReader): return dict(reader.contig_lengths or {}) # cyvcf2 reports a contig whose header declares no length as length 0 return {contig: int(length) for contig, length in zip(reader.seqnames, getattr(reader, 'seqlens', [])) if int(length) > 0}
[docs] def write(self, variant: Site) -> None: """ Write a single variant. :param variant: The variant to write. """ raise NotImplementedError
[docs] def close(self) -> None: """ Flush and release any resources held by the writer. """ pass
[docs] class VCFVariantWriter(VariantWriter): """ Write variants to a VCF (optionally gzipped) via cyvcf2, copying the header from the input VCF. Because it reuses the input's cyvcf2 header, this writer requires a VCF input; writing VCF output from a tree sequence or VCF-Zarr store is not supported (use a ``.vcz``/``.zarr`` output instead). """
[docs] def __init__(self, output: str, template: 'cyvcf2.VCF'): """ Open the writer. :param output: The output VCF path. :param template: The input cyvcf2 VCF whose header is copied. :raises ValueError: If the input is not a VCF (no cyvcf2 header to copy). """ try: from cyvcf2 import Writer except ImportError: raise ImportError( "VCF support in sfsutils requires the optional 'cyvcf2' package. " "Please install sfsutils with the 'vcf' extra: pip install \"sfsutils-popgen[vcf]\"" ) if isinstance(template, VariantReader): raise ValueError( "Writing VCF output is only supported from a VCF input, since the header is copied from it. " "To write from a tree sequence or VCF-Zarr store, use a .vcz/.zarr output instead." ) self._writer = Writer(output, template)
[docs] def write(self, variant: 'cyvcf2.Variant') -> None: """ Write a single record. :param variant: The cyvcf2 variant to write. """ self._writer.write_record(variant)
[docs] def close(self) -> None: """ Close the underlying writer. """ self._writer.close()
[docs] class ZarrVariantWriter(VariantWriter): """ Write variants to a VCF-Zarr store in the `vcf2zarr <https://sgkit-dev.github.io/bio2zarr>`_ (VCZ) layout read back by :class:`~sfsutils.io_handlers.ZarrVariantReader`, so the output can come from any input (VCF, tree sequence or another VCF-Zarr store). Any INFO fields present on the variants (for example an annotated ancestral allele) are persisted as ``variant_<tag>`` arrays. Positions, genotypes, alleles and INFO values accumulate in chunk-sized buffers and each complete chunk is flushed to the store, so the memory held is one chunk rather than the whole input. The two ragged axes (the ploidy and the allele width) are only known once every variant has been seen, so the arrays are created from the first chunk and rebuilt on the rare site that widens them. The same holds of the type of an INFO field, which is taken from the chunk it first appears in and rewritten on the rare chunk that widens it (an integer field first seen without a value, a numeric one that turns out to carry a string). """ #: Number of variants per chunk along the ``variants`` axis, as written by vcf2zarr _variant_chunk: int = 10000 #: Number of samples per chunk along the ``samples`` axis, as written by vcf2zarr _sample_chunk: int = 1000 #: The coordinate, allele and genotype datasets an INFO field must not collide with _reserved = frozenset({'variant_position', 'variant_contig', 'variant_allele', 'call_genotype', 'call_genotype_phased', 'sample_id', 'contig_id'}) #: Marker for an INFO field a variant does not carry _missing = object() #: The genotype sentinel of a haplotype a call does not reach, which the VCF-Zarr spec separates from #: the ``-1`` of a call that is present but missing, so a shorter call exports at its own ploidy _fill: int = -2 #: The VCF-Zarr missing-float sentinel (a specific NaN bit pattern), which vcztools/sgkit emit as a #: missing INFO value; a plain NaN would be exported as the literal token 'nan' _float_missing: float = np.array([0x7FF0000000000001], dtype=np.uint64).view(np.float64)[0] #: How an INFO field is encoded, from the narrowest to the widest. A field is written in the #: encoding of the chunk it first appears in and promoted where a later chunk does not fit it. _info_dtypes: Dict[str, Any] = {'bool': bool, 'int': np.int64, 'float': np.float64, 'str': str}
[docs] def __init__(self, output: str, samples: List[str], seqnames: List[str], info_ancestral: str = 'AA', contig_lengths: Optional[Dict[str, int]] = None): """ Open the writer. :param output: The output store path (``.vcz`` or ``.zarr``). :param samples: The sample names. :param seqnames: The contig names. :param info_ancestral: The INFO tag holding the ancestral allele (written as ``variant_<tag>``). :param contig_lengths: The lengths the source declares for its contigs, written to ``contig_length`` so the store reports the region rather than the span of the variants it holds. A contig absent from the mapping falls back to the last position written on it. """ try: import zarr # noqa: F401 except ImportError: raise ImportError( "VCF-Zarr support in sfsutils requires the optional 'zarr' package. " "Please install sfsutils with the 'zarr' extra: pip install \"sfsutils-popgen[zarr]\"" ) self._output = output self._logger = logger.getChild(self.__class__.__name__) #: Whether the store has been finalised, so that closing again is a no-op self._closed: bool = False self._samples = list(samples) self._contig_ids = list(seqnames) self._contig_index = {c: i for i, c in enumerate(self._contig_ids)} self._info_ancestral = info_ancestral # per-contig lengths (the last position seen on each), so a ##contig header can be emitted self._contig_length: List[int] = [1] * len(self._contig_ids) # the lengths the source declares, which describe the whole contig and not just the part of it # the written variants reach self._contig_declared: Dict[str, int] = {contig: int(length) for contig, length in dict(contig_lengths or {}).items() if int(length) > 0} # the INFO values of the variants of the current chunk, one list per field, padded with the # missing marker where a variant does not carry the field self._info: Dict[str, List] = {} # the encoding each field has been written in so far, so a chunk that does not fit it promotes it self._info_kind: Dict[str, str] = {} # whether every value a field has carried so far was an integer, which a numeric array no longer # says once it holds them and which decides how they are spelled if the field turns out to be one self._info_integral: Dict[str, bool] = {} self._skipped: Set[str] = set() self._root = None self._n: int = 0 self._flushed: int = 0 self._row: int = 0 self._ploidy: Optional[int] = None self._max_alleles: Optional[int] = None self._buf_position: Optional[np.ndarray] = None self._buf_contig: Optional[np.ndarray] = None self._buf_genotype: Optional[np.ndarray] = None self._buf_phased: Optional[np.ndarray] = None self._buf_allele: Optional[np.ndarray] = None
@property def _axis_ploidy(self) -> int: """ The extent of the ploidy axis: the widest call seen, or two where no variant carries any call. :return: The ploidy. """ return self._ploidy if self._ploidy else 2 @property def _axis_alleles(self) -> int: """ The extent of the allele axis: the most alleles seen at any variant. :return: The allele count. """ return self._max_alleles if self._max_alleles else 1 @property def _gt_dtype(self) -> np.dtype: """ The genotype dtype, sized to the data so many-allele sites do not wrap. :return: The dtype. """ return np.dtype(np.int8 if self._axis_alleles <= np.iinfo(np.int8).max else np.int32)
[docs] def write(self, variant: Site) -> None: """ Buffer a single variant, flushing the current chunk to the store once it is full. :param variant: The variant to write. """ alleles = [variant.REF] + list(variant.ALT) indices, phased = self._calls(variant, alleles) chrom = variant.CHROM contig = self._contig_index.get(chrom) if contig is None: contig = self._contig_index[chrom] = len(self._contig_ids) self._contig_ids.append(chrom) self._contig_length.append(1) pos = int(variant.POS) if pos > self._contig_length[contig]: self._contig_length[contig] = pos n_calls, width = indices.shape self._reserve(width, len(alleles)) row = self._row # the buffers are reused across chunks, so the row is cleared before it is filled self._buf_position[row] = pos self._buf_contig[row] = contig self._buf_allele[row] = '' self._buf_allele[row, :len(alleles)] = alleles self._buf_genotype[row] = self._fill self._buf_genotype[row, :n_calls, :width] = indices self._buf_phased[row] = False self._buf_phased[row, :n_calls] = phased self._record_info(variant) self._n += 1 self._row += 1 if self._row == self._variant_chunk: self._flush()
def _calls(self, variant: Site, alleles: List[str]) -> Tuple[np.ndarray, np.ndarray]: """ The numeric calls of a variant: the per-haplotype allele indices of shape ``(n_samples, ploidy)`` alongside the per-sample phase. Every backend carries the calls numerically, so they are taken as they are; assembling and splitting the genotype strings would both cost the bulk of the write and lose the calls those strings cannot express, such as a haploid call of a third allele or any call of ploidy above two. :param variant: The variant. :param alleles: The site's allele strings, the reference first. :return: The allele indices and the per-sample phase. """ indices = getattr(variant, 'allele_indices', None) if indices is not None: return np.atleast_2d(np.asarray(indices)), self._phases(getattr(variant, '_phased', None), len(indices)) # cyvcf2 holds the calls on its Genotypes object, whose last column carries the phase flag genotype = getattr(variant, 'genotype', None) array = np.asarray(genotype.array()) if genotype is not None else None if array is not None and array.ndim == 2 and array.shape[1] >= 2: return array[:, :-1], array[:, -1].astype(bool) return self._split(variant, alleles) @staticmethod def _phases(phased, n_samples: int) -> np.ndarray: """ The per-sample phase of a variant that declares it either per sample or for the site as a whole. :param phased: The phase declaration. :param n_samples: The number of samples. :return: The per-sample phase. """ if phased is None: return np.zeros(n_samples, dtype=bool) if np.ndim(phased) == 0: return np.full(n_samples, bool(phased), dtype=bool) return np.asarray(phased, dtype=bool) @staticmethod def _split(variant: Site, alleles: List[str]) -> Tuple[np.ndarray, np.ndarray]: """ The numeric calls of a variant that carries its genotypes as strings alone. :param variant: The variant. :param alleles: The site's allele strings, the reference first. :return: The allele indices and the per-sample phase. """ index = {a: i for i, a in enumerate(alleles)} # the same genotype string recurs across most samples, so each distinct one is split and looked # up once and the samples only carry a row index into the table of distinct calls table: List[List[int]] = [] phased: List[bool] = [] codes: Dict[str, int] = {} gt_bases = np.asarray(variant.gt_bases) rows = np.empty(len(gt_bases), dtype=np.intp) for i, gt in enumerate(gt_bases): gt = str(gt) code = codes.get(gt) if code is None: code = codes[gt] = len(table) table.append([index.get(c, -1) for c in gt.replace('|', '/').split('/')]) phased.append('|' in gt) rows[i] = code width = max((len(call) for call in table), default=0) calls = np.full((len(table), width), ZarrVariantWriter._fill, dtype=np.intp) for i, call in enumerate(table): calls[i, :len(call)] = call return calls[rows], np.asarray(phased, dtype=bool)[rows] def _record_info(self, variant: Site) -> None: """ Buffer the INFO values of a variant, keeping one list per field aligned with the rows of the chunk being filled. :param variant: The variant whose INFO fields to record. """ info = dict(variant.INFO) if getattr(variant, 'INFO', None) else {} for key, value in info.items(): values = self._info.get(key) if values is None: if f'variant_{key}' in self._reserved: self._skipped.add(key) continue # a field may first appear at any variant, so the earlier rows of the chunk are marked as # missing (the chunks already flushed are backfilled when the array is created) values = self._info[key] = [self._missing] * self._row # cyvcf2 returns a tuple for a Number=A/R/G/. field; write the comma-separated form the VCF # uses, so the value stays parseable instead of becoming a Python repr such as "(12, 3)" if isinstance(value, (tuple, list, np.ndarray)): value = ','.join(str(x) for x in value) values.append(value) for values in self._info.values(): if len(values) == self._row: values.append(self._missing) def _reserve(self, ploidy: int, alleles: int) -> None: """ Make room in the buffers for a variant of the given ploidy and allele count, allocating them on the first variant and widening them where a ragged axis grows. :param ploidy: The widest call of the variant. :param alleles: The number of alleles the variant carries. """ self._ploidy = ploidy if self._ploidy is None else max(self._ploidy, ploidy) self._max_alleles = alleles if self._max_alleles is None else max(self._max_alleles, alleles) n_samples = len(self._samples) if self._buf_position is None: self._buf_position = np.empty(self._variant_chunk, dtype=np.int64) self._buf_contig = np.empty(self._variant_chunk, dtype=np.int64) self._buf_phased = np.zeros((self._variant_chunk, n_samples), dtype=bool) self._buf_genotype = np.full((self._variant_chunk, n_samples, self._axis_ploidy), self._fill, dtype=self._gt_dtype) self._buf_allele = np.full((self._variant_chunk, self._axis_alleles), '', dtype=object) return if self._buf_genotype.shape[2] != self._axis_ploidy or self._buf_genotype.dtype != self._gt_dtype: widened = np.full((self._variant_chunk, n_samples, self._axis_ploidy), self._fill, dtype=self._gt_dtype) # the axis narrows where the variants so far carry no call at all and the default ploidy of # two gives way to the first call seen, whose columns beyond it hold nothing but the fill width = min(self._buf_genotype.shape[2], self._axis_ploidy) widened[:self._row, :, :width] = self._buf_genotype[:self._row, :, :width] self._buf_genotype = widened if self._buf_allele.shape[1] != self._axis_alleles: widened = np.full((self._variant_chunk, self._axis_alleles), '', dtype=object) widened[:self._row, :self._buf_allele.shape[1]] = self._buf_allele[:self._row] self._buf_allele = widened def _chunks(self, shape: Tuple[int, ...], dimensions: List[str]) -> Tuple[int, ...]: """ The chunk grid of an array. The spec requires a single chunk size along ``variants`` across all variant and call arrays, and a single one along ``samples`` across the call arrays, so readers such as vcztools can align the chunk grids. Zarr's own auto-chunking would pick a different grid per array and leave the store readable only by us. :param shape: The final shape of the array. :param dimensions: The names of its axes. :return: The chunk shape. """ sizes = {'variants': self._variant_chunk, 'samples': self._sample_chunk} # any remaining axis (ploidy, alleles, contigs) stays in a single chunk, and an empty axis # still needs a positive chunk length return tuple(max(1, min(sizes.get(dim, extent), extent)) for dim, extent in zip(dimensions, shape)) def _create(self, name: str, shape: Tuple[int, ...], dtype, dimensions: List[str], extent=None): """ Create an array in the store. Every array carries an ``_ARRAY_DIMENSIONS`` attribute naming its axes, so the store is a spec-compliant VCF-Zarr readable by vcztools / sgkit and not only by our own reader. Variable-length strings use the native zarr-3 ``str`` dtype. :param name: The array name. :param shape: The shape to create it with. :param dtype: The dtype. :param dimensions: The names of its axes. :param extent: The final shape the chunk grid is clamped to, where it differs from ``shape``. :return: The created array. """ from zarr.errors import UnstableSpecificationWarning with warnings.catch_warnings(): # VCF-Zarr requires the fixed-length string layout, whose zarr-v3 specification is still unstable; # the notice is not actionable for this format warnings.simplefilter('ignore', UnstableSpecificationWarning) array = self._root.create_array(name, shape=shape, dtype=dtype, chunks=self._chunks(shape if extent is None else extent, dimensions)) array.attrs['_ARRAY_DIMENSIONS'] = list(dimensions) return array def _write(self, name: str, data, dimensions: List[str]) -> None: """ Create an array and write it whole. :param name: The array name. :param data: The data. :param dimensions: The names of its axes. """ data = np.asarray(data) array = self._create(name, data.shape, str if data.dtype == object else data.dtype, dimensions) array[...] = data @staticmethod def _str_data(values) -> np.ndarray: """ Cast values to an object array of strings, which is written as a variable-length string array. :param values: The values. :return: The object array. """ return np.asarray([str(v) for v in values], dtype=object) def _open(self) -> None: """ Open the store and create the streamed arrays. A mid-stream flush only happens once the first chunk is complete, so clamping the grid to the variants seen so far gives the same result as clamping it to the final count. """ import zarr self._root = zarr.open(self._output, mode='w') n_samples = len(self._samples) genotype = (self._n, n_samples, self._axis_ploidy) self._create('variant_position', (0,), np.int64, ['variants'], extent=(self._n,)) self._create('variant_contig', (0,), np.int64, ['variants'], extent=(self._n,)) self._create('call_genotype', (0, n_samples, self._axis_ploidy), self._gt_dtype, ['variants', 'samples', 'ploidy'], extent=genotype) self._create('call_genotype_phased', (0, n_samples), bool, ['variants', 'samples'], extent=(self._n, n_samples)) self._create('variant_allele', (0, self._axis_alleles), str, ['variants', 'alleles'], extent=(self._n, self._axis_alleles)) def _widen(self) -> None: """ Grow the ragged axes of the arrays already in the store to their current extent, so the buffered chunk fits. """ genotype = self._root['call_genotype'] if genotype.shape[2] != self._axis_ploidy or genotype.dtype != self._gt_dtype: self._rebuild('call_genotype', (len(self._samples), self._axis_ploidy), self._gt_dtype, self._fill, ['variants', 'samples', 'ploidy']) if self._root['variant_allele'].shape[1] != self._axis_alleles: self._rebuild('variant_allele', (self._axis_alleles,), str, '', ['variants', 'alleles']) def _rebuild(self, name: str, tail: Tuple[int, ...], dtype, fill, dimensions: List[str]) -> None: """ Recreate an array with a wider trailing shape, carrying the data over a chunk at a time so only one chunk is held in memory. Zarr fixes the chunk grid at creation, so the widened axis needs a fresh array rather than a resize. :param name: The array name. :param tail: The shape after the ``variants`` axis. :param dtype: The dtype. :param fill: The value the widened columns take. :param dimensions: The names of its axes. """ staging = f'{name}_staging' shape = (self._root[name].shape[0],) + tail self._copy(self._root[name], self._create(staging, shape, dtype, dimensions), fill) del self._root[name] self._copy(self._root[staging], self._create(name, shape, dtype, dimensions), fill) del self._root[staging] def _copy(self, source, target, fill) -> None: """ Copy an array chunk by chunk into a target whose trailing axis is at least as wide. :param source: The array to read. :param target: The array to write. :param fill: The value the columns beyond the source's width take. """ dtype = object if isinstance(fill, str) else target.dtype width = min(source.shape[-1], target.shape[-1]) for start in range(0, source.shape[0], self._variant_chunk): stop = min(start + self._variant_chunk, source.shape[0]) block = np.full((stop - start,) + target.shape[1:], fill, dtype=dtype) block[..., :width] = source[start:stop][..., :width] target[start:stop] = block def _flush(self) -> None: """ Write the buffered chunk to the store, creating or widening the arrays as needed. """ if self._root is None: self._open() else: self._widen() if self._row == 0: return start, stop = self._flushed, self._flushed + self._row for name, buffer in (('variant_position', self._buf_position), ('variant_contig', self._buf_contig), ('call_genotype', self._buf_genotype), ('call_genotype_phased', self._buf_phased), ('variant_allele', self._buf_allele)): array = self._root[name] array.resize((stop,) + array.shape[1:]) array[start:stop] = buffer[:self._row] self._flush_info(start, stop) self._flushed = stop self._row = 0
[docs] def close(self) -> None: """ Write the remaining variants and finalise the store. """ # the arrays are created as the store is finalised, so finalising twice would fail on them; a # teardown that runs again after an error must not turn that into a second, confusing exception if self._closed: return self._closed = True self._flush() self._write('sample_id', self._str_data(self._samples), ['samples']) self._write('contig_id', self._str_data(self._contig_ids), ['contigs']) lengths = [self._contig_declared.get(contig, observed) for contig, observed in zip(self._contig_ids, self._contig_length)] self._write('contig_length', np.array(lengths, dtype=np.int64), ['contigs']) for key in sorted(self._skipped): self._logger.warning(f"Skipping INFO field '{key}': it collides with the reserved VCF-Zarr " f"dataset 'variant_{key}'.") # mark the store as a spec-compliant VCF-Zarr so external readers (vcztools/sgkit) accept it from . import __version__ self._root.attrs['vcf_zarr_version'] = '0.5' self._root.attrs['source'] = f'sfsutils-{__version__}'
@staticmethod def _is_bool(value) -> bool: """ Whether a value is a boolean, i.e. a VCF Flag. :param value: The value. :return: Whether it is a boolean. """ return isinstance(value, (bool, np.bool_)) @classmethod def _is_int(cls, value) -> bool: """ Whether a value is an integer that an int64 array holds and that no reader mistakes for the VCF-Zarr missing or fill sentinel. :param value: The value. :return: Whether it is such an integer. """ return (isinstance(value, (int, np.integer)) and not cls._is_bool(value) and -(2 ** 63) <= int(value) < 2 ** 63 and int(value) not in (-1, -2)) @classmethod def _is_numeric(cls, value) -> bool: """ Whether a value is one a float64 array holds without losing precision. An integer beyond the range of an int64 would, so it is written as a string instead. :param value: The value. :return: Whether it is such a number. """ return (isinstance(value, (float, np.floating)) or (isinstance(value, (int, np.integer)) and not cls._is_bool(value) and -(2 ** 63) <= int(value) < 2 ** 63)) def _absent(self, value) -> bool: """ Whether a variant carries no value for a field: the value is unset, the empty string, or the VCF ``.`` missing marker (which annotations such as DegeneracyAnnotation write for sites the field does not apply to). cyvcf2 hands back ``None`` for a Number=1 numeric field whose value is ``.``. :param value: The value. :return: Whether the value is absent. """ if value is self._missing or value is None: return True if isinstance(value, float) and np.isnan(value): return True return isinstance(value, str) and value in ('', '.') def _kind(self, values: List) -> str: """ The narrowest encoding holding one chunk of the values of an INFO field. :param values: The values of the chunk. :return: The encoding, or ``empty`` where the chunk carries no value at all. """ present = [v for v in values if not self._absent(v)] if not present: return 'empty' if all(self._is_bool(v) for v in present): return 'bool' # an integer array has no way of marking a missing value apart from the sentinels, so a field # that is absent anywhere in the chunk is carried as a float if all(self._is_int(v) for v in present) and len(present) == len(values): return 'int' if all(self._is_numeric(v) for v in present): return 'float' return 'str' @staticmethod def _widest(left: str, right: str) -> str: """ The encoding holding the values of two encodings at once. A flag and a number have none in common, so both are written out as the strings the VCF spells them with. :param left: The one encoding. :param right: The other encoding. :return: The encoding holding both. """ if left == 'empty' or left == right: return right if right == 'empty': return left return 'float' if {left, right} == {'int', 'float'} else 'str' def _encode(self, values: List, kind: str) -> np.ndarray: """ One chunk of the values of an INFO field in its encoding, with the marker the encoding uses for an absent value. :param values: The values of the chunk. :param kind: The encoding. :return: The encoded chunk. """ if kind == 'bool': # an absent flag is a flag that is not set return np.array([False if self._absent(v) else bool(v) for v in values], dtype=bool) if kind == 'int': # an integer array has no encoding for an absent value beyond the VCF-Zarr missing sentinel, # which the reader maps back to absent return np.array([-1 if self._absent(v) else int(v) for v in values], dtype=np.int64) if kind == 'float': return np.array([self._float_missing if self._absent(v) else float(v) for v in values], dtype=np.float64) return self._str_data(['' if self._absent(v) else str(v) for v in values]) def _recode(self, data: np.ndarray, source: str, kind: str, integral: bool) -> np.ndarray: """ One chunk of an INFO field already in the store, in a wider encoding. :param data: The stored chunk. :param source: The encoding it is stored in. :param kind: The encoding to carry it over to. :param integral: Whether every value the array holds reached it as an integer. :return: The re-encoded chunk. """ if source == kind: return data if kind == 'float': return data.astype(np.float64) if source == 'float': # a field whose values all reached the store as integers is spelled without a fractional part # again, so that one mixing integers and strings reads back as the VCF wrote it return self._str_data(['' if np.isnan(v) else str(int(v)) if integral else str(float(v)) for v in data]) if source == 'int': return self._str_data([str(int(v)) for v in data]) # a bool array spells an unset flag as False, which is an absent value and not the string 'False' return self._str_data(['True' if bool(v) else '' for v in data]) def _flush_info(self, start: int, stop: int) -> None: """ Write the INFO values of the buffered chunk as ``variant_<tag>`` arrays, typed to match the values. A field first appearing in this chunk is created here, with the variants before it marked as carrying no value, and a field whose values outgrow their encoding is rewritten in a wider one. :param start: The first row of the chunk. :param stop: The row past the chunk. """ for key, values in self._info.items(): name = f'variant_{key}' # a field the last variants of the chunk do not carry is missing on them values.extend([self._missing] * (self._row - len(values))) chunk = self._kind(values) kind = self._widest(self._info_kind.get(key, 'empty'), chunk) # an integer array cannot mark a value the whole chunk is missing, so a field stored as one # widens to the encoding that can if chunk == 'empty' and kind == 'int': kind = 'float' # a field carrying no value at all so far is held as strings, whose empty string the reader # reports as absent, and which a later chunk can still widen if kind == 'empty': kind = 'str' integral = self._info_integral.get(key, True) and all( isinstance(v, (int, np.integer)) and not self._is_bool(v) for v in values if not self._absent(v)) if name not in self._root: # the variants before the field first appears carry no value, and an integer array cannot # say so, so a field appearing late is numeric rather than integral if start and kind == 'int': kind = 'float' array = self._create(name, (0,), self._info_dtypes[kind], ['variants'], extent=(self._n,)) # the variants written before the field first appears are marked a chunk at a time, so # that the memory held stays that of one chunk rather than that of the whole input for begin in range(0, start, self._variant_chunk): finish = min(begin + self._variant_chunk, start) array.resize((finish,)) array[begin:finish] = self._encode([self._missing] * (finish - begin), kind) elif kind != self._info_kind[key]: self._rebuild_info(name, self._info_kind[key], kind, self._info_integral[key]) self._info_kind[key] = kind self._info_integral[key] = integral array = self._root[name] array.resize((stop,)) array[start:stop] = self._encode(values[:self._row], kind) values.clear() def _rebuild_info(self, name: str, source: str, kind: str, integral: bool) -> None: """ Rewrite an INFO array in a wider encoding, a chunk at a time so only one chunk is held in memory. Zarr fixes the dtype at creation, so the wider encoding needs a fresh array rather than a cast. :param name: The array name. :param source: The encoding the array is stored in. :param kind: The encoding to rewrite it in. :param integral: Whether every value the array holds reached it as an integer. """ staging = f'{name}_staging' shape = self._root[name].shape self._recast(self._root[name], self._create(staging, shape, self._info_dtypes[kind], ['variants']), source, kind, integral) del self._root[name] self._recast(self._root[staging], self._create(name, shape, self._info_dtypes[kind], ['variants']), kind, kind, integral) del self._root[staging] def _recast(self, source, target, source_kind: str, kind: str, integral: bool) -> None: """ Copy an INFO array chunk by chunk into a target of a wider encoding. :param source: The array to read. :param target: The array to write. :param source_kind: The encoding the source is stored in. :param kind: The encoding of the target. :param integral: Whether every value the source holds reached it as an integer. """ for start in range(0, source.shape[0], self._variant_chunk): stop = min(start + self._variant_chunk, source.shape[0]) target[start:stop] = self._recode(source[start:stop], source_kind, kind, integral)
[docs] class TskitVariantWriter(VariantWriter): """ Write a site-subset of an input tree sequence to a ``.trees`` file. Only the sites whose variants are written (i.e. survive filtering) are kept, via :meth:`tskit.TreeSequence.delete_sites`, leaving the genealogy untouched. This is the only well-defined ``.trees`` output: a genealogy cannot be reconstructed from genotype data, so a tree-sequence output requires a tree-sequence input. INFO fields added by annotations are attached to the kept sites as JSON metadata on a best-effort basis. """
[docs] def __init__(self, ts: 'tskit.TreeSequence', output: str): """ Open the writer. :param ts: The source tree sequence. :param output: The output ``.trees`` path. """ self._ts = ts self._output = output self._logger = logger.getChild(self.__class__.__name__) #: The exact tskit positions of the sites to keep. self._kept: set = set() #: INFO metadata keyed by exact tskit position, for kept sites. self._info_by_pos: Dict[float, Dict[str, object]] = {}
def _position(self, variant: Site) -> float: """ The exact tskit (0-based) position of a variant. :class:`TskitVariantReader` attaches it so that continuous-genome sequences, whose positions collide under the integer VCF ``POS``, are identified unambiguously; for any other source we fall back to reconstructing it from ``POS``. :param variant: The variant. :return: The exact 0-based position. """ pos = getattr(variant, '_tskit_position', None) return float(pos) if pos is not None else float(int(variant.POS) - 1)
[docs] def write(self, variant: Site) -> None: """ Mark the variant's site as kept. :param variant: The variant to keep. """ pos = self._position(variant) self._kept.add(pos) if getattr(variant, 'INFO', None): self._info_by_pos[pos] = dict(variant.INFO)
[docs] def close(self) -> None: """ Delete the dropped sites and dump the resulting tree sequence. """ drop = [site.id for site in self._ts.sites() if site.position not in self._kept] sub = self._ts.delete_sites(drop) if self._info_by_pos: sub = self._attach_site_metadata(sub) sub.dump(self._output)
def _attach_site_metadata(self, ts: 'tskit.TreeSequence') -> 'tskit.TreeSequence': """ Attach the collected INFO fields to the kept sites as permissive-JSON metadata. :param ts: The site-subset tree sequence. :return: The tree sequence with site metadata, or the input unchanged if metadata could not be encoded. """ import tskit try: tables = ts.dump_tables() sites = tables.sites.copy() tables.sites.clear() tables.sites.metadata_schema = tskit.MetadataSchema.permissive_json() for row in sites: info = self._info_by_pos.get(row.position, {}) metadata = {k: (v if isinstance(v, (str, int, float, bool)) else str(v)) for k, v in info.items()} tables.sites.add_row(position=row.position, ancestral_state=row.ancestral_state, metadata=metadata) return tables.tree_sequence() except Exception as e: self._logger.warning(f"Could not attach INFO metadata to the tree sequence: {e}") return ts
def _output_format(output: str) -> str: """ Infer the output format from a file name. :param output: The output path. :return: One of ``'zarr'``, ``'tskit'`` or ``'vcf'``. """ lowered = output.rstrip('/').lower() if lowered.endswith(('.vcz', '.zarr')): return 'zarr' if lowered.endswith('.trees'): return 'tskit' return 'vcf'