Skip to content

API reference

liulab-data: data curation, download, and organization utilities for the Liu Lab.

BioProject

BioProject(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _SraDownloadMixin, _Record

An NCBI BioProject (PRJNA000000), resolved lazily through NCBI Entrez.

BioProject is the umbrella record that groups a study's GEO Series and SRA data. It lives in the Entrez bioproject database, whose esummary uses the v2.0 DocumentSummarySet shape.

Parameters:

Name Type Description Default
accession str

A BioProject accession, e.g. "PRJNA545296" (^PRJ[A-Z]{2}\d+$, covering NCBI PRJNA, EBI PRJEB, and DDBJ PRJDB).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed BioProject accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

uid cached property

uid: str

The Entrez bioproject UID for this record.

Raises:

Type Description
EntrezError

If NCBI returns no matching bioproject record.

title property

title: str

The project title.

name property

name: str

The project name.

description property

description: str

The free-text project description.

organism property

organism: str

The organism the project targets.

data_type property

data_type: str

The project data type (e.g. Transcriptome or Gene expression).

registration_date property

registration_date: str

The project's registration date as reported by NCBI.

submitter property

submitter: str

The submitting organization.

url property

url: str

The NCBI BioProject web URL for this record.

series cached property

series: list[Series]

The GEO Series (GSE) under this project, as :class:Series instances.

One elink plus one batched esummary; each :class:Series is seeded with the UID and summary already fetched.

experiments cached property

experiments: list[Experiment]

The SRA experiments (SRX) under this project, as :class:Experiment instances.

download

download(
    output_dir: str | Path = ".",
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    select_srx: Iterable[str] | None = None,
    original_srx: Iterable[str] | None = None,
    max_size: str | None = None,
    retries: int | None = None,
    backoff: float | None = None,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    verbose: bool = True,
) -> dict[str, bool]

Download FASTQ for every run of every SRA experiment in this record.

Lays the data out as <output_dir>/<accession>/<SRX>/<SRR>*.fastq.gz — one directory per experiment under a directory named for this record — via a Snakemake DAG (prefetch → extract → compress). Runs already marked done (a .<SRR>.success flag) are skipped, so this is safe to rerun on an unstable connection.

Concurrency is resource-aware: at most ncbi_parallel runs prefetch from NCBI at once (keeping the link busy without tripping throttling), while cores bounds total CPU across the local extraction/compression steps — so those two stages overlap instead of blocking each other.

Pass select_srx to download only some of the record's experiments (see below).

Parameters:

Name Type Description Default
output_dir str or Path

Parent directory; a <output_dir>/<accession> subtree is created for this record.

"."
ncbi_parallel int

Maximum runs to prefetch (the NCBI network step) concurrently. Keep this modest to avoid NCBI throttling; it is independent of cores.

:data:`DEFAULT_NCBI_PARALLEL`
cores int

Total CPU cores the DAG may use across all steps (defaults to the machine's CPU count). On an HPC allocation, pass the allotted cores (e.g. $SLURM_CPUS_ON_NODE).

None
threads_per_run int

Threads handed to fasterq-dump/pigz for each run.

:data:`DEFAULT_THREADS_PER_RUN`
select_srx iterable of str

A whitelist of SRA experiment accessions (SRX…). When given, only the record's experiments whose accession is listed are downloaded and every other experiment is skipped; None (the default) downloads them all. Entries are matched case-insensitively and every one must be a well-formed SRX accession — anything else raises :class:~labdata.exceptions.AccessionError.

None
original_srx iterable of str

A set of SRA experiment accessions (SRX…) to fetch in original format instead of the default sra-tools path: every run of a listed experiment has its submitter-uploaded files downloaded as-is (no fasterq-dump/gzip), because its .sra is missing or useless (e.g. a 10X run whose .sra stored only one read). This selects the mode; it does not by itself widen the selection — an experiment must still survive select_srx to be downloaded at all. Each entry must be a well-formed SRX accession or :class:~labdata.exceptions.AccessionError is raised.

None
max_size str

Passed to prefetch --max-size (defaults to :data:DEFAULT_MAX_SIZE); raise it for runs that exceed prefetch's 20G default.

None
retries int

Attempts for the resumable prefetch network step per run (defaults to :data:DEFAULT_RETRIES).

None
backoff float

Base seconds for the linear backoff between prefetch retries (defaults to :data:DEFAULT_BACKOFF).

None
keep_sra bool

Keep each run's downloaded .sra next to its FASTQ (as <accession>/<SRX>/<SRR>.sra) instead of deleting it after extraction.

False
prefetch_only bool

Only prefetch each run's .sra (into <SRX>/<SRR>/), skipping fasterq-dump, gzip, and cleanup. A later full download resumes from extraction without re-fetching.

False
verbose bool

Print a download plan up front and one line per finished run to stderr. Set False to download silently.

True

Returns:

Type Description
dict[str, bool]

Maps each run accession to whether its data is present on completion.

Examples:

>>> Series("GSE229022").download("./fastq", ncbi_parallel=3, cores=16)
{'SRR24084454': True, 'SRR24084455': True, ...}
>>> BioProject("PRJNA1027859").download("./fastq", ncbi_parallel=3)
{'SRR26452081': True, 'SRR26452082': True, ...}
>>> Series("GSE229022").download("./fastq", select_srx=["SRX19885398"])
{'SRR24084454': True}
Source code in src/labdata/geo/sratools.py
def download(
    self,
    output_dir: str | Path = ".",
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    select_srx: Iterable[str] | None = None,
    original_srx: Iterable[str] | None = None,
    max_size: str | None = None,
    retries: int | None = None,
    backoff: float | None = None,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    verbose: bool = True,
) -> dict[str, bool]:
    """Download FASTQ for every run of every SRA experiment in this record.

    Lays the data out as ``<output_dir>/<accession>/<SRX>/<SRR>*.fastq.gz`` —
    one directory per experiment under a directory named for this record — via
    a Snakemake DAG (``prefetch → extract → compress``). Runs already marked
    done (a ``.<SRR>.success`` flag) are skipped, so this is safe to rerun on
    an unstable connection.

    Concurrency is resource-aware: at most ``ncbi_parallel`` runs ``prefetch``
    from NCBI at once (keeping the link busy without tripping throttling), while
    ``cores`` bounds total CPU across the local extraction/compression steps —
    so those two stages overlap instead of blocking each other.

    Pass ``select_srx`` to download only some of the record's experiments (see
    below).

    Parameters
    ----------
    output_dir : str or Path, default "."
        Parent directory; a ``<output_dir>/<accession>`` subtree is created for
        this record.
    ncbi_parallel : int, default :data:`DEFAULT_NCBI_PARALLEL`
        Maximum runs to ``prefetch`` (the NCBI network step) concurrently. Keep
        this modest to avoid NCBI throttling; it is independent of ``cores``.
    cores : int, optional
        Total CPU cores the DAG may use across all steps (defaults to the
        machine's CPU count). On an HPC allocation, pass the allotted cores
        (e.g. ``$SLURM_CPUS_ON_NODE``).
    threads_per_run : int, default :data:`DEFAULT_THREADS_PER_RUN`
        Threads handed to ``fasterq-dump``/``pigz`` for each run.
    select_srx : iterable of str, optional
        A whitelist of SRA experiment accessions (``SRX…``). When given, only
        the record's experiments whose accession is listed are downloaded and
        every other experiment is skipped; ``None`` (the default) downloads
        them all. Entries are matched case-insensitively and every one must be
        a well-formed ``SRX`` accession — anything else raises
        :class:`~labdata.exceptions.AccessionError`.
    original_srx : iterable of str, optional
        A set of SRA experiment accessions (``SRX…``) to fetch in *original
        format* instead of the default sra-tools path: every run of a listed
        experiment has its submitter-uploaded files downloaded as-is (no
        ``fasterq-dump``/gzip), because its ``.sra`` is missing or useless
        (e.g. a 10X run whose ``.sra`` stored only one read). This selects the
        *mode*; it does not by itself widen the selection — an experiment must
        still survive ``select_srx`` to be downloaded at all. Each entry must
        be a well-formed ``SRX`` accession or :class:`~labdata.exceptions.AccessionError`
        is raised.
    max_size : str, optional
        Passed to ``prefetch --max-size`` (defaults to :data:`DEFAULT_MAX_SIZE`);
        raise it for runs that exceed ``prefetch``'s 20G default.
    retries : int, optional
        Attempts for the resumable ``prefetch`` network step per run (defaults
        to :data:`DEFAULT_RETRIES`).
    backoff : float, optional
        Base seconds for the linear backoff between ``prefetch`` retries
        (defaults to :data:`DEFAULT_BACKOFF`).
    keep_sra : bool, default False
        Keep each run's downloaded ``.sra`` next to its FASTQ (as
        ``<accession>/<SRX>/<SRR>.sra``) instead of deleting it after extraction.
    prefetch_only : bool, default False
        Only ``prefetch`` each run's ``.sra`` (into ``<SRX>/<SRR>/``), skipping
        ``fasterq-dump``, gzip, and cleanup. A later full download resumes from
        extraction without re-fetching.
    verbose : bool, default True
        Print a download plan up front and one line per finished run to
        ``stderr``. Set ``False`` to download silently.

    Returns
    -------
    dict[str, bool]
        Maps each run accession to whether its data is present on completion.

    Examples
    --------
    >>> Series("GSE229022").download("./fastq", ncbi_parallel=3, cores=16)  # doctest: +SKIP
    {'SRR24084454': True, 'SRR24084455': True, ...}
    >>> BioProject("PRJNA1027859").download("./fastq", ncbi_parallel=3)  # doctest: +SKIP
    {'SRR26452081': True, 'SRR26452082': True, ...}
    >>> Series("GSE229022").download("./fastq", select_srx=["SRX19885398"])  # doctest: +SKIP
    {'SRR24084454': True}
    """
    # ``None`` overrides fall through to the sra-tools defaults.
    overrides = {
        key: value
        for key, value in {"max_size": max_size, "retries": retries, "backoff": backoff}.items()
        if value is not None
    }
    # Original-format experiments select the download *mode*, not the selection.
    original_set = _validate_srx_whitelist(original_srx, field="original_srx")
    base = Path(output_dir) / self.accession
    tasks: list[tuple[Run, Path]] = []
    original: set[str] = set()
    for experiment in self._selected_experiments(select_srx):
        srx_dir = base / experiment.accession
        srx_dir.mkdir(parents=True, exist_ok=True)
        is_original = experiment.accession in original_set
        for run in experiment.runs:
            tasks.append((run, srx_dir))
            if is_original:
                original.add(run.accession)
    return _run_plan(
        self.accession,
        tasks,
        output_root=base,
        verbose=verbose,
        ncbi_parallel=ncbi_parallel,
        cores=cores,
        threads_per_run=threads_per_run,
        keep_sra=keep_sra,
        prefetch_only=prefetch_only,
        original=original,
        **overrides,
    )

Experiment

Experiment(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _SraRecord

An SRA experiment (SRX000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

An SRA experiment accession, e.g. "SRX5921017" (^[SED]RX\d+$, covering SRA/ENA/DDBJ).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None
sdl_client SdlClient or None

SRA Data Locator client its runs use for files.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed experiment accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

title property

title: str

The experiment title.

study property

study: str

The parent study accession (SRP…), or empty string if absent.

runs cached property

runs: list[Run]

The sequencing runs (SRR) of this experiment, as :class:Run instances.

Each run is read from this experiment's package (no extra fetch).

FastqRecord dataclass

FastqRecord(
    header: bytes, seq: bytes, plus: bytes, qual: bytes
)

One FASTQ record as its four raw byte lines (newlines stripped).

Congruent with a 4-line FASTQ record so a consumer can write mates back out verbatim with no re-parsing. The lines are stored without their trailing newline; :meth:to_bytes re-emits a canonical record.

Attributes:

Name Type Description
header bytes

The @-prefixed sequence identifier line (e.g. b"@SRR1.1.2 …").

seq bytes

The base sequence.

plus bytes

The + separator line.

qual bytes

The quality string.

to_bytes

to_bytes() -> bytes

Return this record as canonical 4-line FASTQ bytes (trailing newline).

Source code in src/labdata/geo/sratools.py
def to_bytes(self) -> bytes:
    """Return this record as canonical 4-line FASTQ bytes (trailing newline)."""
    return b"\n".join((self.header, self.seq, self.plus, self.qual)) + b"\n"

Platform

Platform(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _GdsRecord

A GEO Platform (GPL000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

A GEO Platform accession, e.g. "GPL16791" (^GPL\d+$).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed GEO Platform accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

sample_count property

sample_count: int | None

The number of samples GEO has on this platform, or None if unknown.

Run

Run(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _SraRecord

An SRA run (SRR000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

An SRA run accession, e.g. "SRR9000001" (^[SED]RR\d+$).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None
sdl_client SdlClient or None

SRA Data Locator client to use for files.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed run accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

total_spots property

total_spots: int | None

The number of spots (reads) in this run, or None if unreported.

total_bases property

total_bases: int | None

The number of bases in this run, or None if unreported.

is_public property

is_public: bool

Whether the run's data is publicly accessible.

read_lengths property

read_lengths: list[int]

The average length of each read (mate) sequenced per spot, in read order.

Reads SRA's per-run <Statistics>. A paired run yields two lengths, e.g. [28, 94]; a single-end run yields one. Empty when SRA reports no read statistics.

n_reads property

n_reads: int | None

The number of reads (mates) sequenced per spot, or None if unreported.

read_structure property

read_structure: str

The run's read structure as {L1}+{L2}+… (e.g. "28+94"), or "".

A compact rendering of :attr:read_lengths: each read's rounded average length joined with + in read order. Empty when the lengths are unknown.

experiment cached property

experiment: Experiment

The parent :class:Experiment (SRX) of this run, from the same package.

files cached property

files: list[RemoteFile]

Every file the SRA Data Locator lists for this run (one SDL request, cached).

Includes both the normalized SRA archive file(s) and any original/submitted files (e.g. a 10X Genomics possorted_genome_bam). This is the data behind the Run Browser's "Data access" panel. A live Run gives SDL's locations; a store-built one lists its stored XML's <SRAFiles>, with only the URLs the XML carries.

Returns:

Type Description
list of RemoteFile

All files listed for this run, each with its download locations.

Raises:

Type Description
SdlError

If the SDL request fails or the run is unknown/non-public.

Examples:

>>> [(f.name, f.type) for f in Run("SRR20172067").files]
[('possorted_genome_bam_TC2_d15_1.bam', 'TenX'), ('SRR20172067', 'sra')]

original_files property

original_files: list[RemoteFile]

The original/submitted files for this run (the "Original format" listing).

The submitter's own uploads — e.g. the 10X Genomics BAM behind a run whose .sra only stored a single read — as opposed to the normalized SRA archive. Filters :attr:files to the non-archive types.

Returns:

Type Description
list of RemoteFile

Files whose type is not a normalized SRA archive type; empty when the submission has no original-format files.

Examples:

>>> [f.name for f in Run("SRR20172067").original_files]
['possorted_genome_bam_TC2_d15_1.bam']

RunReadPreview dataclass

RunReadPreview(
    accession: str,
    n_spots_requested: int,
    n_spots_returned: int,
    reads: dict[int, list[FastqRecord]],
    read_lengths: dict[int, int],
    include_technical: bool,
)

The first N spots of a run's reads, bucketed by within-spot read index.

Returned by :func:stream_run_reads. reads maps a 1-based within-spot read index (the .N that fastq-dump --readids appends) to that mate's records in spot order — so a consumer sees the run's read structure (which mate is the cell barcode, which is cDNA) directly, without positional guessing.

Attributes:

Name Type Description
accession str

The run accession the preview is of (SRR…).

n_spots_requested int

The --maxSpotId bound passed to fastq-dump.

n_spots_returned int

The number of spots actually streamed (<= n_spots_requested; fewer when the run is shorter than the request).

reads dict[int, list[FastqRecord]]

Records per within-spot read index, in spot order.

read_lengths dict[int, int]

The modal sequence length of each read index (a compact view of geometry).

include_technical bool

Whether technical reads (e.g. a separate barcode read) were kept.

read_indexes

read_indexes() -> list[int]

Return the within-spot read indexes present, ascending.

Source code in src/labdata/geo/sratools.py
def read_indexes(self) -> list[int]:
    """Return the within-spot read indexes present, ascending."""
    return sorted(self.reads)

to_fastq_bytes

to_fastq_bytes(index: int) -> bytes

Return read index's records concatenated as FASTQ bytes.

Source code in src/labdata/geo/sratools.py
def to_fastq_bytes(self, index: int) -> bytes:
    """Return read ``index``'s records concatenated as FASTQ bytes."""
    return b"".join(record.to_bytes() for record in self.reads[index])

content_hash

content_hash(index: int) -> str

Return the sha256 of read index's FASTQ bytes (a reproducible slice id).

Source code in src/labdata/geo/sratools.py
def content_hash(self, index: int) -> str:
    """Return the sha256 of read ``index``'s FASTQ bytes (a reproducible slice id)."""
    return hashlib.sha256(self.to_fastq_bytes(index)).hexdigest()

Sample

Sample(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _GdsRecord

A GEO Sample (GSM000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

A GEO Sample accession, e.g. "GSM3827114" (^GSM\d+$).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed GEO Sample accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

series property

series: Series | None

The parent :class:Series, or None if the summary lists none.

platform property

platform: Platform | None

The :class:Platform this sample was run on, or None.

experiments cached property

experiments: list[Experiment]

The SRA experiments (SRX) for this sample, as :class:Experiment instances.

The union of elink and the sample's SOFT Sample_relation = SRA lines.

Series

Series(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _SraDownloadMixin, _GdsRecord

A GEO Series (GSE000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

A GEO Series accession, e.g. "GSE131907" (^GSE\d+$).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed GEO Series accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

summary property

summary: str

The Series summary/abstract text.

pubmed_id cached property

pubmed_id: str | None

The PubMed ID of the associated publication, or None.

samples cached property

samples: list[Sample]

The samples (GSM) belonging to this Series, as :class:Sample instances.

platforms cached property

platforms: list[Platform]

The platforms (GPL) this Series uses, as :class:Platform instances.

experiments cached property

experiments: list[Experiment]

The SRA experiments (SRX) linked to this Series, as :class:Experiment instances.

The union of elink and every sample's SOFT Sample_relation = SRA lines, read in one request for the whole Series.

bioprojects cached property

bioprojects: list[BioProject]

The BioProjects (PRJNA…) linked to this Series, as :class:BioProject instances.

One elink plus one batched esummary; each :class:BioProject is seeded with the UID and summary already fetched.

download

download(
    output_dir: str | Path = ".",
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    select_srx: Iterable[str] | None = None,
    original_srx: Iterable[str] | None = None,
    max_size: str | None = None,
    retries: int | None = None,
    backoff: float | None = None,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    verbose: bool = True,
) -> dict[str, bool]

Download FASTQ for every run of every SRA experiment in this record.

Lays the data out as <output_dir>/<accession>/<SRX>/<SRR>*.fastq.gz — one directory per experiment under a directory named for this record — via a Snakemake DAG (prefetch → extract → compress). Runs already marked done (a .<SRR>.success flag) are skipped, so this is safe to rerun on an unstable connection.

Concurrency is resource-aware: at most ncbi_parallel runs prefetch from NCBI at once (keeping the link busy without tripping throttling), while cores bounds total CPU across the local extraction/compression steps — so those two stages overlap instead of blocking each other.

Pass select_srx to download only some of the record's experiments (see below).

Parameters:

Name Type Description Default
output_dir str or Path

Parent directory; a <output_dir>/<accession> subtree is created for this record.

"."
ncbi_parallel int

Maximum runs to prefetch (the NCBI network step) concurrently. Keep this modest to avoid NCBI throttling; it is independent of cores.

:data:`DEFAULT_NCBI_PARALLEL`
cores int

Total CPU cores the DAG may use across all steps (defaults to the machine's CPU count). On an HPC allocation, pass the allotted cores (e.g. $SLURM_CPUS_ON_NODE).

None
threads_per_run int

Threads handed to fasterq-dump/pigz for each run.

:data:`DEFAULT_THREADS_PER_RUN`
select_srx iterable of str

A whitelist of SRA experiment accessions (SRX…). When given, only the record's experiments whose accession is listed are downloaded and every other experiment is skipped; None (the default) downloads them all. Entries are matched case-insensitively and every one must be a well-formed SRX accession — anything else raises :class:~labdata.exceptions.AccessionError.

None
original_srx iterable of str

A set of SRA experiment accessions (SRX…) to fetch in original format instead of the default sra-tools path: every run of a listed experiment has its submitter-uploaded files downloaded as-is (no fasterq-dump/gzip), because its .sra is missing or useless (e.g. a 10X run whose .sra stored only one read). This selects the mode; it does not by itself widen the selection — an experiment must still survive select_srx to be downloaded at all. Each entry must be a well-formed SRX accession or :class:~labdata.exceptions.AccessionError is raised.

None
max_size str

Passed to prefetch --max-size (defaults to :data:DEFAULT_MAX_SIZE); raise it for runs that exceed prefetch's 20G default.

None
retries int

Attempts for the resumable prefetch network step per run (defaults to :data:DEFAULT_RETRIES).

None
backoff float

Base seconds for the linear backoff between prefetch retries (defaults to :data:DEFAULT_BACKOFF).

None
keep_sra bool

Keep each run's downloaded .sra next to its FASTQ (as <accession>/<SRX>/<SRR>.sra) instead of deleting it after extraction.

False
prefetch_only bool

Only prefetch each run's .sra (into <SRX>/<SRR>/), skipping fasterq-dump, gzip, and cleanup. A later full download resumes from extraction without re-fetching.

False
verbose bool

Print a download plan up front and one line per finished run to stderr. Set False to download silently.

True

Returns:

Type Description
dict[str, bool]

Maps each run accession to whether its data is present on completion.

Examples:

>>> Series("GSE229022").download("./fastq", ncbi_parallel=3, cores=16)
{'SRR24084454': True, 'SRR24084455': True, ...}
>>> BioProject("PRJNA1027859").download("./fastq", ncbi_parallel=3)
{'SRR26452081': True, 'SRR26452082': True, ...}
>>> Series("GSE229022").download("./fastq", select_srx=["SRX19885398"])
{'SRR24084454': True}
Source code in src/labdata/geo/sratools.py
def download(
    self,
    output_dir: str | Path = ".",
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    select_srx: Iterable[str] | None = None,
    original_srx: Iterable[str] | None = None,
    max_size: str | None = None,
    retries: int | None = None,
    backoff: float | None = None,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    verbose: bool = True,
) -> dict[str, bool]:
    """Download FASTQ for every run of every SRA experiment in this record.

    Lays the data out as ``<output_dir>/<accession>/<SRX>/<SRR>*.fastq.gz`` —
    one directory per experiment under a directory named for this record — via
    a Snakemake DAG (``prefetch → extract → compress``). Runs already marked
    done (a ``.<SRR>.success`` flag) are skipped, so this is safe to rerun on
    an unstable connection.

    Concurrency is resource-aware: at most ``ncbi_parallel`` runs ``prefetch``
    from NCBI at once (keeping the link busy without tripping throttling), while
    ``cores`` bounds total CPU across the local extraction/compression steps —
    so those two stages overlap instead of blocking each other.

    Pass ``select_srx`` to download only some of the record's experiments (see
    below).

    Parameters
    ----------
    output_dir : str or Path, default "."
        Parent directory; a ``<output_dir>/<accession>`` subtree is created for
        this record.
    ncbi_parallel : int, default :data:`DEFAULT_NCBI_PARALLEL`
        Maximum runs to ``prefetch`` (the NCBI network step) concurrently. Keep
        this modest to avoid NCBI throttling; it is independent of ``cores``.
    cores : int, optional
        Total CPU cores the DAG may use across all steps (defaults to the
        machine's CPU count). On an HPC allocation, pass the allotted cores
        (e.g. ``$SLURM_CPUS_ON_NODE``).
    threads_per_run : int, default :data:`DEFAULT_THREADS_PER_RUN`
        Threads handed to ``fasterq-dump``/``pigz`` for each run.
    select_srx : iterable of str, optional
        A whitelist of SRA experiment accessions (``SRX…``). When given, only
        the record's experiments whose accession is listed are downloaded and
        every other experiment is skipped; ``None`` (the default) downloads
        them all. Entries are matched case-insensitively and every one must be
        a well-formed ``SRX`` accession — anything else raises
        :class:`~labdata.exceptions.AccessionError`.
    original_srx : iterable of str, optional
        A set of SRA experiment accessions (``SRX…``) to fetch in *original
        format* instead of the default sra-tools path: every run of a listed
        experiment has its submitter-uploaded files downloaded as-is (no
        ``fasterq-dump``/gzip), because its ``.sra`` is missing or useless
        (e.g. a 10X run whose ``.sra`` stored only one read). This selects the
        *mode*; it does not by itself widen the selection — an experiment must
        still survive ``select_srx`` to be downloaded at all. Each entry must
        be a well-formed ``SRX`` accession or :class:`~labdata.exceptions.AccessionError`
        is raised.
    max_size : str, optional
        Passed to ``prefetch --max-size`` (defaults to :data:`DEFAULT_MAX_SIZE`);
        raise it for runs that exceed ``prefetch``'s 20G default.
    retries : int, optional
        Attempts for the resumable ``prefetch`` network step per run (defaults
        to :data:`DEFAULT_RETRIES`).
    backoff : float, optional
        Base seconds for the linear backoff between ``prefetch`` retries
        (defaults to :data:`DEFAULT_BACKOFF`).
    keep_sra : bool, default False
        Keep each run's downloaded ``.sra`` next to its FASTQ (as
        ``<accession>/<SRX>/<SRR>.sra``) instead of deleting it after extraction.
    prefetch_only : bool, default False
        Only ``prefetch`` each run's ``.sra`` (into ``<SRX>/<SRR>/``), skipping
        ``fasterq-dump``, gzip, and cleanup. A later full download resumes from
        extraction without re-fetching.
    verbose : bool, default True
        Print a download plan up front and one line per finished run to
        ``stderr``. Set ``False`` to download silently.

    Returns
    -------
    dict[str, bool]
        Maps each run accession to whether its data is present on completion.

    Examples
    --------
    >>> Series("GSE229022").download("./fastq", ncbi_parallel=3, cores=16)  # doctest: +SKIP
    {'SRR24084454': True, 'SRR24084455': True, ...}
    >>> BioProject("PRJNA1027859").download("./fastq", ncbi_parallel=3)  # doctest: +SKIP
    {'SRR26452081': True, 'SRR26452082': True, ...}
    >>> Series("GSE229022").download("./fastq", select_srx=["SRX19885398"])  # doctest: +SKIP
    {'SRR24084454': True}
    """
    # ``None`` overrides fall through to the sra-tools defaults.
    overrides = {
        key: value
        for key, value in {"max_size": max_size, "retries": retries, "backoff": backoff}.items()
        if value is not None
    }
    # Original-format experiments select the download *mode*, not the selection.
    original_set = _validate_srx_whitelist(original_srx, field="original_srx")
    base = Path(output_dir) / self.accession
    tasks: list[tuple[Run, Path]] = []
    original: set[str] = set()
    for experiment in self._selected_experiments(select_srx):
        srx_dir = base / experiment.accession
        srx_dir.mkdir(parents=True, exist_ok=True)
        is_original = experiment.accession in original_set
        for run in experiment.runs:
            tasks.append((run, srx_dir))
            if is_original:
                original.add(run.accession)
    return _run_plan(
        self.accession,
        tasks,
        output_root=base,
        verbose=verbose,
        ncbi_parallel=ncbi_parallel,
        cores=cores,
        threads_per_run=threads_per_run,
        keep_sra=keep_sra,
        prefetch_only=prefetch_only,
        original=original,
        **overrides,
    )

make_sra_run_table

make_sra_run_table() -> pandas.DataFrame

Build a tidy, run-level (SRR) table for this Series, like SRA Run Selector.

Resolves the Series' SRA experiments and fetches NCBI's runinfo report in one batched EFetch per chunk, returning one row per sequencing run. The columns are SRA's runinfo fields (Run, BioSample, Experiment, LibraryName, Platform, …) with a few renamed to the Run-Selector names (AvgSpotLen, Bases, Instrument). The reported size is size_MB — E-utilities runinfo does not expose an exact byte count.

A ReadStructure column ({L1}+{L2}+…, e.g. "28+94") is added from each run's per-read <Statistics>, read from the full SRA XML with a second batched EFetch per chunk (see :attr:Run.read_structure); it is empty for runs SRA reports no read statistics for.

Returns:

Type Description
DataFrame

One row per run, key columns ordered first. Empty when the Series has no linked SRA runs.

Examples:

>>> table = Series("GSE229022").make_sra_run_table()
>>> table[["Run", "BioSample", "Instrument"]].head()
Source code in src/labdata/geo/geo_records.py
def make_sra_run_table(self) -> pandas.DataFrame:
    """Build a tidy, run-level (``SRR``) table for this Series, like SRA Run Selector.

    Resolves the Series' SRA experiments and fetches NCBI's ``runinfo`` report
    in one batched EFetch per chunk, returning one row per sequencing run. The
    columns are SRA's ``runinfo`` fields (``Run``, ``BioSample``, ``Experiment``,
    ``LibraryName``, ``Platform``, …) with a few renamed to the Run-Selector
    names (``AvgSpotLen``, ``Bases``, ``Instrument``). The reported size is
    ``size_MB`` — E-utilities ``runinfo`` does not expose an exact byte count.

    A ``ReadStructure`` column (``{L1}+{L2}+…``, e.g. ``"28+94"``) is added
    from each run's per-read ``<Statistics>``, read from the full SRA XML with
    a second batched EFetch per chunk (see :attr:`Run.read_structure`); it is
    empty for runs SRA reports no read statistics for.

    Returns
    -------
    pandas.DataFrame
        One row per run, key columns ordered first. Empty when the Series has
        no linked SRA runs.

    Examples
    --------
    >>> table = Series("GSE229022").make_sra_run_table()  # doctest: +SKIP
    >>> table[["Run", "BioSample", "Instrument"]].head()  # doctest: +SKIP
    """
    import pandas

    sra_uids = self.client.elink(dbfrom="gds", db="sra", uid=self.uid)
    frames: list[pandas.DataFrame] = []
    structures: dict[str, str] = {}
    for chunk in _chunked(sra_uids, _RUNINFO_CHUNK):
        text = self.client.efetch(db="sra", ids=chunk, rettype="runinfo", retmode="text")
        if text.strip():
            frames.append(pandas.read_csv(io.StringIO(text)))
        xml = self.client.efetch(db="sra", ids=chunk, rettype="full", retmode="xml")
        for accession, block in _run_blocks(xml).items():
            structures[accession] = _read_structure(_read_lengths(block))
    if not frames:
        return pandas.DataFrame()
    table = pandas.concat(frames, ignore_index=True).rename(columns=_RUNINFO_RENAME)
    table["ReadStructure"] = [structures.get(run, "") for run in table["Run"]]
    leading = [column for column in _RUN_TABLE_COLUMNS if column in table.columns]
    remaining = [column for column in table.columns if column not in leading]
    return table.reindex(columns=leading + remaining)

SraDownloader

SraDownloader(
    experiment: Experiment, output_dir: str | Path = "."
)

Download FASTQ for every run of one SRA experiment (SRX) with sra-tools.

Parameters:

Name Type Description Default
experiment Experiment

The SRA experiment whose runs (SRR) will be downloaded.

required
output_dir str or Path

Parent directory. A subdirectory named by the experiment accession (<output_dir>/<SRX>) is created and populated with one gzipped FASTQ set per run, plus a .<SRR>.success marker per finished run.

"."

Examples:

>>> from labdata import Experiment
>>> SraDownloader(Experiment("SRX5921017"), "./fastq").download(ncbi_parallel=2)
{'SRR9000001': True, 'SRR9000002': True}
Source code in src/labdata/geo/sratools.py
def __init__(self, experiment: Experiment, output_dir: str | Path = ".") -> None:
    self.experiment = experiment
    self.output_dir = Path(output_dir)

srx_dir property

srx_dir: Path

The destination directory for this experiment (<output_dir>/<SRX>).

__repr__

__repr__() -> str

Return an unambiguous representation.

Source code in src/labdata/geo/sratools.py
def __repr__(self) -> str:
    """Return an unambiguous representation."""
    return f"{type(self).__name__}({self.experiment.accession!r}, {str(self.output_dir)!r})"

download

download(
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    max_size: str = DEFAULT_MAX_SIZE,
    retries: int = DEFAULT_RETRIES,
    backoff: float = DEFAULT_BACKOFF,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    original: bool = False,
    verbose: bool = True,
) -> dict[str, bool]

Download every run of the experiment as a resource-aware Snakemake DAG.

Creates :attr:srx_dir, then downloads each run's gzipped FASTQ into it. Runs with an existing .<SRR>.done marker are skipped; a run that fails is recorded as False without aborting the others.

Pass original=True to fetch this experiment's original-format files instead — each run's submitter-uploaded files are downloaded as-is into <SRX>/<SRR>/ (no fasterq-dump/gzip), for runs whose .sra is missing or useless.

Parameters:

Name Type Description Default
ncbi_parallel int

Maximum runs to prefetch (the NCBI network step) concurrently; independent of cores. Keep it modest to avoid NCBI throttling.

:data:`DEFAULT_NCBI_PARALLEL`
cores int

Total CPU cores the DAG may use across all steps (defaults to the machine's CPU count).

None
threads_per_run int

Threads handed to fasterq-dump/pigz for each run.

:data:`DEFAULT_THREADS_PER_RUN`
max_size str

Passed to prefetch --max-size; raise it for large runs (prefetch otherwise refuses anything over its 20G default).

:data:`DEFAULT_MAX_SIZE`
retries int

Attempts for the resumable prefetch network step per run; a retry continues the partial download rather than restarting.

:data:`DEFAULT_RETRIES`
backoff float

Base seconds for the linear backoff between prefetch retries.

:data:`DEFAULT_BACKOFF`
keep_sra bool

Keep each run's downloaded .sra next to its FASTQ (as <SRX>/<SRR>.sra) instead of deleting it after extraction.

False
prefetch_only bool

Only prefetch each run's .sra (into <SRX>/<SRR>/), skipping fasterq-dump, gzip, and cleanup. A later full download resumes from extraction without re-fetching.

False
original bool

Fetch the experiment's original-format files (download only) instead of the prefetch → extract → compress chain; see above.

False
verbose bool

Print a download plan up front and one line per finished run to stderr. Set False to download silently.

True

Returns:

Type Description
dict[str, bool]

Maps each run accession to whether its data is present on completion.

Source code in src/labdata/geo/sratools.py
def download(
    self,
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    max_size: str = DEFAULT_MAX_SIZE,
    retries: int = DEFAULT_RETRIES,
    backoff: float = DEFAULT_BACKOFF,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    original: bool = False,
    verbose: bool = True,
) -> dict[str, bool]:
    """Download every run of the experiment as a resource-aware Snakemake DAG.

    Creates :attr:`srx_dir`, then downloads each run's gzipped FASTQ into it.
    Runs with an existing ``.<SRR>.done`` marker are skipped; a run that fails
    is recorded as ``False`` without aborting the others.

    Pass ``original=True`` to fetch this experiment's *original-format* files
    instead — each run's submitter-uploaded files are downloaded as-is into
    ``<SRX>/<SRR>/`` (no ``fasterq-dump``/gzip), for runs whose ``.sra`` is
    missing or useless.

    Parameters
    ----------
    ncbi_parallel : int, default :data:`DEFAULT_NCBI_PARALLEL`
        Maximum runs to ``prefetch`` (the NCBI network step) concurrently;
        independent of ``cores``. Keep it modest to avoid NCBI throttling.
    cores : int, optional
        Total CPU cores the DAG may use across all steps (defaults to the
        machine's CPU count).
    threads_per_run : int, default :data:`DEFAULT_THREADS_PER_RUN`
        Threads handed to ``fasterq-dump``/``pigz`` for each run.
    max_size : str, default :data:`DEFAULT_MAX_SIZE`
        Passed to ``prefetch --max-size``; raise it for large runs (``prefetch``
        otherwise refuses anything over its 20G default).
    retries : int, default :data:`DEFAULT_RETRIES`
        Attempts for the resumable ``prefetch`` network step per run; a retry
        continues the partial download rather than restarting.
    backoff : float, default :data:`DEFAULT_BACKOFF`
        Base seconds for the linear backoff between ``prefetch`` retries.
    keep_sra : bool, default False
        Keep each run's downloaded ``.sra`` next to its FASTQ (as
        ``<SRX>/<SRR>.sra``) instead of deleting it after extraction.
    prefetch_only : bool, default False
        Only ``prefetch`` each run's ``.sra`` (into ``<SRX>/<SRR>/``), skipping
        ``fasterq-dump``, gzip, and cleanup. A later full download resumes from
        extraction without re-fetching.
    original : bool, default False
        Fetch the experiment's original-format files (download only) instead of
        the ``prefetch → extract → compress`` chain; see above.
    verbose : bool, default True
        Print a download plan up front and one line per finished run to
        ``stderr``. Set ``False`` to download silently.

    Returns
    -------
    dict[str, bool]
        Maps each run accession to whether its data is present on completion.
    """
    srx_dir = self.srx_dir
    srx_dir.mkdir(parents=True, exist_ok=True)
    tasks = [(run, srx_dir) for run in self.experiment.runs]
    original_runs = {run.accession for run in self.experiment.runs} if original else set()
    # output_root is the parent of the SRX dir so the run->srx map (and the
    # Snakefile's ``{srx}`` wildcard) sees the experiment accession as a single
    # path component, matching the whole-record layout.
    return _run_plan(
        self.experiment.accession,
        tasks,
        output_root=self.output_dir,
        verbose=verbose,
        ncbi_parallel=ncbi_parallel,
        cores=cores,
        threads_per_run=threads_per_run,
        max_size=max_size,
        retries=retries,
        backoff=backoff,
        keep_sra=keep_sra,
        prefetch_only=prefetch_only,
        original=original_runs,
    )

FileLocation dataclass

FileLocation(service: str, region: str | None, link: str)

One place a :class:RemoteFile can be fetched from.

Parameters:

Name Type Description Default
service str

The hosting service, e.g. "s3", "gs", or "ncbi".

required
region str or None

The cloud region (e.g. "us-east-1"), or None when unspecified.

required
link str

The download URL or cloud URI for this location.

required

RemoteFile dataclass

RemoteFile(
    name: str,
    type: str,
    size: int | None,
    md5: str | None,
    modification_date: str | None,
    accession: str,
    locations: tuple[FileLocation, ...],
    supertype: str | None = None,
)

A single file SDL lists for a run, in any of its locations.

Parameters:

Name Type Description Default
name str

The file name, e.g. "possorted_genome_bam_TC2_d15_1.bam".

required
type str

SDL's file type, e.g. "sra" for the normalized archive or "TenX", "bam", "fastq", … for an original/submitted file.

required
size int or None

File size in bytes, or None when unreported.

required
md5 str or None

The file's MD5 checksum, or None when unreported.

required
modification_date str or None

The file's modification timestamp (ISO 8601 string), or None.

required
accession str

The run accession this file belongs to.

required
locations tuple of FileLocation

Every place the file can be fetched from, in SDL's order.

required
supertype str or None

The SRA package XML's supertype, such as "Original"; SDL gives none.

None

is_sra_archive property

is_sra_archive: bool

Whether this is a normalized SRA-archive file (vs. an original/submitted one).

url property

url: str

The first location's download link, or "" when there are none.

SDL lists the anonymous, worldwide-egress HTTPS link first, so this is the simplest URL to fetch the file from.

SdlClient

SdlClient(
    base_url: str = SDL_RETRIEVE_URL,
    *,
    timeout: float = 30.0,
)

Entry point to NCBI's SRA Data Locator.

Parameters:

Name Type Description Default
base_url str

The retrieve endpoint to query (default :data:SDL_RETRIEVE_URL).

SDL_RETRIEVE_URL
timeout float

Per-request socket timeout, in seconds.

30.0
Source code in src/labdata/ncbi/sdl.py
def __init__(self, base_url: str = SDL_RETRIEVE_URL, *, timeout: float = 30.0) -> None:
    self.base_url = base_url
    self.timeout = timeout
    # Per-instance response cache keyed by accession; redundant lookups across
    # records that share this client are served without a request.
    self._cache: dict[str, list[RemoteFile]] = {}

clear_cache

clear_cache() -> None

Drop all cached responses (forcing fresh requests on the next call).

Source code in src/labdata/ncbi/sdl.py
def clear_cache(self) -> None:
    """Drop all cached responses (forcing fresh requests on the next call)."""
    self._cache.clear()

retrieve

retrieve(accession: str) -> list[RemoteFile]

Return every file SDL lists for accession.

Parameters:

Name Type Description Default
accession str

An SRA run accession, e.g. "SRR20172067".

required

Returns:

Type Description
list of RemoteFile

Both the normalized SRA archive file(s) and any original/submitted files (e.g. a 10X possorted_genome_bam). Cached per client.

Raises:

Type Description
SdlError

If the request fails, the response is not valid JSON, or SDL reports a non-OK status for the accession (e.g. unknown or non-public run).

Source code in src/labdata/ncbi/sdl.py
def retrieve(self, accession: str) -> list[RemoteFile]:
    """Return every file SDL lists for ``accession``.

    Parameters
    ----------
    accession : str
        An SRA run accession, e.g. ``"SRR20172067"``.

    Returns
    -------
    list of RemoteFile
        Both the normalized SRA archive file(s) and any original/submitted
        files (e.g. a 10X ``possorted_genome_bam``). Cached per client.

    Raises
    ------
    SdlError
        If the request fails, the response is not valid JSON, or SDL reports a
        non-OK status for the accession (e.g. unknown or non-public run).
    """
    cached = self._cache.get(accession)
    if cached is None:
        payload = self._fetch(accession)
        cached = self._cache[accession] = _parse_files(payload, accession)
    return list(cached)

Store

Store(root: Path, settings: dict[str, Any])

An opened store, reading the build its marker named when it was opened.

Parameters:

Name Type Description Default
root Path

The store's root folder.

required
settings dict

The parsed labdata-store.toml.

required
Source code in src/labdata/store/root.py
def __init__(self, root: Path, settings: dict[str, Any]) -> None:
    self.root = root
    self.settings = settings
    self._reader: BuildReader | None = None

reader property

reader: BuildReader

The reader over the build the marker named at open, opened on first use.

Raises:

Type Description
NotAStoreError

If the marker names no build.

__repr__

__repr__() -> str

Return the root and the build.

Source code in src/labdata/store/root.py
def __repr__(self) -> str:
    """Return the root and the build."""
    return f"Store({str(self.root)!r}, build={self.settings.get('build')!r})"

open_many

open_many(
    accessions: Iterable[str],
) -> dict[str, Record | LabdataError]

Open many records: one record lookup and one link lookup for the whole batch.

Parameters:

Name Type Description Default
accessions iterable of str

Public accessions of any record type.

required

Returns:

Type Description
dict

Each accession, in input order, mapped to its Record or to the error that stopped it (AccessionError, NotMirrored or NotFound).

Source code in src/labdata/store/root.py
def open_many(self, accessions: Iterable[str]) -> dict[str, Record | LabdataError]:
    """Open many records: one record lookup and one link lookup for the whole batch.

    Parameters
    ----------
    accessions : iterable of str
        Public accessions of any record type.

    Returns
    -------
    dict
        Each accession, in input order, mapped to its ``Record`` or to the error that
        stopped it (``AccessionError``, ``NotMirrored`` or ``NotFound``).
    """
    order = [_norm(a) for a in accessions]
    out: dict[str, Record | LabdataError] = {}
    valid: list[str] = []
    for accession in order:
        try:
            kind = kind_of(accession)
            if kind == PAPER:
                raise AccessionError(
                    f"{accession} is a paper, not a record; follow 'PubMed' or 'DOI'"
                )
            if kind == ANNOTATION:
                raise AccessionError(
                    f"{accession} is a GEO named annotation, not a record; "
                    "follow 'Named Annotation'"
                )
            valid.append(accession)
        except AccessionError as err:
            out[accession] = err
    records = self.reader.records(valid) if valid else {}
    by_end = self._links(records) if records else {}
    absent = self._absent_errors(set(valid) - set(records))
    for accession in valid:
        record = records.get(accession)
        out[accession] = (
            absent[accession]
            if record is None
            else replace(record, links=tuple(by_end[accession]))
        )
    return {a: out[a] for a in order}

open

open(accession: str) -> Record

Open one record by public accession; open_many of one.

Raises:

Type Description
AccessionError

If the accession is malformed or names a paper.

NotMirrored

If a snapshot lists it but the store does not hold it.

NotFound

If no snapshot lists it as public. The message gives the snapshot date.

Source code in src/labdata/store/root.py
def open(self, accession: str) -> Record:
    """Open one record by public accession; ``open_many`` of one.

    Raises
    ------
    AccessionError
        If the accession is malformed or names a paper.
    NotMirrored
        If a snapshot lists it but the store does not hold it.
    NotFound
        If no snapshot lists it as public. The message gives the snapshot date.
    """
    [result] = self.open_many([accession]).values()
    if isinstance(result, LabdataError):
        raise result
    return result
links(
    accessions: str | Iterable[str],
    relation: str | None = None,
) -> dict[str, list[Link]]

Map each accession to its link rows as read from its end, in one reader call.

A far end may be an accession the store does not hold.

Parameters:

Name Type Description Default
accessions str or iterable of str

Accessions of any record type, PMID:<n> or DOI:<doi>.

required
relation str or None

Keep only links of this name as read from the given end, forward or reverse.

None

Returns:

Type Description
dict

Each accession, in input order, mapped to its links sorted by relation, far end and source.

Raises:

Type Description
AccessionError

If an accession is malformed.

ValueError

If the relation name is unknown.

Source code in src/labdata/store/root.py
def links(
    self, accessions: str | Iterable[str], relation: str | None = None
) -> dict[str, list[Link]]:
    """Map each accession to its link rows as read from its end, in one reader call.

    A far end may be an accession the store does not hold.

    Parameters
    ----------
    accessions : str or iterable of str
        Accessions of any record type, ``PMID:<n>`` or ``DOI:<doi>``.
    relation : str or None
        Keep only links of this name as read from the given end, forward or reverse.

    Returns
    -------
    dict
        Each accession, in input order, mapped to its links sorted by relation, far end
        and source.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    ValueError
        If the relation name is unknown.
    """
    accs = _accessions(accessions)
    relations = None if relation is None else [forward_name(relation)]
    found = self._links(set(accs), relations)
    return {
        a: [k for k in found[a] if relation is None or k.relation == relation] for a in accs
    }

follow

follow(
    accessions: str | Iterable[str], relation: str
) -> dict[str, list[str]]

Map each accession to the sorted accessions at the far end of one relation.

Reads links only, in one reader call, so a far end may be an accession the store does not hold. relation takes a forward or a reverse name.

Raises:

Type Description
AccessionError

If an accession is malformed.

ValueError

If the relation name is unknown.

Source code in src/labdata/store/root.py
def follow(self, accessions: str | Iterable[str], relation: str) -> dict[str, list[str]]:
    """Map each accession to the sorted accessions at the far end of one relation.

    Reads links only, in one reader call, so a far end may be an accession the store
    does not hold. ``relation`` takes a forward or a reverse name.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    ValueError
        If the relation name is unknown.
    """
    return {
        a: sorted({k.other for k in links})
        for a, links in self.links(accessions, relation).items()
    }

raw

raw(accessions: Iterable[str]) -> dict[str, RawText]

Map each accession to its raw source text and content_hash, in one call.

Raises:

Type Description
AccessionError

If an accession is malformed.

NotMirrored

If a snapshot lists an accession the store does not hold. The message lists every such accession.

NotFound

If no snapshot lists an accession as public. The message gives the snapshot date.

Source code in src/labdata/store/root.py
def raw(self, accessions: Iterable[str]) -> dict[str, RawText]:
    """Map each accession to its raw source text and ``content_hash``, in one call.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    NotMirrored
        If a snapshot lists an accession the store does not hold. The message lists
        every such accession.
    NotFound
        If no snapshot lists an accession as public. The message gives the snapshot date.
    """
    wanted = set(_accessions(accessions))
    texts = self.reader.raw(wanted) if wanted else {}
    self._raise_absent(wanted - set(texts))
    return dict(sorted(texts.items()))

supplementary_files

supplementary_files(
    accessions: Iterable[str],
) -> dict[str, list[SupplementaryFile]]

Map each accession to the supplementary files its SOFT lists, in two reader calls.

A Series lists its own files; its samples' files are under the samples. An accession the store holds with no files maps to an empty list.

Parameters:

Name Type Description Default
accessions iterable of str

GEO accessions, in the order wanted back.

required

Returns:

Type Description
dict

Each accession, in input order, mapped to its files sorted by name.

Raises:

Type Description
AccessionError

If an accession is malformed.

NotMirrored

If a snapshot lists an accession the store does not hold. The message lists every such accession.

NotFound

If no snapshot lists an accession as public. The message gives the snapshot date.

Source code in src/labdata/store/root.py
def supplementary_files(self, accessions: Iterable[str]) -> dict[str, list[SupplementaryFile]]:
    """Map each accession to the supplementary files its SOFT lists, in two reader calls.

    A Series lists its own files; its samples' files are under the samples. An
    accession the store holds with no files maps to an empty list.

    Parameters
    ----------
    accessions : iterable of str
        GEO accessions, in the order wanted back.

    Returns
    -------
    dict
        Each accession, in input order, mapped to its files sorted by name.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    NotMirrored
        If a snapshot lists an accession the store does not hold. The message lists
        every such accession.
    NotFound
        If no snapshot lists an accession as public. The message gives the snapshot date.
    """
    order = _accessions(accessions)
    self._require_held(set(order))
    out: dict[str, list[SupplementaryFile]] = {a: [] for a in order}
    for file in self.reader.supplementary_files(set(order)) if order else []:
        out[file.owner].append(file)
    return out

experiments_for_many

experiments_for_many(
    accessions: Iterable[str],
    *,
    partial: Literal[False] = False,
) -> dict[str, list[Experiment]]
experiments_for_many(
    accessions: Iterable[str], *, partial: Literal[True]
) -> Partial[dict[str, list[Experiment]]]
experiments_for_many(
    accessions: Iterable[str], *, partial: bool = False
) -> (
    dict[str, list[Experiment]]
    | Partial[dict[str, list[Experiment]]]
)

Map each accession to the SRA experiments under it, built from stored packages.

Walks down one level at a time, with one link lookup and one holdings lookup per level for all roots together.

Parameters:

Name Type Description Default
accessions iterable of str

The roots of the walk.

required
partial bool

Return what is held instead of raising NotMirrored.

False

Returns:

Type Description
dict or Partial

Each root mapped to its sorted experiments. With partial=True, a Partial whose held is that mapping over held records and whose missing lists every accession the walk reached but the store does not hold.

Raises:

Type Description
AccessionError

If an accession is malformed.

NotFound

If no snapshot lists a root as public, even with partial.

NotMirrored

If a root or a record on the way down is not held, unless partial. The message lists every missing accession.

Source code in src/labdata/store/root.py
def experiments_for_many(
    self, accessions: Iterable[str], *, partial: bool = False
) -> dict[str, list[Experiment]] | Partial[dict[str, list[Experiment]]]:
    """Map each accession to the SRA experiments under it, built from stored packages.

    Walks down one level at a time, with one link lookup and one holdings lookup per
    level for all roots together.

    Parameters
    ----------
    accessions : iterable of str
        The roots of the walk.
    partial : bool
        Return what is held instead of raising ``NotMirrored``.

    Returns
    -------
    dict or Partial
        Each root mapped to its sorted experiments. With ``partial=True``, a ``Partial``
        whose ``held`` is that mapping over held records and whose ``missing`` lists every
        accession the walk reached but the store does not hold.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    NotFound
        If no snapshot lists a root as public, even with ``partial``.
    NotMirrored
        If a root or a record on the way down is not held, unless ``partial``. The
        message lists every missing accession.
    """
    roots = _accessions(accessions)
    missing = self._require_held(set(roots), not_held_ok=True)
    found: dict[str, set[str]] = {r: set() for r in roots}
    level = {(r, r) for r in roots if r not in missing}
    seen: set[tuple[str, str]] = set()
    while level:
        seen |= level
        todo = set()
        for root, acc in level:
            if kind_of(acc) == "SRA experiment":
                found[root].add(acc)
            else:
                todo.add((root, acc))
        if not todo:
            break
        children: dict[str, set[str]] = {}
        for row in self.reader.links({acc for _, acc in todo}):
            for end, link in _ends(row):
                if _leads_down(kind_of(end), link):
                    children.setdefault(end, set()).add(link.other)
        reached = {c for cs in children.values() for c in cs}
        held = self.reader.held(reached) if reached else set()
        missing |= reached - held
        level = {
            (root, child)
            for root, acc in todo
            for child in children.get(acc, ())
            if child in held
        } - seen
    if missing and not partial:
        raise NotMirrored(f"not held in this store: {', '.join(sorted(missing))}")
    wanted = {acc for accs in found.values() for acc in accs}
    packages = {a: raw.text for a, raw in (self.reader.raw(wanted) if wanted else {}).items()}
    built = {
        experiment.accession: experiment
        for experiment in experiments_from_package_xml(
            "".join(packages.values()),
            client=OfflineEntrezClient(),
            sdl_client=StoredSdlClient(packages),
        )
    }
    result = {r: [built[a] for a in sorted(found[r]) if a in built] for r in roots}
    return Partial(result, tuple(sorted(missing))) if partial else result

experiments_for

experiments_for(
    accession: str, *, partial: Literal[False] = False
) -> list[Experiment]
experiments_for(
    accession: str, *, partial: Literal[True]
) -> Partial[list[Experiment]]
experiments_for(
    accession: str, *, partial: bool = False
) -> list[Experiment] | Partial[list[Experiment]]

Return the SRA experiments under one accession, sorted, with no network call.

The store twin of :func:labdata.experiments_for: the same Experiment type, built from the stored package XML by the same parser. Their properties, and their runs', never call NCBI. Run.files and Run.original_files list the <SRAFiles> entries of the stored XML, with only the URLs it carries; a live Run gives the SRA Data Locator's locations. With partial=True it returns a Partial of that list and the missing accessions.

Raises:

Type Description
AccessionError

If the accession is malformed.

NotFound

If no snapshot lists the accession as public.

NotMirrored

If the accession, or a record under it, is not held (unless partial).

Source code in src/labdata/store/root.py
def experiments_for(
    self, accession: str, *, partial: bool = False
) -> list[Experiment] | Partial[list[Experiment]]:
    """Return the SRA experiments under one accession, sorted, with no network call.

    The store twin of :func:`labdata.experiments_for`: the same ``Experiment`` type,
    built from the stored package XML by the same parser. Their properties, and their
    runs', never call NCBI. ``Run.files`` and ``Run.original_files`` list the
    ``<SRAFiles>`` entries of the stored XML, with only the URLs it carries; a live
    ``Run`` gives the SRA Data Locator's locations.
    With ``partial=True`` it returns a ``Partial`` of that list and the missing
    accessions.

    Raises
    ------
    AccessionError
        If the accession is malformed.
    NotFound
        If no snapshot lists the accession as public.
    NotMirrored
        If the accession, or a record under it, is not held (unless ``partial``).
    """
    key = _norm(accession)
    if partial:
        walk = self.experiments_for_many([accession], partial=True)
        return Partial(walk.held[key], walk.missing)
    return self.experiments_for_many([accession])[key]

table

table(
    type: str,
    fields: Sequence[str],
    where: str | None = None,
) -> pa.Table

Return one record type as a pyarrow.Table, sorted by accession, in one call.

The only call that imports pyarrow.

Parameters:

Name Type Description Default
type str

A record type, such as "GEO Series" or "SRA experiment".

required
fields sequence of str

Raw field names. Each becomes a list column of every value, in source order.

required
where str or None

A DuckDB condition over the columns, such as "published >= '2020-01-01'". It is trusted SQL from the caller, pasted into the query as written.

None

Returns:

Type Description
Table

accession, type, status, submitted, updated, published, content_hash and snapshot_date, then one column per field.

Raises:

Type Description
ValueError

If the record type is unknown, or where holds a ;.

Source code in src/labdata/store/root.py
def table(self, type: str, fields: Sequence[str], where: str | None = None) -> pa.Table:
    """Return one record type as a ``pyarrow.Table``, sorted by accession, in one call.

    The only call that imports pyarrow.

    Parameters
    ----------
    type : str
        A record type, such as ``"GEO Series"`` or ``"SRA experiment"``.
    fields : sequence of str
        Raw field names. Each becomes a list column of every value, in source order.
    where : str or None
        A DuckDB condition over the columns, such as ``"published >= '2020-01-01'"``.
        It is trusted SQL from the caller, pasted into the query as written.

    Returns
    -------
    pyarrow.Table
        ``accession``, ``type``, ``status``, ``submitted``, ``updated``, ``published``,
        ``content_hash`` and ``snapshot_date``, then one column per field.

    Raises
    ------
    ValueError
        If the record type is unknown, or ``where`` holds a ``;``.
    """
    if type not in _SNAPSHOT_OF:
        raise ValueError(f"unknown record type {type!r}; known: {sorted(_SNAPSHOT_OF)}")
    if where is not None and ";" in where:
        raise ValueError(f"where must be one condition, without ';': {where!r}")
    return self.reader.table(type, list(fields), where)

TenxConverter

TenxConverter(
    accession_or_record: str | Series | BioProject,
    output_dir: str | Path = ".",
)

Convert a project's 10x cellranger BAMs to FASTQ with bamtofastq.

Drives the tenx Snakemake DAG over every 10x-BAM run of a GEO Series or BioProject: for each run whose original files include a cellranger BAM (see :func:is_tenx_bam_run), the already-downloaded BAM is converted to FASTQ laid out like the download pipeline (<accession>/<SRX>/<SRR>_S1_L00N_R{1,2}_00N.fastq.gz). The BAMs must already be on disk from a prior original-format download (labdata geo download … --original-srx); this step does not fetch them.

Parameters:

Name Type Description Default
accession_or_record str, Series, or BioProject

The project to convert. A GSE… / PRJ… accession is resolved to the matching record; a record is used as-is.

required
output_dir str or Path

Parent directory; the <output_dir>/<accession> subtree (the same one the original-format download wrote the BAMs into) is read from and written to.

"."

Examples:

>>> TenxConverter("GSE208154", "./data").convert(cores=16)
{'SRR20172067': True, ...}
Source code in src/labdata/tenx/bamtofastq.py
def __init__(
    self, accession_or_record: str | Series | BioProject, output_dir: str | Path = "."
) -> None:
    self.record = _as_record(accession_or_record)
    self.output_dir = Path(output_dir)

__repr__

__repr__() -> str

Return an unambiguous representation.

Source code in src/labdata/tenx/bamtofastq.py
def __repr__(self) -> str:
    """Return an unambiguous representation."""
    return f"{type(self).__name__}({self.record.accession!r}, {str(self.output_dir)!r})"

convert

convert(
    *,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    reads_per_fastq: int = DEFAULT_READS_PER_FASTQ,
    select_srx: Iterable[str] | None = None,
    all_runs: bool = False,
    from_disk: bool = False,
    remove_bam: bool = False,
    verbose: bool = True,
) -> dict[str, bool]

Convert every 10x-BAM run of this record's experiments to FASTQ.

Auto-detects which runs carry a cellranger BAM (:func:is_tenx_bam_run) and converts each via a Snakemake DAG, laying the FASTQ out as <output_dir>/<accession>/<SRX>/<SRR>_S1_L00N_R{1,2}_00N.fastq.gz. Runs already marked done (a .<SRR>.tenx.done marker) are skipped, so this is safe to rerun.

Parameters:

Name Type Description Default
cores int

Total CPU cores the DAG may use (defaults to the machine's CPU count). On an HPC allocation, pass the allotted cores (e.g. $SLURM_CPUS_ON_NODE).

None
threads_per_run int

Threads handed to bamtofastq per run (--nthreads).

:data:`DEFAULT_THREADS_PER_RUN`
reads_per_fastq int

Reads per output FASTQ chunk (bamtofastq --reads-per-fastq).

:data:`DEFAULT_READS_PER_FASTQ`
select_srx iterable of str

A whitelist of SRA experiment accessions (SRX…). When given, only those experiments are considered; None (the default) considers them all. Each entry must be a well-formed SRX accession or :class:AccessionError is raised.

None
all_runs bool

Convert every run of the selected experiments, skipping the 10x-BAM auto-detection. Use when you know all runs carry a cellranger BAM (e.g. to avoid the extra SDL lookups) or to force conversion of a run SDL does not flag.

False
from_disk bool

Build the task list by scanning the on-disk tree (<output_dir>/<accession>/<SRX>/<SRR>_*.bam) instead of resolving the record through NCBI. This needs no network or NCBI credentials — the point when converting already-downloaded BAMs on an offline HPC compute node. select_srx still filters by experiment; all_runs and the SDL auto-detection do not apply (a BAM's presence on disk is the signal).

False
remove_bam bool

Delete each run's source BAM once its FASTQ are produced, reclaiming the large original. Deletion happens only after a run converts successfully.

False
verbose bool

Print a conversion plan up front and a summary to stderr. Set False to run silently.

True

Returns:

Type Description
dict[str, bool]

Maps each converted run accession to whether its FASTQ are present on completion (empty if there are no 10x-BAM runs).

Source code in src/labdata/tenx/bamtofastq.py
def convert(
    self,
    *,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    reads_per_fastq: int = DEFAULT_READS_PER_FASTQ,
    select_srx: Iterable[str] | None = None,
    all_runs: bool = False,
    from_disk: bool = False,
    remove_bam: bool = False,
    verbose: bool = True,
) -> dict[str, bool]:
    """Convert every 10x-BAM run of this record's experiments to FASTQ.

    Auto-detects which runs carry a cellranger BAM (:func:`is_tenx_bam_run`) and
    converts each via a Snakemake DAG, laying the FASTQ out as
    ``<output_dir>/<accession>/<SRX>/<SRR>_S1_L00N_R{1,2}_00N.fastq.gz``. Runs
    already marked done (a ``.<SRR>.tenx.done`` marker) are skipped, so this is safe
    to rerun.

    Parameters
    ----------
    cores : int, optional
        Total CPU cores the DAG may use (defaults to the machine's CPU count). On an
        HPC allocation, pass the allotted cores (e.g. ``$SLURM_CPUS_ON_NODE``).
    threads_per_run : int, default :data:`DEFAULT_THREADS_PER_RUN`
        Threads handed to ``bamtofastq`` per run (``--nthreads``).
    reads_per_fastq : int, default :data:`DEFAULT_READS_PER_FASTQ`
        Reads per output FASTQ chunk (``bamtofastq --reads-per-fastq``).
    select_srx : iterable of str, optional
        A whitelist of SRA experiment accessions (``SRX…``). When given, only those
        experiments are considered; ``None`` (the default) considers them all. Each
        entry must be a well-formed ``SRX`` accession or :class:`AccessionError` is
        raised.
    all_runs : bool, default False
        Convert every run of the selected experiments, skipping the 10x-BAM
        auto-detection. Use when you know all runs carry a cellranger BAM (e.g. to
        avoid the extra SDL lookups) or to force conversion of a run SDL does not
        flag.
    from_disk : bool, default False
        Build the task list by scanning the on-disk tree
        (``<output_dir>/<accession>/<SRX>/<SRR>_*.bam``) instead of resolving the
        record through NCBI. This needs no network or NCBI credentials — the point
        when converting already-downloaded BAMs on an offline HPC compute node.
        ``select_srx`` still filters by experiment; ``all_runs`` and the SDL
        auto-detection do not apply (a BAM's presence on disk is the signal).
    remove_bam : bool, default False
        Delete each run's source BAM once its FASTQ are produced, reclaiming the
        large original. Deletion happens only after a run converts successfully.
    verbose : bool, default True
        Print a conversion plan up front and a summary to ``stderr``. Set ``False``
        to run silently.

    Returns
    -------
    dict[str, bool]
        Maps each converted run accession to whether its FASTQ are present on
        completion (empty if there are no 10x-BAM runs).
    """
    base = self.output_dir / self.record.accession
    if from_disk:
        tasks = _tasks_from_disk(base, _validate_srx_whitelist(select_srx))
    else:
        tasks = [
            (run, base / experiment.accession)
            for experiment in self._selected_experiments(select_srx)
            for run in experiment.runs
            if all_runs or is_tenx_bam_run(run)
        ]
    for srx_dir in {srx_dir for _, srx_dir in tasks}:
        srx_dir.mkdir(parents=True, exist_ok=True)
    return _run_conversion(
        self.record.accession,
        tasks,
        output_root=base,
        cores=cores,
        threads_per_run=threads_per_run,
        reads_per_fastq=reads_per_fastq,
        remove_bam=remove_bam,
        verbose=verbose,
    )

experiments_for

experiments_for(
    accession: str, *, client: EntrezClient | None = None
) -> list[Experiment]

Resolve any GEO/SRA/BioProject accession to the SRA experiments under it.

Parameters:

Name Type Description Default
accession str

A GEO Series (GSE), GEO Sample (GSM), BioProject (PRJNA/ PRJEB/PRJDB), SRA study (SRP), SRA sample (SRS), BioSample (SAMN/SAMEA/SAMD), SRA experiment (SRX) or SRA run (SRR) accession. Case-insensitive; whitespace is stripped.

required
client EntrezClient or None

Entrez client to share across the resolved records; a default one is built on first network access when None.

None

Returns:

Type Description
list of Experiment

The de-duplicated :class:~labdata.geo.sra_records.Experiment instances under accession (each sharing client), or an empty list when the record links to no SRA data (e.g. an unreleased or metadata-only record).

Raises:

Type Description
AccessionError

If accession is not a recognized GEO/SRA/BioProject accession.

Examples:

>>> [e.accession for e in experiments_for("GSE229022")]
['SRX19885398', 'SRX19885399', ...]
Source code in src/labdata/geo/dispatch.py
def experiments_for(accession: str, *, client: EntrezClient | None = None) -> list[Experiment]:
    r"""Resolve any GEO/SRA/BioProject accession to the SRA experiments under it.

    Parameters
    ----------
    accession : str
        A GEO Series (``GSE``), GEO Sample (``GSM``), BioProject (``PRJNA``/
        ``PRJEB``/``PRJDB``), SRA study (``SRP``), SRA sample (``SRS``), BioSample
        (``SAMN``/``SAMEA``/``SAMD``), SRA experiment (``SRX``) or SRA run
        (``SRR``) accession. Case-insensitive; whitespace is stripped.
    client : EntrezClient or None
        Entrez client to share across the resolved records; a default one is built
        on first network access when ``None``.

    Returns
    -------
    list of Experiment
        The de-duplicated :class:`~labdata.geo.sra_records.Experiment` instances
        under ``accession`` (each sharing ``client``), or an empty list when the
        record links to no SRA data (e.g. an unreleased or metadata-only record).

    Raises
    ------
    AccessionError
        If ``accession`` is not a recognized GEO/SRA/BioProject accession.

    Examples
    --------
    >>> [e.accession for e in experiments_for("GSE229022")]  # doctest: +SKIP
    ['SRX19885398', 'SRX19885399', ...]
    """
    if not isinstance(accession, str):
        raise AccessionError(f"not a resolvable accession: {accession!r}")
    acc = accession.strip().upper()

    if _GSE.match(acc):
        return Series(acc, client=client).experiments
    if _GSM.match(acc):
        return Sample(acc, client=client).experiments
    if _PRJ.match(acc):
        return BioProject(acc, client=client).experiments
    if _SRX.match(acc):
        return [Experiment(acc, client=client)]
    if _SRR.match(acc):
        return [Run(acc, client=client).experiment]
    if _SEARCHABLE.match(acc):
        client = client or EntrezClient()
        sra_uids = client.esearch(db="sra", term=acc, retmax=_SRA_SEARCH_RETMAX)
        return _experiments_from_ids(client, sra_uids)

    raise AccessionError(
        f"not a resolvable accession: {accession!r}. Known kinds: GSE, GSM, "
        "PRJNA/PRJEB/PRJDB, SRP/ERP/DRP, SRS/ERS/DRS, SAMN/SAMEA/SAMD, "
        "SRX/ERX/DRX, SRR/ERR/DRR."
    )

iter_run_reads

iter_run_reads(
    run: Run | str,
    *,
    n_spots: int = DEFAULT_PREVIEW_SPOTS,
    include_technical: bool = True,
    retries: int = DEFAULT_RETRIES,
    backoff: float = DEFAULT_BACKOFF,
) -> Iterator[tuple[int, FastqRecord]]

Stream the first n_spots spots of a run as (read_index, record) pairs.

The streaming building block behind :func:stream_run_reads: it shells out once (see :func:_dump_spots) and yields every read of every streamed spot in emission order, each paired with its within-spot read index. Nothing is written to disk.

Parameters:

Name Type Description Default
run Run or str

The run to preview, as a :class:Run or a run accession string.

required
n_spots int

Upper bound on spots to stream (fastq-dump --maxSpotId).

:data:`DEFAULT_PREVIEW_SPOTS`
include_technical bool

Keep technical reads (e.g. a separate cell-barcode read). Set False to pass --skip-technical.

True
retries int

Attempts for the (network-backed) fastq-dump stream before giving up.

:data:`DEFAULT_RETRIES`
backoff float

Base seconds for the linear backoff between retries.

:data:`DEFAULT_BACKOFF`

Yields:

Type Description
tuple[int, FastqRecord]

The within-spot read index (1-based) and the record.

Raises:

Type Description
DownloadError

If fastq-dump is missing or every attempt fails.

AccessionError

If run is a string that is not a well-formed run accession.

Source code in src/labdata/geo/sratools.py
def iter_run_reads(
    run: Run | str,
    *,
    n_spots: int = DEFAULT_PREVIEW_SPOTS,
    include_technical: bool = True,
    retries: int = DEFAULT_RETRIES,
    backoff: float = DEFAULT_BACKOFF,
) -> Iterator[tuple[int, FastqRecord]]:
    """Stream the first ``n_spots`` spots of a run as ``(read_index, record)`` pairs.

    The streaming building block behind :func:`stream_run_reads`: it shells out once
    (see :func:`_dump_spots`) and yields every read of every streamed spot in emission
    order, each paired with its within-spot read index. Nothing is written to disk.

    Parameters
    ----------
    run : Run or str
        The run to preview, as a :class:`Run` or a run accession string.
    n_spots : int, default :data:`DEFAULT_PREVIEW_SPOTS`
        Upper bound on spots to stream (``fastq-dump --maxSpotId``).
    include_technical : bool, default True
        Keep technical reads (e.g. a separate cell-barcode read). Set ``False`` to
        pass ``--skip-technical``.
    retries : int, default :data:`DEFAULT_RETRIES`
        Attempts for the (network-backed) ``fastq-dump`` stream before giving up.
    backoff : float, default :data:`DEFAULT_BACKOFF`
        Base seconds for the linear backoff between retries.

    Yields
    ------
    tuple[int, FastqRecord]
        The within-spot read index (1-based) and the record.

    Raises
    ------
    DownloadError
        If ``fastq-dump`` is missing or every attempt fails.
    AccessionError
        If ``run`` is a string that is not a well-formed run accession.
    """
    accession = _accession_of(run)
    stdout = _dump_spots(
        accession,
        n_spots=n_spots,
        include_technical=include_technical,
        retries=retries,
        backoff=backoff,
    )
    yield from _parse_interleaved(stdout)

stream_run_reads

stream_run_reads(
    run: Run | str,
    *,
    n_spots: int = DEFAULT_PREVIEW_SPOTS,
    include_technical: bool = True,
    retries: int = DEFAULT_RETRIES,
    backoff: float = DEFAULT_BACKOFF,
) -> RunReadPreview

Stream the first n_spots spots of a run into a :class:RunReadPreview.

A bounded, in-memory head of a run — enough to fingerprint its chemistry, never the whole file. Reads are bucketed by within-spot read index (from the .N tag --readids appends), so the caller sees the run's read structure directly. No FASTQ, .sra, or cache is written: everything the tool produces lives in memory or a temporary directory reclaimed before this returns.

Parameters:

Name Type Description Default
run Run or str

The run to preview, as a :class:Run or a run accession string.

required
n_spots int

Upper bound on spots to stream.

:data:`DEFAULT_PREVIEW_SPOTS`
include_technical bool

Keep technical reads (the default; a chemistry fingerprint needs the barcode read). Set False to pass --skip-technical.

True
retries int

Attempts for the fastq-dump stream before giving up.

:data:`DEFAULT_RETRIES`
backoff float

Base seconds for the linear backoff between retries.

:data:`DEFAULT_BACKOFF`

Returns:

Type Description
RunReadPreview

The streamed reads bucketed by read index, with per-index modal lengths and the requested/returned spot counts.

Raises:

Type Description
DownloadError

If fastq-dump is missing or every attempt fails.

AccessionError

If run is a string that is not a well-formed run accession.

Examples:

>>> preview = stream_run_reads("SRR9000001", n_spots=2000)
>>> preview.read_indexes()
[1, 2]
>>> preview.read_lengths
{1: 28, 2: 94}
Source code in src/labdata/geo/sratools.py
def stream_run_reads(
    run: Run | str,
    *,
    n_spots: int = DEFAULT_PREVIEW_SPOTS,
    include_technical: bool = True,
    retries: int = DEFAULT_RETRIES,
    backoff: float = DEFAULT_BACKOFF,
) -> RunReadPreview:
    """Stream the first ``n_spots`` spots of a run into a :class:`RunReadPreview`.

    A bounded, in-memory head of a run — enough to fingerprint its chemistry, never
    the whole file. Reads are bucketed by within-spot read index (from the ``.N``
    tag ``--readids`` appends), so the caller sees the run's read structure directly.
    No FASTQ, ``.sra``, or cache is written: everything the tool produces lives in
    memory or a temporary directory reclaimed before this returns.

    Parameters
    ----------
    run : Run or str
        The run to preview, as a :class:`Run` or a run accession string.
    n_spots : int, default :data:`DEFAULT_PREVIEW_SPOTS`
        Upper bound on spots to stream.
    include_technical : bool, default True
        Keep technical reads (the default; a chemistry fingerprint needs the barcode
        read). Set ``False`` to pass ``--skip-technical``.
    retries : int, default :data:`DEFAULT_RETRIES`
        Attempts for the ``fastq-dump`` stream before giving up.
    backoff : float, default :data:`DEFAULT_BACKOFF`
        Base seconds for the linear backoff between retries.

    Returns
    -------
    RunReadPreview
        The streamed reads bucketed by read index, with per-index modal lengths and
        the requested/returned spot counts.

    Raises
    ------
    DownloadError
        If ``fastq-dump`` is missing or every attempt fails.
    AccessionError
        If ``run`` is a string that is not a well-formed run accession.

    Examples
    --------
    >>> preview = stream_run_reads("SRR9000001", n_spots=2000)  # doctest: +SKIP
    >>> preview.read_indexes()  # doctest: +SKIP
    [1, 2]
    >>> preview.read_lengths  # doctest: +SKIP
    {1: 28, 2: 94}
    """
    accession = _accession_of(run)
    reads: dict[int, list[FastqRecord]] = {}
    for index, record in iter_run_reads(
        run,
        n_spots=n_spots,
        include_technical=include_technical,
        retries=retries,
        backoff=backoff,
    ):
        reads.setdefault(index, []).append(record)
    read_lengths = {index: _modal_length(records) for index, records in reads.items()}
    # Every spot emits its first read, so read index 1's count is the spot count; fall
    # back to the largest bucket if index 1 was filtered out (all-technical + skip).
    n_returned = len(reads.get(1, [])) or max((len(r) for r in reads.values()), default=0)
    return RunReadPreview(
        accession=accession,
        n_spots_requested=n_spots,
        n_spots_returned=n_returned,
        reads=reads,
        read_lengths=read_lengths,
        include_technical=include_technical,
    )

open_store

open_store(
    root: str | PathLike[str] | None = None,
) -> Store

Open the store, resolving its root without network or prompt.

The root is the first of: the root argument, LABDATA_STORE, then the per-user pointer file written by labdata store configure. Opening writes nothing in the store.

Parameters:

Name Type Description Default
root str or PathLike or None

The store root. None falls back to the environment, then the pointer file.

None

Returns:

Type Description
Store

The opened store.

Raises:

Type Description
StoreNotConfiguredError

If no root is set anywhere.

NotAStoreError

If the root has no labdata-store.toml.

Source code in src/labdata/store/root.py
def open_store(root: str | os.PathLike[str] | None = None) -> Store:
    """Open the store, resolving its root without network or prompt.

    The root is the first of: the ``root`` argument, ``LABDATA_STORE``, then the per-user
    pointer file written by ``labdata store configure``. Opening writes nothing in the store.

    Parameters
    ----------
    root : str or os.PathLike or None
        The store root. ``None`` falls back to the environment, then the pointer file.

    Returns
    -------
    Store
        The opened store.

    Raises
    ------
    StoreNotConfiguredError
        If no root is set anywhere.
    NotAStoreError
        If the root has no ``labdata-store.toml``.
    """
    path = resolve_root(root, STORE_ENV, pointer_path(), "store")
    if path is None:
        raise StoreNotConfiguredError(
            f"store not configured: pass open_store(root=...), set {STORE_ENV}, "
            "or run `labdata store configure <root>`"
        )
    return Store(root=path, settings=read_marker(path))

geo_records

The GEO object model: Series, Sample, Platform.

GEO records (GSE/GSM/GPL) live in the Entrez gds database (:class:_GdsRecord); they expose a url and a suppl/ directory. Each class is a lazy handle on one accessioned record: construction only validates and stores the accession; properties issue their NCBI request on first access and cache it (functools.cached_property), so building a record is cheap and network traffic happens only for the fields you read.

Properties that reference other GEO/SRA objects return instances of the relevant class (sharing this record's Entrez client), not bare accession strings — a Series yields :class:~labdata.geo.sra_records.Experiment and :class:~labdata.geo.bio_project_records.BioProject instances; a Sample points back at its :class:Series.

Series

Series(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _SraDownloadMixin, _GdsRecord

A GEO Series (GSE000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

A GEO Series accession, e.g. "GSE131907" (^GSE\d+$).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed GEO Series accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

summary property

summary: str

The Series summary/abstract text.

pubmed_id cached property

pubmed_id: str | None

The PubMed ID of the associated publication, or None.

samples cached property

samples: list[Sample]

The samples (GSM) belonging to this Series, as :class:Sample instances.

platforms cached property

platforms: list[Platform]

The platforms (GPL) this Series uses, as :class:Platform instances.

experiments cached property

experiments: list[Experiment]

The SRA experiments (SRX) linked to this Series, as :class:Experiment instances.

The union of elink and every sample's SOFT Sample_relation = SRA lines, read in one request for the whole Series.

bioprojects cached property

bioprojects: list[BioProject]

The BioProjects (PRJNA…) linked to this Series, as :class:BioProject instances.

One elink plus one batched esummary; each :class:BioProject is seeded with the UID and summary already fetched.

make_sra_run_table

make_sra_run_table() -> pandas.DataFrame

Build a tidy, run-level (SRR) table for this Series, like SRA Run Selector.

Resolves the Series' SRA experiments and fetches NCBI's runinfo report in one batched EFetch per chunk, returning one row per sequencing run. The columns are SRA's runinfo fields (Run, BioSample, Experiment, LibraryName, Platform, …) with a few renamed to the Run-Selector names (AvgSpotLen, Bases, Instrument). The reported size is size_MB — E-utilities runinfo does not expose an exact byte count.

A ReadStructure column ({L1}+{L2}+…, e.g. "28+94") is added from each run's per-read <Statistics>, read from the full SRA XML with a second batched EFetch per chunk (see :attr:Run.read_structure); it is empty for runs SRA reports no read statistics for.

Returns:

Type Description
DataFrame

One row per run, key columns ordered first. Empty when the Series has no linked SRA runs.

Examples:

>>> table = Series("GSE229022").make_sra_run_table()
>>> table[["Run", "BioSample", "Instrument"]].head()
Source code in src/labdata/geo/geo_records.py
def make_sra_run_table(self) -> pandas.DataFrame:
    """Build a tidy, run-level (``SRR``) table for this Series, like SRA Run Selector.

    Resolves the Series' SRA experiments and fetches NCBI's ``runinfo`` report
    in one batched EFetch per chunk, returning one row per sequencing run. The
    columns are SRA's ``runinfo`` fields (``Run``, ``BioSample``, ``Experiment``,
    ``LibraryName``, ``Platform``, …) with a few renamed to the Run-Selector
    names (``AvgSpotLen``, ``Bases``, ``Instrument``). The reported size is
    ``size_MB`` — E-utilities ``runinfo`` does not expose an exact byte count.

    A ``ReadStructure`` column (``{L1}+{L2}+…``, e.g. ``"28+94"``) is added
    from each run's per-read ``<Statistics>``, read from the full SRA XML with
    a second batched EFetch per chunk (see :attr:`Run.read_structure`); it is
    empty for runs SRA reports no read statistics for.

    Returns
    -------
    pandas.DataFrame
        One row per run, key columns ordered first. Empty when the Series has
        no linked SRA runs.

    Examples
    --------
    >>> table = Series("GSE229022").make_sra_run_table()  # doctest: +SKIP
    >>> table[["Run", "BioSample", "Instrument"]].head()  # doctest: +SKIP
    """
    import pandas

    sra_uids = self.client.elink(dbfrom="gds", db="sra", uid=self.uid)
    frames: list[pandas.DataFrame] = []
    structures: dict[str, str] = {}
    for chunk in _chunked(sra_uids, _RUNINFO_CHUNK):
        text = self.client.efetch(db="sra", ids=chunk, rettype="runinfo", retmode="text")
        if text.strip():
            frames.append(pandas.read_csv(io.StringIO(text)))
        xml = self.client.efetch(db="sra", ids=chunk, rettype="full", retmode="xml")
        for accession, block in _run_blocks(xml).items():
            structures[accession] = _read_structure(_read_lengths(block))
    if not frames:
        return pandas.DataFrame()
    table = pandas.concat(frames, ignore_index=True).rename(columns=_RUNINFO_RENAME)
    table["ReadStructure"] = [structures.get(run, "") for run in table["Run"]]
    leading = [column for column in _RUN_TABLE_COLUMNS if column in table.columns]
    remaining = [column for column in table.columns if column not in leading]
    return table.reindex(columns=leading + remaining)

download

download(
    output_dir: str | Path = ".",
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    select_srx: Iterable[str] | None = None,
    original_srx: Iterable[str] | None = None,
    max_size: str | None = None,
    retries: int | None = None,
    backoff: float | None = None,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    verbose: bool = True,
) -> dict[str, bool]

Download FASTQ for every run of every SRA experiment in this record.

Lays the data out as <output_dir>/<accession>/<SRX>/<SRR>*.fastq.gz — one directory per experiment under a directory named for this record — via a Snakemake DAG (prefetch → extract → compress). Runs already marked done (a .<SRR>.success flag) are skipped, so this is safe to rerun on an unstable connection.

Concurrency is resource-aware: at most ncbi_parallel runs prefetch from NCBI at once (keeping the link busy without tripping throttling), while cores bounds total CPU across the local extraction/compression steps — so those two stages overlap instead of blocking each other.

Pass select_srx to download only some of the record's experiments (see below).

Parameters:

Name Type Description Default
output_dir str or Path

Parent directory; a <output_dir>/<accession> subtree is created for this record.

"."
ncbi_parallel int

Maximum runs to prefetch (the NCBI network step) concurrently. Keep this modest to avoid NCBI throttling; it is independent of cores.

:data:`DEFAULT_NCBI_PARALLEL`
cores int

Total CPU cores the DAG may use across all steps (defaults to the machine's CPU count). On an HPC allocation, pass the allotted cores (e.g. $SLURM_CPUS_ON_NODE).

None
threads_per_run int

Threads handed to fasterq-dump/pigz for each run.

:data:`DEFAULT_THREADS_PER_RUN`
select_srx iterable of str

A whitelist of SRA experiment accessions (SRX…). When given, only the record's experiments whose accession is listed are downloaded and every other experiment is skipped; None (the default) downloads them all. Entries are matched case-insensitively and every one must be a well-formed SRX accession — anything else raises :class:~labdata.exceptions.AccessionError.

None
original_srx iterable of str

A set of SRA experiment accessions (SRX…) to fetch in original format instead of the default sra-tools path: every run of a listed experiment has its submitter-uploaded files downloaded as-is (no fasterq-dump/gzip), because its .sra is missing or useless (e.g. a 10X run whose .sra stored only one read). This selects the mode; it does not by itself widen the selection — an experiment must still survive select_srx to be downloaded at all. Each entry must be a well-formed SRX accession or :class:~labdata.exceptions.AccessionError is raised.

None
max_size str

Passed to prefetch --max-size (defaults to :data:DEFAULT_MAX_SIZE); raise it for runs that exceed prefetch's 20G default.

None
retries int

Attempts for the resumable prefetch network step per run (defaults to :data:DEFAULT_RETRIES).

None
backoff float

Base seconds for the linear backoff between prefetch retries (defaults to :data:DEFAULT_BACKOFF).

None
keep_sra bool

Keep each run's downloaded .sra next to its FASTQ (as <accession>/<SRX>/<SRR>.sra) instead of deleting it after extraction.

False
prefetch_only bool

Only prefetch each run's .sra (into <SRX>/<SRR>/), skipping fasterq-dump, gzip, and cleanup. A later full download resumes from extraction without re-fetching.

False
verbose bool

Print a download plan up front and one line per finished run to stderr. Set False to download silently.

True

Returns:

Type Description
dict[str, bool]

Maps each run accession to whether its data is present on completion.

Examples:

>>> Series("GSE229022").download("./fastq", ncbi_parallel=3, cores=16)
{'SRR24084454': True, 'SRR24084455': True, ...}
>>> BioProject("PRJNA1027859").download("./fastq", ncbi_parallel=3)
{'SRR26452081': True, 'SRR26452082': True, ...}
>>> Series("GSE229022").download("./fastq", select_srx=["SRX19885398"])
{'SRR24084454': True}
Source code in src/labdata/geo/sratools.py
def download(
    self,
    output_dir: str | Path = ".",
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    select_srx: Iterable[str] | None = None,
    original_srx: Iterable[str] | None = None,
    max_size: str | None = None,
    retries: int | None = None,
    backoff: float | None = None,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    verbose: bool = True,
) -> dict[str, bool]:
    """Download FASTQ for every run of every SRA experiment in this record.

    Lays the data out as ``<output_dir>/<accession>/<SRX>/<SRR>*.fastq.gz`` —
    one directory per experiment under a directory named for this record — via
    a Snakemake DAG (``prefetch → extract → compress``). Runs already marked
    done (a ``.<SRR>.success`` flag) are skipped, so this is safe to rerun on
    an unstable connection.

    Concurrency is resource-aware: at most ``ncbi_parallel`` runs ``prefetch``
    from NCBI at once (keeping the link busy without tripping throttling), while
    ``cores`` bounds total CPU across the local extraction/compression steps —
    so those two stages overlap instead of blocking each other.

    Pass ``select_srx`` to download only some of the record's experiments (see
    below).

    Parameters
    ----------
    output_dir : str or Path, default "."
        Parent directory; a ``<output_dir>/<accession>`` subtree is created for
        this record.
    ncbi_parallel : int, default :data:`DEFAULT_NCBI_PARALLEL`
        Maximum runs to ``prefetch`` (the NCBI network step) concurrently. Keep
        this modest to avoid NCBI throttling; it is independent of ``cores``.
    cores : int, optional
        Total CPU cores the DAG may use across all steps (defaults to the
        machine's CPU count). On an HPC allocation, pass the allotted cores
        (e.g. ``$SLURM_CPUS_ON_NODE``).
    threads_per_run : int, default :data:`DEFAULT_THREADS_PER_RUN`
        Threads handed to ``fasterq-dump``/``pigz`` for each run.
    select_srx : iterable of str, optional
        A whitelist of SRA experiment accessions (``SRX…``). When given, only
        the record's experiments whose accession is listed are downloaded and
        every other experiment is skipped; ``None`` (the default) downloads
        them all. Entries are matched case-insensitively and every one must be
        a well-formed ``SRX`` accession — anything else raises
        :class:`~labdata.exceptions.AccessionError`.
    original_srx : iterable of str, optional
        A set of SRA experiment accessions (``SRX…``) to fetch in *original
        format* instead of the default sra-tools path: every run of a listed
        experiment has its submitter-uploaded files downloaded as-is (no
        ``fasterq-dump``/gzip), because its ``.sra`` is missing or useless
        (e.g. a 10X run whose ``.sra`` stored only one read). This selects the
        *mode*; it does not by itself widen the selection — an experiment must
        still survive ``select_srx`` to be downloaded at all. Each entry must
        be a well-formed ``SRX`` accession or :class:`~labdata.exceptions.AccessionError`
        is raised.
    max_size : str, optional
        Passed to ``prefetch --max-size`` (defaults to :data:`DEFAULT_MAX_SIZE`);
        raise it for runs that exceed ``prefetch``'s 20G default.
    retries : int, optional
        Attempts for the resumable ``prefetch`` network step per run (defaults
        to :data:`DEFAULT_RETRIES`).
    backoff : float, optional
        Base seconds for the linear backoff between ``prefetch`` retries
        (defaults to :data:`DEFAULT_BACKOFF`).
    keep_sra : bool, default False
        Keep each run's downloaded ``.sra`` next to its FASTQ (as
        ``<accession>/<SRX>/<SRR>.sra``) instead of deleting it after extraction.
    prefetch_only : bool, default False
        Only ``prefetch`` each run's ``.sra`` (into ``<SRX>/<SRR>/``), skipping
        ``fasterq-dump``, gzip, and cleanup. A later full download resumes from
        extraction without re-fetching.
    verbose : bool, default True
        Print a download plan up front and one line per finished run to
        ``stderr``. Set ``False`` to download silently.

    Returns
    -------
    dict[str, bool]
        Maps each run accession to whether its data is present on completion.

    Examples
    --------
    >>> Series("GSE229022").download("./fastq", ncbi_parallel=3, cores=16)  # doctest: +SKIP
    {'SRR24084454': True, 'SRR24084455': True, ...}
    >>> BioProject("PRJNA1027859").download("./fastq", ncbi_parallel=3)  # doctest: +SKIP
    {'SRR26452081': True, 'SRR26452082': True, ...}
    >>> Series("GSE229022").download("./fastq", select_srx=["SRX19885398"])  # doctest: +SKIP
    {'SRR24084454': True}
    """
    # ``None`` overrides fall through to the sra-tools defaults.
    overrides = {
        key: value
        for key, value in {"max_size": max_size, "retries": retries, "backoff": backoff}.items()
        if value is not None
    }
    # Original-format experiments select the download *mode*, not the selection.
    original_set = _validate_srx_whitelist(original_srx, field="original_srx")
    base = Path(output_dir) / self.accession
    tasks: list[tuple[Run, Path]] = []
    original: set[str] = set()
    for experiment in self._selected_experiments(select_srx):
        srx_dir = base / experiment.accession
        srx_dir.mkdir(parents=True, exist_ok=True)
        is_original = experiment.accession in original_set
        for run in experiment.runs:
            tasks.append((run, srx_dir))
            if is_original:
                original.add(run.accession)
    return _run_plan(
        self.accession,
        tasks,
        output_root=base,
        verbose=verbose,
        ncbi_parallel=ncbi_parallel,
        cores=cores,
        threads_per_run=threads_per_run,
        keep_sra=keep_sra,
        prefetch_only=prefetch_only,
        original=original,
        **overrides,
    )

Sample

Sample(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _GdsRecord

A GEO Sample (GSM000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

A GEO Sample accession, e.g. "GSM3827114" (^GSM\d+$).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed GEO Sample accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

series property

series: Series | None

The parent :class:Series, or None if the summary lists none.

platform property

platform: Platform | None

The :class:Platform this sample was run on, or None.

experiments cached property

experiments: list[Experiment]

The SRA experiments (SRX) for this sample, as :class:Experiment instances.

The union of elink and the sample's SOFT Sample_relation = SRA lines.

Platform

Platform(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _GdsRecord

A GEO Platform (GPL000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

A GEO Platform accession, e.g. "GPL16791" (^GPL\d+$).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed GEO Platform accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

sample_count property

sample_count: int | None

The number of samples GEO has on this platform, or None if unknown.

sra_records

The SRA object model: Experiment and Run.

Both are read from the full EXPERIMENT_PACKAGE XML that efetch db=sra rettype=full returns. :func:experiments_from_package_xml is the one parser: the live path fetches packages and hands them to it, and so can a caller holding stored packages. Each class is a lazy handle on one accessioned record: construction only validates the accession, and a handle that was not built from a package fetches its package on first property access.

Properties that reference other SRA objects (Run.experiment, Experiment.runs) return instances sharing this record's clients, not bare accession strings.

Experiment

Experiment(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _SraRecord

An SRA experiment (SRX000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

An SRA experiment accession, e.g. "SRX5921017" (^[SED]RX\d+$, covering SRA/ENA/DDBJ).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None
sdl_client SdlClient or None

SRA Data Locator client its runs use for files.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed experiment accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

title property

title: str

The experiment title.

study property

study: str

The parent study accession (SRP…), or empty string if absent.

runs cached property

runs: list[Run]

The sequencing runs (SRR) of this experiment, as :class:Run instances.

Each run is read from this experiment's package (no extra fetch).

Run

Run(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _SraRecord

An SRA run (SRR000000), resolved lazily through NCBI Entrez.

Parameters:

Name Type Description Default
accession str

An SRA run accession, e.g. "SRR9000001" (^[SED]RR\d+$).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None
sdl_client SdlClient or None

SRA Data Locator client to use for files.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed run accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

total_spots property

total_spots: int | None

The number of spots (reads) in this run, or None if unreported.

total_bases property

total_bases: int | None

The number of bases in this run, or None if unreported.

is_public property

is_public: bool

Whether the run's data is publicly accessible.

read_lengths property

read_lengths: list[int]

The average length of each read (mate) sequenced per spot, in read order.

Reads SRA's per-run <Statistics>. A paired run yields two lengths, e.g. [28, 94]; a single-end run yields one. Empty when SRA reports no read statistics.

n_reads property

n_reads: int | None

The number of reads (mates) sequenced per spot, or None if unreported.

read_structure property

read_structure: str

The run's read structure as {L1}+{L2}+… (e.g. "28+94"), or "".

A compact rendering of :attr:read_lengths: each read's rounded average length joined with + in read order. Empty when the lengths are unknown.

experiment cached property

experiment: Experiment

The parent :class:Experiment (SRX) of this run, from the same package.

files cached property

files: list[RemoteFile]

Every file the SRA Data Locator lists for this run (one SDL request, cached).

Includes both the normalized SRA archive file(s) and any original/submitted files (e.g. a 10X Genomics possorted_genome_bam). This is the data behind the Run Browser's "Data access" panel. A live Run gives SDL's locations; a store-built one lists its stored XML's <SRAFiles>, with only the URLs the XML carries.

Returns:

Type Description
list of RemoteFile

All files listed for this run, each with its download locations.

Raises:

Type Description
SdlError

If the SDL request fails or the run is unknown/non-public.

Examples:

>>> [(f.name, f.type) for f in Run("SRR20172067").files]
[('possorted_genome_bam_TC2_d15_1.bam', 'TenX'), ('SRR20172067', 'sra')]

original_files property

original_files: list[RemoteFile]

The original/submitted files for this run (the "Original format" listing).

The submitter's own uploads — e.g. the 10X Genomics BAM behind a run whose .sra only stored a single read — as opposed to the normalized SRA archive. Filters :attr:files to the non-archive types.

Returns:

Type Description
list of RemoteFile

Files whose type is not a normalized SRA archive type; empty when the submission has no original-format files.

Examples:

>>> [f.name for f in Run("SRR20172067").original_files]
['possorted_genome_bam_TC2_d15_1.bam']

experiments_from_package_xml

experiments_from_package_xml(
    xml: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> list[Experiment]

Build experiments, with their runs, from SRA EXPERIMENT_PACKAGE XML.

Every property of the returned experiments and their runs is read from xml, except Run.files and Run.original_files, which ask sdl_client.

Parameters:

Name Type Description Default
xml str

One EXPERIMENT_PACKAGE, several concatenated, or an EXPERIMENT_PACKAGE_SET (the efetch db=sra rettype=full response).

required
client EntrezClient or None

Entrez client the objects hold; reading their properties does not use it.

None
sdl_client SdlClient or None

SRA Data Locator client the runs hold for files.

None

Returns:

Type Description
list of Experiment

One experiment per distinct accession, in document order. Its uid is None: package XML does not carry the Entrez UID.

Raises:

Type Description
LabdataError

If xml is not well-formed XML.

Source code in src/labdata/geo/sra_records.py
def experiments_from_package_xml(
    xml: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> list[Experiment]:
    """Build experiments, with their runs, from SRA ``EXPERIMENT_PACKAGE`` XML.

    Every property of the returned experiments and their runs is read from ``xml``,
    except ``Run.files`` and ``Run.original_files``, which ask ``sdl_client``.

    Parameters
    ----------
    xml : str
        One ``EXPERIMENT_PACKAGE``, several concatenated, or an
        ``EXPERIMENT_PACKAGE_SET`` (the ``efetch db=sra rettype=full`` response).
    client : EntrezClient or None
        Entrez client the objects hold; reading their properties does not use it.
    sdl_client : SdlClient or None
        SRA Data Locator client the runs hold for ``files``.

    Returns
    -------
    list of Experiment
        One experiment per distinct accession, in document order. Its ``uid`` is
        ``None``: package XML does not carry the Entrez UID.

    Raises
    ------
    LabdataError
        If ``xml`` is not well-formed XML.
    """
    return [
        _experiment_from_package(package, client, sdl_client) for package in _packages(xml).values()
    ]

split_packages

split_packages(xml: str) -> dict[str, str]

Map each experiment accession in an efetch db=sra reply to its package text.

Each value is the EXPERIMENT_PACKAGE element exactly as served; the first package of an accession wins. NCBI sometimes cuts a package off mid-text and goes on with the next one; a package that is not well-formed is left out, so it reads as dropped and is asked for again.

Source code in src/labdata/geo/sra_records.py
def split_packages(xml: str) -> dict[str, str]:
    """Map each experiment accession in an ``efetch db=sra`` reply to its package text.

    Each value is the ``EXPERIMENT_PACKAGE`` element exactly as served; the first
    package of an accession wins. NCBI sometimes cuts a package off mid-text and goes on
    with the next one; a package that is not well-formed is left out, so it reads as
    dropped and is asked for again.
    """
    out: dict[str, str] = {}
    for found in _PACKAGE.findall(xml):
        package = found[[m.start() for m in _PACKAGE_START.finditer(found)][-1] :]
        try:
            ET.fromstring(package)
        except ET.ParseError:
            continue
        match = _EXPERIMENT_ACC.search(package)
        if match:
            out.setdefault(match[1], package)
    return out

fetch_package_texts

fetch_package_texts(
    client: EntrezClient, ids: list[str]
) -> dict[str, str]

Fetch the package text for SRA UIDs or accessions, checking every id came back.

A batched EFetch can leave packages out with no error, so the ids left out are requested again, fresh, before this gives up.

Parameters:

Name Type Description Default
client EntrezClient

The Entrez seam.

required
ids list of str

SRA UIDs, or experiment or run accessions.

required

Returns:

Type Description
dict of str to str

Each experiment accession mapped to its EXPERIMENT_PACKAGE text as served.

Raises:

Type Description
EntrezError

If some ids still have no package after the retries.

Source code in src/labdata/geo/sra_records.py
def fetch_package_texts(client: EntrezClient, ids: list[str]) -> dict[str, str]:
    """Fetch the package text for SRA UIDs or accessions, checking every id came back.

    A batched EFetch can leave packages out with no error, so the ids left out are
    requested again, fresh, before this gives up.

    Parameters
    ----------
    client : EntrezClient
        The Entrez seam.
    ids : list of str
        SRA UIDs, or experiment or run accessions.

    Returns
    -------
    dict of str to str
        Each experiment accession mapped to its ``EXPERIMENT_PACKAGE`` text as served.

    Raises
    ------
    EntrezError
        If some ids still have no package after the retries.
    """
    found: dict[str, str] = {}
    for start in range(0, len(ids), _PACKAGE_CHUNK):
        chunk = list(dict.fromkeys(str(i) for i in ids[start : start + _PACKAGE_CHUNK]))
        texts: dict[str, str] = {}
        parsed: dict[str, ET.Element] = {}
        missing = chunk
        for attempt in range(_PACKAGE_RETRIES + 1):
            reply = client.efetch(
                db="sra", ids=missing, rettype="full", retmode="xml", refresh=attempt > 0
            )
            for accession, text in split_packages(reply).items():
                if accession not in texts:
                    texts[accession] = text
                    parsed[accession] = _parse_package(text)
            missing = _missing(chunk, parsed)
            if not missing:
                break
        else:
            raise EntrezError(
                f"SRA returned {len(texts)} experiment packages for {len(chunk)} "
                f"requested ids; no package for: {', '.join(missing)}"
            )
        for accession, text in texts.items():
            found.setdefault(accession, text)
    return found

bio_project_records

BioProject — the umbrella record a GEO Series links out to.

A BioProject (PRJNA/PRJEB/PRJDB) groups a study's GEO Series and SRA data. It lives in the Entrez bioproject database (:class:BioProject), whose esummary uses the v2.0 DocumentSummarySet shape. Like the GEO/SRA records it is a lazy handle: construction only validates the accession; properties fetch and cache on first access.

Its series and experiments return :class:~labdata.geo.geo_records.Series and :class:~labdata.geo.sra_records.Experiment instances (sharing this record's Entrez client), and it inherits :meth:~labdata.geo.sratools._SraDownloadMixin.download so a whole project can be fetched with sra-tools.

BioProject

BioProject(
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
)

Bases: _SraDownloadMixin, _Record

An NCBI BioProject (PRJNA000000), resolved lazily through NCBI Entrez.

BioProject is the umbrella record that groups a study's GEO Series and SRA data. It lives in the Entrez bioproject database, whose esummary uses the v2.0 DocumentSummarySet shape.

Parameters:

Name Type Description Default
accession str

A BioProject accession, e.g. "PRJNA545296" (^PRJ[A-Z]{2}\d+$, covering NCBI PRJNA, EBI PRJEB, and DDBJ PRJDB).

required
client EntrezClient or None

Entrez client to use; a default one is built on first network access.

None

Raises:

Type Description
AccessionError

If accession is not a well-formed BioProject accession.

Source code in src/labdata/geo/_base.py
def __init__(
    self,
    accession: str,
    *,
    client: EntrezClient | None = None,
    sdl_client: SdlClient | None = None,
) -> None:
    if not isinstance(accession, str):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    accession = accession.strip().upper()
    if not self._ACCESSION_RE.match(accession):
        raise AccessionError(f"not a GEO/SRA {self._KIND} accession: {accession!r}")
    self.accession = accession
    self._client = client
    self._sdl_client = sdl_client

uid cached property

uid: str

The Entrez bioproject UID for this record.

Raises:

Type Description
EntrezError

If NCBI returns no matching bioproject record.

title property

title: str

The project title.

name property

name: str

The project name.

description property

description: str

The free-text project description.

organism property

organism: str

The organism the project targets.

data_type property

data_type: str

The project data type (e.g. Transcriptome or Gene expression).

registration_date property

registration_date: str

The project's registration date as reported by NCBI.

submitter property

submitter: str

The submitting organization.

url property

url: str

The NCBI BioProject web URL for this record.

series cached property

series: list[Series]

The GEO Series (GSE) under this project, as :class:Series instances.

One elink plus one batched esummary; each :class:Series is seeded with the UID and summary already fetched.

experiments cached property

experiments: list[Experiment]

The SRA experiments (SRX) under this project, as :class:Experiment instances.

download

download(
    output_dir: str | Path = ".",
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    select_srx: Iterable[str] | None = None,
    original_srx: Iterable[str] | None = None,
    max_size: str | None = None,
    retries: int | None = None,
    backoff: float | None = None,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    verbose: bool = True,
) -> dict[str, bool]

Download FASTQ for every run of every SRA experiment in this record.

Lays the data out as <output_dir>/<accession>/<SRX>/<SRR>*.fastq.gz — one directory per experiment under a directory named for this record — via a Snakemake DAG (prefetch → extract → compress). Runs already marked done (a .<SRR>.success flag) are skipped, so this is safe to rerun on an unstable connection.

Concurrency is resource-aware: at most ncbi_parallel runs prefetch from NCBI at once (keeping the link busy without tripping throttling), while cores bounds total CPU across the local extraction/compression steps — so those two stages overlap instead of blocking each other.

Pass select_srx to download only some of the record's experiments (see below).

Parameters:

Name Type Description Default
output_dir str or Path

Parent directory; a <output_dir>/<accession> subtree is created for this record.

"."
ncbi_parallel int

Maximum runs to prefetch (the NCBI network step) concurrently. Keep this modest to avoid NCBI throttling; it is independent of cores.

:data:`DEFAULT_NCBI_PARALLEL`
cores int

Total CPU cores the DAG may use across all steps (defaults to the machine's CPU count). On an HPC allocation, pass the allotted cores (e.g. $SLURM_CPUS_ON_NODE).

None
threads_per_run int

Threads handed to fasterq-dump/pigz for each run.

:data:`DEFAULT_THREADS_PER_RUN`
select_srx iterable of str

A whitelist of SRA experiment accessions (SRX…). When given, only the record's experiments whose accession is listed are downloaded and every other experiment is skipped; None (the default) downloads them all. Entries are matched case-insensitively and every one must be a well-formed SRX accession — anything else raises :class:~labdata.exceptions.AccessionError.

None
original_srx iterable of str

A set of SRA experiment accessions (SRX…) to fetch in original format instead of the default sra-tools path: every run of a listed experiment has its submitter-uploaded files downloaded as-is (no fasterq-dump/gzip), because its .sra is missing or useless (e.g. a 10X run whose .sra stored only one read). This selects the mode; it does not by itself widen the selection — an experiment must still survive select_srx to be downloaded at all. Each entry must be a well-formed SRX accession or :class:~labdata.exceptions.AccessionError is raised.

None
max_size str

Passed to prefetch --max-size (defaults to :data:DEFAULT_MAX_SIZE); raise it for runs that exceed prefetch's 20G default.

None
retries int

Attempts for the resumable prefetch network step per run (defaults to :data:DEFAULT_RETRIES).

None
backoff float

Base seconds for the linear backoff between prefetch retries (defaults to :data:DEFAULT_BACKOFF).

None
keep_sra bool

Keep each run's downloaded .sra next to its FASTQ (as <accession>/<SRX>/<SRR>.sra) instead of deleting it after extraction.

False
prefetch_only bool

Only prefetch each run's .sra (into <SRX>/<SRR>/), skipping fasterq-dump, gzip, and cleanup. A later full download resumes from extraction without re-fetching.

False
verbose bool

Print a download plan up front and one line per finished run to stderr. Set False to download silently.

True

Returns:

Type Description
dict[str, bool]

Maps each run accession to whether its data is present on completion.

Examples:

>>> Series("GSE229022").download("./fastq", ncbi_parallel=3, cores=16)
{'SRR24084454': True, 'SRR24084455': True, ...}
>>> BioProject("PRJNA1027859").download("./fastq", ncbi_parallel=3)
{'SRR26452081': True, 'SRR26452082': True, ...}
>>> Series("GSE229022").download("./fastq", select_srx=["SRX19885398"])
{'SRR24084454': True}
Source code in src/labdata/geo/sratools.py
def download(
    self,
    output_dir: str | Path = ".",
    *,
    ncbi_parallel: int = DEFAULT_NCBI_PARALLEL,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    select_srx: Iterable[str] | None = None,
    original_srx: Iterable[str] | None = None,
    max_size: str | None = None,
    retries: int | None = None,
    backoff: float | None = None,
    keep_sra: bool = False,
    prefetch_only: bool = False,
    verbose: bool = True,
) -> dict[str, bool]:
    """Download FASTQ for every run of every SRA experiment in this record.

    Lays the data out as ``<output_dir>/<accession>/<SRX>/<SRR>*.fastq.gz`` —
    one directory per experiment under a directory named for this record — via
    a Snakemake DAG (``prefetch → extract → compress``). Runs already marked
    done (a ``.<SRR>.success`` flag) are skipped, so this is safe to rerun on
    an unstable connection.

    Concurrency is resource-aware: at most ``ncbi_parallel`` runs ``prefetch``
    from NCBI at once (keeping the link busy without tripping throttling), while
    ``cores`` bounds total CPU across the local extraction/compression steps —
    so those two stages overlap instead of blocking each other.

    Pass ``select_srx`` to download only some of the record's experiments (see
    below).

    Parameters
    ----------
    output_dir : str or Path, default "."
        Parent directory; a ``<output_dir>/<accession>`` subtree is created for
        this record.
    ncbi_parallel : int, default :data:`DEFAULT_NCBI_PARALLEL`
        Maximum runs to ``prefetch`` (the NCBI network step) concurrently. Keep
        this modest to avoid NCBI throttling; it is independent of ``cores``.
    cores : int, optional
        Total CPU cores the DAG may use across all steps (defaults to the
        machine's CPU count). On an HPC allocation, pass the allotted cores
        (e.g. ``$SLURM_CPUS_ON_NODE``).
    threads_per_run : int, default :data:`DEFAULT_THREADS_PER_RUN`
        Threads handed to ``fasterq-dump``/``pigz`` for each run.
    select_srx : iterable of str, optional
        A whitelist of SRA experiment accessions (``SRX…``). When given, only
        the record's experiments whose accession is listed are downloaded and
        every other experiment is skipped; ``None`` (the default) downloads
        them all. Entries are matched case-insensitively and every one must be
        a well-formed ``SRX`` accession — anything else raises
        :class:`~labdata.exceptions.AccessionError`.
    original_srx : iterable of str, optional
        A set of SRA experiment accessions (``SRX…``) to fetch in *original
        format* instead of the default sra-tools path: every run of a listed
        experiment has its submitter-uploaded files downloaded as-is (no
        ``fasterq-dump``/gzip), because its ``.sra`` is missing or useless
        (e.g. a 10X run whose ``.sra`` stored only one read). This selects the
        *mode*; it does not by itself widen the selection — an experiment must
        still survive ``select_srx`` to be downloaded at all. Each entry must
        be a well-formed ``SRX`` accession or :class:`~labdata.exceptions.AccessionError`
        is raised.
    max_size : str, optional
        Passed to ``prefetch --max-size`` (defaults to :data:`DEFAULT_MAX_SIZE`);
        raise it for runs that exceed ``prefetch``'s 20G default.
    retries : int, optional
        Attempts for the resumable ``prefetch`` network step per run (defaults
        to :data:`DEFAULT_RETRIES`).
    backoff : float, optional
        Base seconds for the linear backoff between ``prefetch`` retries
        (defaults to :data:`DEFAULT_BACKOFF`).
    keep_sra : bool, default False
        Keep each run's downloaded ``.sra`` next to its FASTQ (as
        ``<accession>/<SRX>/<SRR>.sra``) instead of deleting it after extraction.
    prefetch_only : bool, default False
        Only ``prefetch`` each run's ``.sra`` (into ``<SRX>/<SRR>/``), skipping
        ``fasterq-dump``, gzip, and cleanup. A later full download resumes from
        extraction without re-fetching.
    verbose : bool, default True
        Print a download plan up front and one line per finished run to
        ``stderr``. Set ``False`` to download silently.

    Returns
    -------
    dict[str, bool]
        Maps each run accession to whether its data is present on completion.

    Examples
    --------
    >>> Series("GSE229022").download("./fastq", ncbi_parallel=3, cores=16)  # doctest: +SKIP
    {'SRR24084454': True, 'SRR24084455': True, ...}
    >>> BioProject("PRJNA1027859").download("./fastq", ncbi_parallel=3)  # doctest: +SKIP
    {'SRR26452081': True, 'SRR26452082': True, ...}
    >>> Series("GSE229022").download("./fastq", select_srx=["SRX19885398"])  # doctest: +SKIP
    {'SRR24084454': True}
    """
    # ``None`` overrides fall through to the sra-tools defaults.
    overrides = {
        key: value
        for key, value in {"max_size": max_size, "retries": retries, "backoff": backoff}.items()
        if value is not None
    }
    # Original-format experiments select the download *mode*, not the selection.
    original_set = _validate_srx_whitelist(original_srx, field="original_srx")
    base = Path(output_dir) / self.accession
    tasks: list[tuple[Run, Path]] = []
    original: set[str] = set()
    for experiment in self._selected_experiments(select_srx):
        srx_dir = base / experiment.accession
        srx_dir.mkdir(parents=True, exist_ok=True)
        is_original = experiment.accession in original_set
        for run in experiment.runs:
            tasks.append((run, srx_dir))
            if is_original:
                original.add(run.accession)
    return _run_plan(
        self.accession,
        tasks,
        output_root=base,
        verbose=verbose,
        ncbi_parallel=ncbi_parallel,
        cores=cores,
        threads_per_run=threads_per_run,
        keep_sra=keep_sra,
        prefetch_only=prefetch_only,
        original=original,
        **overrides,
    )

bamtofastq

Per-run cellranger-BAM → FASTQ stage behind the Snakemake tenx DAG.

This is a 10x-specific counterpart to :mod:labdata.geo.sratools. When a 10x run's SRA-normalized .sra is broken (e.g. it dropped the technical reads, leaving only R1), the usable source is the submitter's original cellranger BAM, which :mod:labdata.geo can fetch with the original-format download path. Turning that BAM back into correctly-split FASTQ requires 10x Genomics' own bamtofastq — a generic BAM→FASTQ tool cannot decode the 10x tags / @RG headers that separate the barcode (R1), cDNA (R2), and index (I1) reads, and would corrupt the read structure for a downstream STARsolo remap.

Every subprocess call is funnelled through the single :func:_run helper — the one place tests monkeypatch — so the stage (BAM discovery, the tool call, flattening its nested output into the download-style layout) is exercised without the bamtofastq binary. Orchestration — scheduling this stage across many runs — lives in :mod:labdata.tenx.pipeline (Snakemake); :class:TenxConverter drives that DAG over a whole :class:~labdata.geo.geo_records.Series or :class:~labdata.geo.bio_project_records.BioProject, auto-detecting which runs carry a 10x BAM.

TenxConverter

TenxConverter(
    accession_or_record: str | Series | BioProject,
    output_dir: str | Path = ".",
)

Convert a project's 10x cellranger BAMs to FASTQ with bamtofastq.

Drives the tenx Snakemake DAG over every 10x-BAM run of a GEO Series or BioProject: for each run whose original files include a cellranger BAM (see :func:is_tenx_bam_run), the already-downloaded BAM is converted to FASTQ laid out like the download pipeline (<accession>/<SRX>/<SRR>_S1_L00N_R{1,2}_00N.fastq.gz). The BAMs must already be on disk from a prior original-format download (labdata geo download … --original-srx); this step does not fetch them.

Parameters:

Name Type Description Default
accession_or_record str, Series, or BioProject

The project to convert. A GSE… / PRJ… accession is resolved to the matching record; a record is used as-is.

required
output_dir str or Path

Parent directory; the <output_dir>/<accession> subtree (the same one the original-format download wrote the BAMs into) is read from and written to.

"."

Examples:

>>> TenxConverter("GSE208154", "./data").convert(cores=16)
{'SRR20172067': True, ...}
Source code in src/labdata/tenx/bamtofastq.py
def __init__(
    self, accession_or_record: str | Series | BioProject, output_dir: str | Path = "."
) -> None:
    self.record = _as_record(accession_or_record)
    self.output_dir = Path(output_dir)

__repr__

__repr__() -> str

Return an unambiguous representation.

Source code in src/labdata/tenx/bamtofastq.py
def __repr__(self) -> str:
    """Return an unambiguous representation."""
    return f"{type(self).__name__}({self.record.accession!r}, {str(self.output_dir)!r})"

convert

convert(
    *,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    reads_per_fastq: int = DEFAULT_READS_PER_FASTQ,
    select_srx: Iterable[str] | None = None,
    all_runs: bool = False,
    from_disk: bool = False,
    remove_bam: bool = False,
    verbose: bool = True,
) -> dict[str, bool]

Convert every 10x-BAM run of this record's experiments to FASTQ.

Auto-detects which runs carry a cellranger BAM (:func:is_tenx_bam_run) and converts each via a Snakemake DAG, laying the FASTQ out as <output_dir>/<accession>/<SRX>/<SRR>_S1_L00N_R{1,2}_00N.fastq.gz. Runs already marked done (a .<SRR>.tenx.done marker) are skipped, so this is safe to rerun.

Parameters:

Name Type Description Default
cores int

Total CPU cores the DAG may use (defaults to the machine's CPU count). On an HPC allocation, pass the allotted cores (e.g. $SLURM_CPUS_ON_NODE).

None
threads_per_run int

Threads handed to bamtofastq per run (--nthreads).

:data:`DEFAULT_THREADS_PER_RUN`
reads_per_fastq int

Reads per output FASTQ chunk (bamtofastq --reads-per-fastq).

:data:`DEFAULT_READS_PER_FASTQ`
select_srx iterable of str

A whitelist of SRA experiment accessions (SRX…). When given, only those experiments are considered; None (the default) considers them all. Each entry must be a well-formed SRX accession or :class:AccessionError is raised.

None
all_runs bool

Convert every run of the selected experiments, skipping the 10x-BAM auto-detection. Use when you know all runs carry a cellranger BAM (e.g. to avoid the extra SDL lookups) or to force conversion of a run SDL does not flag.

False
from_disk bool

Build the task list by scanning the on-disk tree (<output_dir>/<accession>/<SRX>/<SRR>_*.bam) instead of resolving the record through NCBI. This needs no network or NCBI credentials — the point when converting already-downloaded BAMs on an offline HPC compute node. select_srx still filters by experiment; all_runs and the SDL auto-detection do not apply (a BAM's presence on disk is the signal).

False
remove_bam bool

Delete each run's source BAM once its FASTQ are produced, reclaiming the large original. Deletion happens only after a run converts successfully.

False
verbose bool

Print a conversion plan up front and a summary to stderr. Set False to run silently.

True

Returns:

Type Description
dict[str, bool]

Maps each converted run accession to whether its FASTQ are present on completion (empty if there are no 10x-BAM runs).

Source code in src/labdata/tenx/bamtofastq.py
def convert(
    self,
    *,
    cores: int | None = None,
    threads_per_run: int = DEFAULT_THREADS_PER_RUN,
    reads_per_fastq: int = DEFAULT_READS_PER_FASTQ,
    select_srx: Iterable[str] | None = None,
    all_runs: bool = False,
    from_disk: bool = False,
    remove_bam: bool = False,
    verbose: bool = True,
) -> dict[str, bool]:
    """Convert every 10x-BAM run of this record's experiments to FASTQ.

    Auto-detects which runs carry a cellranger BAM (:func:`is_tenx_bam_run`) and
    converts each via a Snakemake DAG, laying the FASTQ out as
    ``<output_dir>/<accession>/<SRX>/<SRR>_S1_L00N_R{1,2}_00N.fastq.gz``. Runs
    already marked done (a ``.<SRR>.tenx.done`` marker) are skipped, so this is safe
    to rerun.

    Parameters
    ----------
    cores : int, optional
        Total CPU cores the DAG may use (defaults to the machine's CPU count). On an
        HPC allocation, pass the allotted cores (e.g. ``$SLURM_CPUS_ON_NODE``).
    threads_per_run : int, default :data:`DEFAULT_THREADS_PER_RUN`
        Threads handed to ``bamtofastq`` per run (``--nthreads``).
    reads_per_fastq : int, default :data:`DEFAULT_READS_PER_FASTQ`
        Reads per output FASTQ chunk (``bamtofastq --reads-per-fastq``).
    select_srx : iterable of str, optional
        A whitelist of SRA experiment accessions (``SRX…``). When given, only those
        experiments are considered; ``None`` (the default) considers them all. Each
        entry must be a well-formed ``SRX`` accession or :class:`AccessionError` is
        raised.
    all_runs : bool, default False
        Convert every run of the selected experiments, skipping the 10x-BAM
        auto-detection. Use when you know all runs carry a cellranger BAM (e.g. to
        avoid the extra SDL lookups) or to force conversion of a run SDL does not
        flag.
    from_disk : bool, default False
        Build the task list by scanning the on-disk tree
        (``<output_dir>/<accession>/<SRX>/<SRR>_*.bam``) instead of resolving the
        record through NCBI. This needs no network or NCBI credentials — the point
        when converting already-downloaded BAMs on an offline HPC compute node.
        ``select_srx`` still filters by experiment; ``all_runs`` and the SDL
        auto-detection do not apply (a BAM's presence on disk is the signal).
    remove_bam : bool, default False
        Delete each run's source BAM once its FASTQ are produced, reclaiming the
        large original. Deletion happens only after a run converts successfully.
    verbose : bool, default True
        Print a conversion plan up front and a summary to ``stderr``. Set ``False``
        to run silently.

    Returns
    -------
    dict[str, bool]
        Maps each converted run accession to whether its FASTQ are present on
        completion (empty if there are no 10x-BAM runs).
    """
    base = self.output_dir / self.record.accession
    if from_disk:
        tasks = _tasks_from_disk(base, _validate_srx_whitelist(select_srx))
    else:
        tasks = [
            (run, base / experiment.accession)
            for experiment in self._selected_experiments(select_srx)
            for run in experiment.runs
            if all_runs or is_tenx_bam_run(run)
        ]
    for srx_dir in {srx_dir for _, srx_dir in tasks}:
        srx_dir.mkdir(parents=True, exist_ok=True)
    return _run_conversion(
        self.record.accession,
        tasks,
        output_root=base,
        cores=cores,
        threads_per_run=threads_per_run,
        reads_per_fastq=reads_per_fastq,
        remove_bam=remove_bam,
        verbose=verbose,
    )

is_tenx_bam_run

is_tenx_bam_run(run: Run) -> bool

Return whether run has an original-format 10x cellranger BAM.

Inspects the run's original/submitted files (via the SRA Data Locator) for a BAM: an entry whose SDL type is TenX/bam or whose name ends in .bam. This is the signal that the run should be recovered from its cellranger BAM rather than the (possibly broken) SRA archive — the runs :class:TenxConverter selects by default.

Parameters:

Name Type Description Default
run Run

The SRA run to inspect.

required

Returns:

Type Description
bool

True if the run lists an original-format BAM, else False.

Source code in src/labdata/tenx/bamtofastq.py
def is_tenx_bam_run(run: Run) -> bool:
    """Return whether ``run`` has an original-format 10x cellranger BAM.

    Inspects the run's original/submitted files (via the SRA Data Locator) for a BAM:
    an entry whose SDL type is ``TenX``/``bam`` or whose name ends in ``.bam``. This is
    the signal that the run should be recovered from its cellranger BAM rather than the
    (possibly broken) SRA archive — the runs :class:`TenxConverter` selects by default.

    Parameters
    ----------
    run : Run
        The SRA run to inspect.

    Returns
    -------
    bool
        ``True`` if the run lists an original-format BAM, else ``False``.
    """
    return any(
        file.type.lower() in {"tenx", "bam"} or file.name.lower().endswith(".bam")
        for file in run.original_files
    )

bamtofastq_run

bamtofastq_run(
    run: Run,
    srx_dir: Path,
    *,
    threads: int = DEFAULT_THREADS_PER_RUN,
    reads_per_fastq: int = DEFAULT_READS_PER_FASTQ,
    remove_bam: bool = False,
) -> list[Path]

Convert one run's on-disk cellranger BAM to FASTQ with bamtofastq.

The single stage of the tenx DAG. Locates the run's original-format BAM (<srx_dir>/<SRR>_*.bam), runs 10x's bamtofastq into a scratch dir, then flattens that nested output into <srx_dir>/<SRR>_S1_L00N_R{1,2}_00N.fastq.gz (see :func:_flatten_output) and removes the scratch. Idempotent: a stale scratch from an interrupted run is cleared first (bamtofastq refuses a pre-existing output dir), so a rerun reconverts cleanly.

Parameters:

Name Type Description Default
run Run

The run whose BAM to convert.

required
srx_dir Path

The experiment directory holding <SRR>_<name>.bam; the FASTQ land here.

required
threads int

Threads passed to bamtofastq --nthreads.

:data:`DEFAULT_THREADS_PER_RUN`
reads_per_fastq int

Reads per output chunk (bamtofastq --reads-per-fastq).

:data:`DEFAULT_READS_PER_FASTQ`
remove_bam bool

Delete the source BAM once the FASTQ are produced. The deletion happens only after a successful conversion, so an interrupted run leaves the BAM in place for a resumed rerun to reconvert from.

False

Returns:

Type Description
list of Path

The converted FASTQ files written under srx_dir.

Raises:

Type Description
DownloadError

If the BAM is missing/ambiguous, bamtofastq is missing or fails, or it produced no FASTQ (a non-original BAM).

Source code in src/labdata/tenx/bamtofastq.py
def bamtofastq_run(
    run: Run,
    srx_dir: Path,
    *,
    threads: int = DEFAULT_THREADS_PER_RUN,
    reads_per_fastq: int = DEFAULT_READS_PER_FASTQ,
    remove_bam: bool = False,
) -> list[Path]:
    """Convert one run's on-disk cellranger BAM to FASTQ with ``bamtofastq``.

    The single stage of the tenx DAG. Locates the run's original-format BAM
    (``<srx_dir>/<SRR>_*.bam``), runs 10x's ``bamtofastq`` into a scratch dir, then
    flattens that nested output into ``<srx_dir>/<SRR>_S1_L00N_R{1,2}_00N.fastq.gz`` (see
    :func:`_flatten_output`) and removes the scratch. Idempotent: a stale scratch from
    an interrupted run is cleared first (``bamtofastq`` refuses a pre-existing output
    dir), so a rerun reconverts cleanly.

    Parameters
    ----------
    run : Run
        The run whose BAM to convert.
    srx_dir : Path
        The experiment directory holding ``<SRR>_<name>.bam``; the FASTQ land here.
    threads : int, default :data:`DEFAULT_THREADS_PER_RUN`
        Threads passed to ``bamtofastq --nthreads``.
    reads_per_fastq : int, default :data:`DEFAULT_READS_PER_FASTQ`
        Reads per output chunk (``bamtofastq --reads-per-fastq``).
    remove_bam : bool, default False
        Delete the source BAM once the FASTQ are produced. The deletion happens only
        *after* a successful conversion, so an interrupted run leaves the BAM in place
        for a resumed rerun to reconvert from.

    Returns
    -------
    list of Path
        The converted FASTQ files written under ``srx_dir``.

    Raises
    ------
    DownloadError
        If the BAM is missing/ambiguous, ``bamtofastq`` is missing or fails, or it
        produced no FASTQ (a non-original BAM).
    """
    accession = run.accession
    bam = _find_bam(srx_dir, accession)
    scratch = _scratch_dir(srx_dir, accession)
    if scratch.exists():
        shutil.rmtree(scratch)  # bamtofastq refuses an existing output dir
    _run(
        [
            "bamtofastq",
            f"--nthreads={threads}",
            f"--reads-per-fastq={reads_per_fastq}",
            str(bam),
            str(scratch),
        ]
    )
    moved = _flatten_output(scratch, srx_dir, accession)
    shutil.rmtree(scratch, ignore_errors=True)
    if remove_bam:
        bam.unlink(missing_ok=True)  # only reached on success — the FASTQ now stand in
    logger.info("converted %s (%d FASTQ)", accession, len(moved))
    return moved

entrez

The one seam for NCBI E-utilities and the GEO download site.

Every NCBI call in liulab-data goes through :class:EntrezClient. E-utilities calls are POSTs over kept-open connections, paced by one limiter per process; Biopython's :func:Bio.Entrez.read parses the XML replies. fetch_text and download GET pages and files outside E-utilities. Every request has a socket timeout and is retried with jittered backoff when the failure is transient.

Responses are memoized per client instance, except efetch_records, download and stream, which bulk runs use.

FetchedRecords dataclass

FetchedRecords(
    records: dict[str, str] = dict(),
    missing: list[str] = list(),
    failed: dict[str, str] = dict(),
)

What a batched :meth:EntrezClient.efetch_records got back.

Attributes:

Name Type Description
records dict of str to str

Each requested id mapped to its record text.

missing list of str

Ids NCBI answered with "ID list is empty": it has no record for them.

failed dict of str to str

Ids whose re-ask failed for another reason, mapped to the error; worth a later try.

Traffic

Traffic()

What one client sent and read: requests per <host> <status>, and body bytes read.

A request that got no reply counts under its exception's name instead of a status.

Source code in src/labdata/ncbi/entrez.py
def __init__(self) -> None:
    self.requests: Counter[str] = Counter()
    self.bytes = 0
    self._lock = threading.Lock()

count

count(host: str, outcome: object) -> None

Count one request to host that ended in outcome.

Source code in src/labdata/ncbi/entrez.py
def count(self, host: str, outcome: object) -> None:
    """Count one request to ``host`` that ended in ``outcome``."""
    with self._lock:
        self.requests[f"{host} {outcome}"] += 1

add_bytes

add_bytes(n: int) -> None

Count n body bytes read.

Source code in src/labdata/ncbi/entrez.py
def add_bytes(self, n: int) -> None:
    """Count ``n`` body bytes read."""
    with self._lock:
        self.bytes += n

__str__

__str__() -> str

Return one log line: the totals, then the count per host and status.

Source code in src/labdata/ncbi/entrez.py
def __str__(self) -> str:
    """Return one log line: the totals, then the count per host and status."""
    each = ", ".join(f"{k}: {v}" for k, v in sorted(self.requests.items()))
    return f"{self.requests.total()} requests, {self.bytes} bytes read: {each}"

EntrezClient

EntrezClient(
    credentials: NcbiCredentials | None = None,
    *,
    allow_prompt: bool = True,
    transport: Transport | None = None,
    limiter: RateLimiter | None = None,
)

Authenticated entry point to NCBI E-utilities and the GEO download site.

Parameters:

Name Type Description Default
credentials NcbiCredentials or None

Credentials to use. When None (default) they are resolved via :func:labdata.ncbi.config.load_credentials.

None
allow_prompt bool

Forwarded to :func:load_credentials when credentials is None.

True
transport Transport or None

Sends the HTTP requests; kept-open connections by default.

None
limiter RateLimiter or None

Paces E-utilities calls; the process-wide :data:EUTILS_LIMITER by default. A 429 or 503 halves its rate. Without an API key it is capped at NCBI's keyless limit.

None
Source code in src/labdata/ncbi/entrez.py
def __init__(
    self,
    credentials: NcbiCredentials | None = None,
    *,
    allow_prompt: bool = True,
    transport: Transport | None = None,
    limiter: RateLimiter | None = None,
) -> None:
    self.credentials = credentials or load_credentials(allow_prompt=allow_prompt)
    self._transport: Transport = transport or HttpTransport()
    self._limiter = limiter or EUTILS_LIMITER
    self._identity = {"tool": _TOOL, "email": self.credentials.email}
    if self.credentials.api_key:
        self._identity["api_key"] = self.credentials.api_key
    else:
        self._limiter.lower_to(_KEYLESS_RATE)
    # Per-instance response cache keyed by call signature. Redundant lookups
    # across records that share this client are served without a request.
    self._cache: dict[tuple[Any, ...], Any] = {}
    #: Requests sent and bytes read, for a step's log.
    self.traffic = Traffic()

clear_cache

clear_cache() -> None

Drop all cached responses (forcing fresh requests on the next call).

Source code in src/labdata/ncbi/entrez.py
def clear_cache(self) -> None:
    """Drop all cached responses (forcing fresh requests on the next call)."""
    self._cache.clear()

esearch

esearch(
    db: str, term: str, *, retmax: int = 20
) -> list[str]

Run an ESearch and return the matching UIDs.

Parameters:

Name Type Description Default
db str

Entrez database to search (e.g. "gds").

required
term str

The search term.

required
retmax int

Maximum number of UIDs to return.

20

Returns:

Type Description
list of str

The matching UIDs, in NCBI's ranking order. Cached per client.

Source code in src/labdata/ncbi/entrez.py
def esearch(self, db: str, term: str, *, retmax: int = 20) -> list[str]:
    """Run an ESearch and return the matching UIDs.

    Parameters
    ----------
    db : str
        Entrez database to search (e.g. ``"gds"``).
    term : str
        The search term.
    retmax : int
        Maximum number of UIDs to return.

    Returns
    -------
    list of str
        The matching UIDs, in NCBI's ranking order. Cached per client.
    """
    key = ("esearch", db, term, retmax)
    cached = self._cache.get(key)
    if cached is None:
        record = self._eutil("esearch", {"db": db, "term": term, "retmax": retmax})
        cached = self._cache[key] = [str(uid) for uid in record.get("IdList", [])]
    return list(cached)

esearch_all

esearch_all(
    db: str, term: str, *, page: int = 10000
) -> list[str]

Run an ESearch and return every matching UID, one call per page UIDs.

Parameters:

Name Type Description Default
db str

Entrez database to search (e.g. "gds").

required
term str

The search term.

required
page int

UIDs asked for per call.

10000

Returns:

Type Description
list of str

Every matching UID, in NCBI's order, each once. Not cached.

Source code in src/labdata/ncbi/entrez.py
def esearch_all(self, db: str, term: str, *, page: int = 10_000) -> list[str]:
    """Run an ESearch and return every matching UID, one call per ``page`` UIDs.

    Parameters
    ----------
    db : str
        Entrez database to search (e.g. ``"gds"``).
    term : str
        The search term.
    page : int
        UIDs asked for per call.

    Returns
    -------
    list of str
        Every matching UID, in NCBI's order, each once. Not cached.
    """
    uids: dict[str, None] = {}
    start, count = 0, 1
    while start < count:
        params = {"db": db, "term": term, "retstart": start, "retmax": page}
        record = self._eutil("esearch", params)
        count = int(record.get("Count", 0))
        got = [str(uid) for uid in record.get("IdList", [])]
        uids |= dict.fromkeys(got)
        if not got:
            break
        start += len(got)
    return list(uids)

esummary

esummary(db: str, uid: str) -> dict[str, Any]

Fetch the document summary for a single UID.

Handles both esummary response shapes: the classic DocSum list used by gds/sra and the v2.0 DocumentSummarySet used by databases such as bioproject.

Parameters:

Name Type Description Default
db str

Entrez database (e.g. "gds", "sra", or "bioproject").

required
uid str

The UID whose summary to fetch.

required

Returns:

Type Description
dict

The document summary as a plain dictionary. Cached per client.

Raises:

Type Description
EntrezError

If NCBI returns no summary for the UID.

Source code in src/labdata/ncbi/entrez.py
def esummary(self, db: str, uid: str) -> dict[str, Any]:
    """Fetch the document summary for a single UID.

    Handles both esummary response shapes: the classic ``DocSum`` list used
    by ``gds``/``sra`` and the v2.0 ``DocumentSummarySet`` used by databases
    such as ``bioproject``.

    Parameters
    ----------
    db : str
        Entrez database (e.g. ``"gds"``, ``"sra"``, or ``"bioproject"``).
    uid : str
        The UID whose summary to fetch.

    Returns
    -------
    dict
        The document summary as a plain dictionary. Cached per client.

    Raises
    ------
    EntrezError
        If NCBI returns no summary for the UID.
    """
    key = ("esummary", db, str(uid))
    cached = self._cache.get(key)
    if cached is None:
        record = self._eutil("esummary", {"db": db, "id": str(uid)})
        docsums = _docsums(record)
        if not docsums:
            raise EntrezError(f"empty esummary for {db} uid {uid!r}")
        cached = self._cache[key] = dict(docsums[0])
    return cached

esummary_many

esummary_many(
    db: str, uids: list[str]
) -> list[dict[str, Any]]

Fetch document summaries for several UIDs in a single request.

Collapses what would otherwise be one ESummary per UID into one batched call (NCBI accepts a comma-joined id list), chunked to keep the request URL within limits. Use this instead of looping over :meth:esummary.

Parameters:

Name Type Description Default
db str

Entrez database (e.g. "gds", "sra", or "bioproject").

required
uids list of str

The UIDs whose summaries to fetch. An empty list issues no request.

required

Returns:

Type Description
list of dict

One document summary per UID, in NCBI's order. The mappings may carry NCBI's Id/uid so callers can recover each summary's UID via :func:docsum_uid. Cached per client by the exact UID list.

Source code in src/labdata/ncbi/entrez.py
def esummary_many(self, db: str, uids: list[str]) -> list[dict[str, Any]]:
    """Fetch document summaries for several UIDs in a single request.

    Collapses what would otherwise be one ESummary per UID into one batched
    call (NCBI accepts a comma-joined id list), chunked to keep the request
    URL within limits. Use this instead of looping over :meth:`esummary`.

    Parameters
    ----------
    db : str
        Entrez database (e.g. ``"gds"``, ``"sra"``, or ``"bioproject"``).
    uids : list of str
        The UIDs whose summaries to fetch. An empty list issues no request.

    Returns
    -------
    list of dict
        One document summary per UID, in NCBI's order. The mappings may carry
        NCBI's ``Id``/``uid`` so callers can recover each summary's UID via
        :func:`docsum_uid`. Cached per client by the exact UID list.
    """
    ids = [str(uid) for uid in uids]
    if not ids:
        return []
    key = ("esummary_many", db, tuple(ids))
    cached = self._cache.get(key)
    if cached is None:
        docsums: list[Any] = []
        for chunk in _chunked(ids, _ESUMMARY_CHUNK):
            record = self._eutil("esummary", {"db": db, "id": ",".join(chunk)})
            docsums.extend(_docsums(record))
        cached = self._cache[key] = docsums
    return list(cached)
elink(dbfrom: str, db: str, uid: str) -> list[str]

Return UIDs in db linked from uid in dbfrom.

Parameters:

Name Type Description Default
dbfrom str

Source database (e.g. "gds").

required
db str

Target database (e.g. "sra").

required
uid str

The source UID.

required

Returns:

Type Description
list of str

Linked UIDs in the target database (empty if there are none). Cached per client.

Source code in src/labdata/ncbi/entrez.py
def elink(self, dbfrom: str, db: str, uid: str) -> list[str]:
    """Return UIDs in ``db`` linked from ``uid`` in ``dbfrom``.

    Parameters
    ----------
    dbfrom : str
        Source database (e.g. ``"gds"``).
    db : str
        Target database (e.g. ``"sra"``).
    uid : str
        The source UID.

    Returns
    -------
    list of str
        Linked UIDs in the target database (empty if there are none). Cached
        per client.
    """
    key = ("elink", dbfrom, db, str(uid))
    cached = self._cache.get(key)
    if cached is None:
        record = self._eutil("elink", {"dbfrom": dbfrom, "db": db, "id": str(uid)})
        linked: list[str] = []
        for linkset in record:
            for linksetdb in linkset.get("LinkSetDb", []):
                linked.extend(str(link["Id"]) for link in linksetdb.get("Link", []))
        cached = self._cache[key] = linked
    return list(cached)
elink_many(
    dbfrom: str, db: str, uids: list[str]
) -> dict[str, list[str]]

Map each of several UIDs to the UIDs it links to, one request per chunk.

Parameters:

Name Type Description Default
dbfrom str

Source database (e.g. "gds").

required
db str

Target database (e.g. "sra").

required
uids list of str

The source UIDs. An empty list issues no request.

required

Returns:

Type Description
dict of str to list of str

Each distinct source UID mapped to its linked UIDs (empty if none).

Source code in src/labdata/ncbi/entrez.py
def elink_many(self, dbfrom: str, db: str, uids: list[str]) -> dict[str, list[str]]:
    """Map each of several UIDs to the UIDs it links to, one request per chunk.

    Parameters
    ----------
    dbfrom : str
        Source database (e.g. ``"gds"``).
    db : str
        Target database (e.g. ``"sra"``).
    uids : list of str
        The source UIDs. An empty list issues no request.

    Returns
    -------
    dict of str to list of str
        Each distinct source UID mapped to its linked UIDs (empty if none).
    """
    wanted = list(dict.fromkeys(str(uid) for uid in uids))
    out: dict[str, list[str]] = {uid: [] for uid in wanted}
    for start in range(0, len(wanted), _ESUMMARY_CHUNK):
        chunk = wanted[start : start + _ESUMMARY_CHUNK]
        # A list ``id`` is sent as repeated parameters, so NCBI answers one set per UID.
        record = self._eutil("elink", {"dbfrom": dbfrom, "db": db, "id": chunk})
        for linkset in record:
            source = str((linkset.get("IdList") or [""])[0])
            for linksetdb in linkset.get("LinkSetDb", []):
                out.setdefault(source, []).extend(
                    str(link["Id"]) for link in linksetdb.get("Link", [])
                )
    return out

efetch

efetch(
    db: str,
    ids: list[str],
    *,
    rettype: str,
    retmode: str = "text",
    refresh: bool = False,
) -> str

Fetch raw records via EFetch and return the response as text.

Unlike the other helpers this returns the response unparsed (e.g. the SRA runinfo CSV), because EFetch serves formats Biopython does not parse into Python structures.

Parameters:

Name Type Description Default
db str

Entrez database (e.g. "sra").

required
ids list of str

The UIDs to fetch. An empty list issues no request.

required
rettype str

EFetch return type (e.g. "runinfo").

required
retmode str

EFetch return mode (default "text").

'text'
refresh bool

Skip the cached response and request again (the new response is cached).

False

Returns:

Type Description
str

The raw response body (empty string for an empty ids). Cached per client by the exact request.

Source code in src/labdata/ncbi/entrez.py
def efetch(
    self,
    db: str,
    ids: list[str],
    *,
    rettype: str,
    retmode: str = "text",
    refresh: bool = False,
) -> str:
    """Fetch raw records via EFetch and return the response as text.

    Unlike the other helpers this returns the response **unparsed** (e.g. the
    SRA ``runinfo`` CSV), because EFetch serves formats Biopython does not
    parse into Python structures.

    Parameters
    ----------
    db : str
        Entrez database (e.g. ``"sra"``).
    ids : list of str
        The UIDs to fetch. An empty list issues no request.
    rettype : str
        EFetch return type (e.g. ``"runinfo"``).
    retmode : str
        EFetch return mode (default ``"text"``).
    refresh : bool
        Skip the cached response and request again (the new response is cached).

    Returns
    -------
    str
        The raw response body (empty string for an empty ``ids``). Cached per
        client by the exact request.
    """
    uids = [str(uid) for uid in ids]
    if not uids:
        return ""
    key = ("efetch", db, tuple(uids), rettype, retmode)
    cached = None if refresh else self._cache.get(key)
    if cached is None:
        params = {"db": db, "id": ",".join(uids), "rettype": rettype, "retmode": retmode}
        cached = self._cache[key] = self._eutil("efetch", params, _read_text)
    return cached

efetch_records

efetch_records(
    db: str,
    ids: list[str],
    *,
    rettype: str,
    retmode: str = "xml",
    split: Callable[[str], Mapping[str, str]],
) -> FetchedRecords

Fetch one batch of records, re-asking once for the ids the reply left out.

NCBI can leave records out of a batch with no error; those ids are asked for again together. An "ID list is empty" answer marks the ids it covers missing and is not retried. A failed re-ask never drops what the batch already returned. Nothing is cached.

Parameters:

Name Type Description Default
db str

Entrez database (e.g. "sra").

required
ids list of str

The ids to fetch in one request.

required
rettype str

EFetch return type (e.g. "full").

required
retmode str

EFetch return mode (default "xml").

'xml'
split callable

Maps a reply's text to {id: record text}, keyed as ids are.

required

Returns:

Type Description
FetchedRecords

The records got, the ids NCBI has none for, and the ids whose re-ask failed.

Raises:

Type Description
EntrezError

If the first request fails for a reason other than "ID list is empty".

Source code in src/labdata/ncbi/entrez.py
def efetch_records(
    self,
    db: str,
    ids: list[str],
    *,
    rettype: str,
    retmode: str = "xml",
    split: Callable[[str], Mapping[str, str]],
) -> FetchedRecords:
    """Fetch one batch of records, re-asking once for the ids the reply left out.

    NCBI can leave records out of a batch with no error; those ids are asked for again
    together. An "ID list is empty" answer marks the ids it covers missing and is not
    retried. A failed re-ask never drops what the batch already returned. Nothing is
    cached.

    Parameters
    ----------
    db : str
        Entrez database (e.g. ``"sra"``).
    ids : list of str
        The ids to fetch in one request.
    rettype : str
        EFetch return type (e.g. ``"full"``).
    retmode : str
        EFetch return mode (default ``"xml"``).
    split : callable
        Maps a reply's text to ``{id: record text}``, keyed as ``ids`` are.

    Returns
    -------
    FetchedRecords
        The records got, the ids NCBI has none for, and the ids whose re-ask failed.

    Raises
    ------
    EntrezError
        If the first request fails for a reason other than "ID list is empty".
    """
    out = FetchedRecords()
    todo = list(dict.fromkeys(str(i) for i in ids))
    for ask in range(2):
        params = {"db": db, "id": ",".join(todo), "rettype": rettype, "retmode": retmode}
        try:
            reply = self._eutil("efetch", params, _read_text)
        except EntrezError as err:
            if _ids_empty(err.__cause__ or err):
                out.missing = todo
            elif ask == 0:
                raise
            else:
                out.failed = dict.fromkeys(todo, str(err))
            return out
        asked = set(todo)
        out.records |= {k: v for k, v in split(reply).items() if k in asked}
        todo = [i for i in todo if i not in out.records]
        if not todo:
            break
    out.missing = todo
    return out

eutil_text

eutil_text(util: str, params: Mapping[str, str]) -> str

POST one E-utilities call and return its reply as text, unparsed and uncached.

A record-level error in a 200 reply stays readable in the text.

Parameters:

Name Type Description Default
util str

The utility, e.g. "esummary" or "efetch".

required
params mapping

The call's parameters; the client adds its identity.

required

Returns:

Type Description
str

The reply body.

Raises:

Type Description
EntrezError

If the call fails; :func:ids_empty tells NCBI's "ID list is empty" answer.

Source code in src/labdata/ncbi/entrez.py
def eutil_text(self, util: str, params: Mapping[str, str]) -> str:
    """POST one E-utilities call and return its reply as text, unparsed and uncached.

    A record-level error in a 200 reply stays readable in the text.

    Parameters
    ----------
    util : str
        The utility, e.g. ``"esummary"`` or ``"efetch"``.
    params : mapping
        The call's parameters; the client adds its identity.

    Returns
    -------
    str
        The reply body.

    Raises
    ------
    EntrezError
        If the call fails; :func:`ids_empty` tells NCBI's "ID list is empty" answer.
    """
    return self._eutil(util, params, _read_text)

fetch_text

fetch_text(url: str) -> str

GET an NCBI web page that E-utilities does not serve and return its text.

Parameters:

Name Type Description Default
url str

The page URL.

required

Returns:

Type Description
str

The response body. Cached per client by URL.

Raises:

Type Description
PageNotFound

If the server answers 404.

EntrezError

If the request fails after the transient-failure retries.

Source code in src/labdata/ncbi/entrez.py
def fetch_text(self, url: str) -> str:
    """GET an NCBI web page that E-utilities does not serve and return its text.

    Parameters
    ----------
    url : str
        The page URL.

    Returns
    -------
    str
        The response body. Cached per client by URL.

    Raises
    ------
    PageNotFound
        If the server answers 404.
    EntrezError
        If the request fails after the transient-failure retries.
    """
    key = ("fetch_text", url)
    cached = self._cache.get(key)
    if cached is None:
        cached = self._cache[key] = self._get(url, _read_text)
    return cached

download

download(url: str, dest: Path) -> int

Stream a file from the GEO site to dest as it arrives, never whole in memory.

The bytes are written as served (a .gz stays compressed) to a temporary file beside dest and renamed into place once complete.

Parameters:

Name Type Description Default
url str

The file URL.

required
dest Path

Where to write it.

required

Returns:

Type Description
int

Bytes written.

Raises:

Type Description
PageNotFound

If the server answers 404.

EntrezError

If the request fails after the transient-failure retries.

Source code in src/labdata/ncbi/entrez.py
def download(self, url: str, dest: Path) -> int:
    """Stream a file from the GEO site to ``dest`` as it arrives, never whole in memory.

    The bytes are written as served (a ``.gz`` stays compressed) to a temporary file
    beside ``dest`` and renamed into place once complete.

    Parameters
    ----------
    url : str
        The file URL.
    dest : Path
        Where to write it.

    Returns
    -------
    int
        Bytes written.

    Raises
    ------
    PageNotFound
        If the server answers 404.
    EntrezError
        If the request fails after the transient-failure retries.
    """
    part = dest.with_name(dest.name + ".part")

    def write(response: Response) -> int:
        size = 0
        with part.open("wb") as fh:
            while chunk := response.read(_CHUNK_BYTES):
                fh.write(chunk)
                size += len(chunk)
        return size

    try:
        size = self._get(url, write)
    except BaseException:
        part.unlink(missing_ok=True)
        raise
    part.replace(dest)
    return size

stream

stream(
    url: str,
    consume: Callable[[Iterator[bytes]], _T],
    *,
    tries: int = _MAX_TRIES,
) -> _T

GET a file from the GEO site and hand its bytes to consume as they arrive.

A gzip body is decompressed on the way. The body is read in chunks, so a large file is never whole in memory unless consume keeps it. Nothing is cached.

Parameters:

Name Type Description Default
url str

The file URL.

required
consume callable

Takes an iterator over the body's chunks, decompressed, and returns the result. It runs again from the start on each retry.

required
tries int

Tries before giving up; 1 leaves retries and backoff to the caller.

_MAX_TRIES

Returns:

Type Description
object

What consume returned.

Raises:

Type Description
PageNotFound

If the server answers 404.

EntrezError

If the request fails tries times; the last failure is its __cause__.

Source code in src/labdata/ncbi/entrez.py
def stream(
    self, url: str, consume: Callable[[Iterator[bytes]], _T], *, tries: int = _MAX_TRIES
) -> _T:
    """GET a file from the GEO site and hand its bytes to ``consume`` as they arrive.

    A gzip body is decompressed on the way. The body is read in chunks, so a large file
    is never whole in memory unless ``consume`` keeps it. Nothing is cached.

    Parameters
    ----------
    url : str
        The file URL.
    consume : callable
        Takes an iterator over the body's chunks, decompressed, and returns the result.
        It runs again from the start on each retry.
    tries : int
        Tries before giving up; ``1`` leaves retries and backoff to the caller.

    Returns
    -------
    object
        What ``consume`` returned.

    Raises
    ------
    PageNotFound
        If the server answers 404.
    EntrezError
        If the request fails ``tries`` times; the last failure is its ``__cause__``.
    """

    def read(response: Response) -> _T:
        body = io.BufferedReader(_RawBody(response), _CHUNK_BYTES)
        data: io.BufferedIOBase = body
        if body.peek(2)[:2] == b"\x1f\x8b":
            data = gzip.GzipFile(fileobj=body)
        result = consume(iter(lambda: data.read(_CHUNK_BYTES), b""))
        while body.read(_CHUNK_BYTES):  # drain, so the connection can be reused
            pass
        return result

    return self._get(url, read, tries=tries)

ids_empty

ids_empty(err: BaseException) -> bool

Return whether a failed request, or its cause, is NCBI's "ID list is empty" answer.

That answer means no requested id resolves to a record.

Source code in src/labdata/ncbi/entrez.py
def ids_empty(err: BaseException) -> bool:
    """Return whether a failed request, or its cause, is NCBI's "ID list is empty" answer.

    That answer means no requested id resolves to a record.
    """
    return _ids_empty(err) or (err.__cause__ is not None and _ids_empty(err.__cause__))

retry_after

retry_after(err: BaseException) -> float

Return the seconds a throttled reply asked us to wait, or zero.

Source code in src/labdata/ncbi/entrez.py
def retry_after(err: BaseException) -> float:
    """Return the seconds a throttled reply asked us to wait, or zero."""
    headers = getattr(err, "headers", None)
    value = headers.get("Retry-After") if headers is not None else None
    with contextlib.suppress(TypeError, ValueError):
        return max(0.0, float(value))  # type: ignore[arg-type]
    return 0.0

docsum_uid

docsum_uid(docsum: Any) -> str | None

Return the NCBI UID of a single document summary, if present.

Classic DocSum records (gds/sra) carry the UID under the Id key; v2.0 DocumentSummary records (bioproject) carry it as the XML uid attribute, which Biopython exposes via .attributes.

Parameters:

Name Type Description Default
docsum Any

One summary from :meth:EntrezClient.esummary_many.

required

Returns:

Type Description
str or None

The UID, or None when the summary carries none.

Source code in src/labdata/ncbi/entrez.py
def docsum_uid(docsum: Any) -> str | None:
    """Return the NCBI UID of a single document summary, if present.

    Classic ``DocSum`` records (``gds``/``sra``) carry the UID under the ``Id``
    key; v2.0 ``DocumentSummary`` records (``bioproject``) carry it as the XML
    ``uid`` attribute, which Biopython exposes via ``.attributes``.

    Parameters
    ----------
    docsum : Any
        One summary from :meth:`EntrezClient.esummary_many`.

    Returns
    -------
    str or None
        The UID, or ``None`` when the summary carries none.
    """
    uid = docsum.get("Id") if hasattr(docsum, "get") else None
    if not uid:
        attributes = getattr(docsum, "attributes", None) or {}
        uid = attributes.get("uid")
    return str(uid) if uid else None

sdl

A thin, mockable client for NCBI's SRA Data Locator (SDL).

The SRA Run Browser's "Data access" panel — both the normalized SRA archive files and the original format files the submitter uploaded — is served by the SDL service rather than by E-utilities. :class:SdlClient wraps the public sdl/2/retrieve endpoint and returns the file listing as plain :class:RemoteFile objects, so the rest of the package can discover (for example) the original 10X Genomics BAM behind a run whose .sra only stored one read.

Concentrating this network access in one seam keeps callers easy to unit-test by substituting a fake client, mirroring :class:labdata.ncbi.entrez.EntrezClient. Responses are memoized per client instance, keyed by accession.

FileLocation dataclass

FileLocation(service: str, region: str | None, link: str)

One place a :class:RemoteFile can be fetched from.

Parameters:

Name Type Description Default
service str

The hosting service, e.g. "s3", "gs", or "ncbi".

required
region str or None

The cloud region (e.g. "us-east-1"), or None when unspecified.

required
link str

The download URL or cloud URI for this location.

required

RemoteFile dataclass

RemoteFile(
    name: str,
    type: str,
    size: int | None,
    md5: str | None,
    modification_date: str | None,
    accession: str,
    locations: tuple[FileLocation, ...],
    supertype: str | None = None,
)

A single file SDL lists for a run, in any of its locations.

Parameters:

Name Type Description Default
name str

The file name, e.g. "possorted_genome_bam_TC2_d15_1.bam".

required
type str

SDL's file type, e.g. "sra" for the normalized archive or "TenX", "bam", "fastq", … for an original/submitted file.

required
size int or None

File size in bytes, or None when unreported.

required
md5 str or None

The file's MD5 checksum, or None when unreported.

required
modification_date str or None

The file's modification timestamp (ISO 8601 string), or None.

required
accession str

The run accession this file belongs to.

required
locations tuple of FileLocation

Every place the file can be fetched from, in SDL's order.

required
supertype str or None

The SRA package XML's supertype, such as "Original"; SDL gives none.

None

is_sra_archive property

is_sra_archive: bool

Whether this is a normalized SRA-archive file (vs. an original/submitted one).

url property

url: str

The first location's download link, or "" when there are none.

SDL lists the anonymous, worldwide-egress HTTPS link first, so this is the simplest URL to fetch the file from.

SdlClient

SdlClient(
    base_url: str = SDL_RETRIEVE_URL,
    *,
    timeout: float = 30.0,
)

Entry point to NCBI's SRA Data Locator.

Parameters:

Name Type Description Default
base_url str

The retrieve endpoint to query (default :data:SDL_RETRIEVE_URL).

SDL_RETRIEVE_URL
timeout float

Per-request socket timeout, in seconds.

30.0
Source code in src/labdata/ncbi/sdl.py
def __init__(self, base_url: str = SDL_RETRIEVE_URL, *, timeout: float = 30.0) -> None:
    self.base_url = base_url
    self.timeout = timeout
    # Per-instance response cache keyed by accession; redundant lookups across
    # records that share this client are served without a request.
    self._cache: dict[str, list[RemoteFile]] = {}

clear_cache

clear_cache() -> None

Drop all cached responses (forcing fresh requests on the next call).

Source code in src/labdata/ncbi/sdl.py
def clear_cache(self) -> None:
    """Drop all cached responses (forcing fresh requests on the next call)."""
    self._cache.clear()

retrieve

retrieve(accession: str) -> list[RemoteFile]

Return every file SDL lists for accession.

Parameters:

Name Type Description Default
accession str

An SRA run accession, e.g. "SRR20172067".

required

Returns:

Type Description
list of RemoteFile

Both the normalized SRA archive file(s) and any original/submitted files (e.g. a 10X possorted_genome_bam). Cached per client.

Raises:

Type Description
SdlError

If the request fails, the response is not valid JSON, or SDL reports a non-OK status for the accession (e.g. unknown or non-public run).

Source code in src/labdata/ncbi/sdl.py
def retrieve(self, accession: str) -> list[RemoteFile]:
    """Return every file SDL lists for ``accession``.

    Parameters
    ----------
    accession : str
        An SRA run accession, e.g. ``"SRR20172067"``.

    Returns
    -------
    list of RemoteFile
        Both the normalized SRA archive file(s) and any original/submitted
        files (e.g. a 10X ``possorted_genome_bam``). Cached per client.

    Raises
    ------
    SdlError
        If the request fails, the response is not valid JSON, or SDL reports a
        non-OK status for the accession (e.g. unknown or non-public run).
    """
    cached = self._cache.get(accession)
    if cached is None:
        payload = self._fetch(accession)
        cached = self._cache[accession] = _parse_files(payload, accession)
    return list(cached)

config

NCBI E-utilities credential resolution.

NCBI asks every E-utilities client to identify itself with an email address, and optionally accepts an API key that raises the rate limit from 3 to 10 requests per second. This module resolves those two values from, in order:

  1. the NCBI_EMAIL / NCBI_API_KEY environment variables;
  2. a cached config file at <cache>/ncbi.toml;
  3. an interactive prompt (only on a TTY), whose answers are then cached.

In a non-interactive context with nothing configured, :func:load_credentials raises :class:CredentialsError with an actionable message rather than hanging.

NcbiCredentials dataclass

NcbiCredentials(email: str, api_key: str | None = None)

NCBI E-utilities credentials.

Parameters:

Name Type Description Default
email str

Contact email reported to NCBI on every request (required).

required
api_key str or None

Optional NCBI API key; raises the request rate limit when present.

None

config_path

config_path() -> Path

Return the path to the cached credentials file (which may not exist).

Returns:

Type Description
Path

<cache>/ncbi.toml.

Source code in src/labdata/ncbi/config.py
def config_path() -> Path:
    """Return the path to the cached credentials file (which may not exist).

    Returns
    -------
    pathlib.Path
        ``<cache>/ncbi.toml``.
    """
    return liulab_data_cache_dir() / CONFIG_FILENAME

load_credentials

load_credentials(
    *, allow_prompt: bool = True
) -> NcbiCredentials

Resolve NCBI credentials from env vars, the cache file, or a prompt.

Parameters:

Name Type Description Default
allow_prompt bool

When True (default) and nothing is configured, prompt interactively on a TTY and cache the answers. When False, never prompt.

True

Returns:

Type Description
NcbiCredentials

The resolved credentials.

Raises:

Type Description
CredentialsError

If no credentials are configured and prompting is disabled or impossible (e.g. a non-interactive process).

Source code in src/labdata/ncbi/config.py
def load_credentials(*, allow_prompt: bool = True) -> NcbiCredentials:
    """Resolve NCBI credentials from env vars, the cache file, or a prompt.

    Parameters
    ----------
    allow_prompt : bool
        When ``True`` (default) and nothing is configured, prompt interactively
        on a TTY and cache the answers. When ``False``, never prompt.

    Returns
    -------
    NcbiCredentials
        The resolved credentials.

    Raises
    ------
    CredentialsError
        If no credentials are configured and prompting is disabled or impossible
        (e.g. a non-interactive process).
    """
    creds = _from_env() or _from_file()
    if creds is not None:
        return creds

    if allow_prompt and _is_interactive():
        return prompt_and_save()

    raise CredentialsError(
        f"no NCBI credentials found. Set the {EMAIL_ENV} environment variable "
        f"(and optionally {API_KEY_ENV}), or run `labdata ncbi configure`."
    )

save_credentials

save_credentials(creds: NcbiCredentials) -> Path

Persist credentials to the cache file with owner-only permissions.

Parameters:

Name Type Description Default
creds NcbiCredentials

The credentials to write.

required

Returns:

Type Description
Path

The path the credentials were written to.

Source code in src/labdata/ncbi/config.py
def save_credentials(creds: NcbiCredentials) -> Path:
    """Persist credentials to the cache file with owner-only permissions.

    Parameters
    ----------
    creds : NcbiCredentials
        The credentials to write.

    Returns
    -------
    pathlib.Path
        The path the credentials were written to.
    """
    ensure_cache_dir()
    path = config_path()
    path.write_text(_to_toml(creds), encoding="utf-8")
    path.chmod(0o600)
    return path

prompt_and_save

prompt_and_save() -> NcbiCredentials

Prompt for credentials interactively and cache them.

Unlike :func:load_credentials, this always prompts (overwriting any cached file), which is what labdata ncbi configure wants.

Returns:

Type Description
NcbiCredentials

The entered credentials, already written to the cache file.

Source code in src/labdata/ncbi/config.py
def prompt_and_save() -> NcbiCredentials:
    """Prompt for credentials interactively and cache them.

    Unlike :func:`load_credentials`, this always prompts (overwriting any cached
    file), which is what ``labdata ncbi configure`` wants.

    Returns
    -------
    NcbiCredentials
        The entered credentials, already written to the cache file.
    """
    creds = _prompt()
    save_credentials(creds)
    return creds

root

Find the store root and check its marker.

The per-user pointer file follows the ncbi.toml pattern and holds only the root; store settings live in the store's own labdata-store.toml.

Store

Store(root: Path, settings: dict[str, Any])

An opened store, reading the build its marker named when it was opened.

Parameters:

Name Type Description Default
root Path

The store's root folder.

required
settings dict

The parsed labdata-store.toml.

required
Source code in src/labdata/store/root.py
def __init__(self, root: Path, settings: dict[str, Any]) -> None:
    self.root = root
    self.settings = settings
    self._reader: BuildReader | None = None

reader property

reader: BuildReader

The reader over the build the marker named at open, opened on first use.

Raises:

Type Description
NotAStoreError

If the marker names no build.

__repr__

__repr__() -> str

Return the root and the build.

Source code in src/labdata/store/root.py
def __repr__(self) -> str:
    """Return the root and the build."""
    return f"Store({str(self.root)!r}, build={self.settings.get('build')!r})"

open_many

open_many(
    accessions: Iterable[str],
) -> dict[str, Record | LabdataError]

Open many records: one record lookup and one link lookup for the whole batch.

Parameters:

Name Type Description Default
accessions iterable of str

Public accessions of any record type.

required

Returns:

Type Description
dict

Each accession, in input order, mapped to its Record or to the error that stopped it (AccessionError, NotMirrored or NotFound).

Source code in src/labdata/store/root.py
def open_many(self, accessions: Iterable[str]) -> dict[str, Record | LabdataError]:
    """Open many records: one record lookup and one link lookup for the whole batch.

    Parameters
    ----------
    accessions : iterable of str
        Public accessions of any record type.

    Returns
    -------
    dict
        Each accession, in input order, mapped to its ``Record`` or to the error that
        stopped it (``AccessionError``, ``NotMirrored`` or ``NotFound``).
    """
    order = [_norm(a) for a in accessions]
    out: dict[str, Record | LabdataError] = {}
    valid: list[str] = []
    for accession in order:
        try:
            kind = kind_of(accession)
            if kind == PAPER:
                raise AccessionError(
                    f"{accession} is a paper, not a record; follow 'PubMed' or 'DOI'"
                )
            if kind == ANNOTATION:
                raise AccessionError(
                    f"{accession} is a GEO named annotation, not a record; "
                    "follow 'Named Annotation'"
                )
            valid.append(accession)
        except AccessionError as err:
            out[accession] = err
    records = self.reader.records(valid) if valid else {}
    by_end = self._links(records) if records else {}
    absent = self._absent_errors(set(valid) - set(records))
    for accession in valid:
        record = records.get(accession)
        out[accession] = (
            absent[accession]
            if record is None
            else replace(record, links=tuple(by_end[accession]))
        )
    return {a: out[a] for a in order}

open

open(accession: str) -> Record

Open one record by public accession; open_many of one.

Raises:

Type Description
AccessionError

If the accession is malformed or names a paper.

NotMirrored

If a snapshot lists it but the store does not hold it.

NotFound

If no snapshot lists it as public. The message gives the snapshot date.

Source code in src/labdata/store/root.py
def open(self, accession: str) -> Record:
    """Open one record by public accession; ``open_many`` of one.

    Raises
    ------
    AccessionError
        If the accession is malformed or names a paper.
    NotMirrored
        If a snapshot lists it but the store does not hold it.
    NotFound
        If no snapshot lists it as public. The message gives the snapshot date.
    """
    [result] = self.open_many([accession]).values()
    if isinstance(result, LabdataError):
        raise result
    return result
links(
    accessions: str | Iterable[str],
    relation: str | None = None,
) -> dict[str, list[Link]]

Map each accession to its link rows as read from its end, in one reader call.

A far end may be an accession the store does not hold.

Parameters:

Name Type Description Default
accessions str or iterable of str

Accessions of any record type, PMID:<n> or DOI:<doi>.

required
relation str or None

Keep only links of this name as read from the given end, forward or reverse.

None

Returns:

Type Description
dict

Each accession, in input order, mapped to its links sorted by relation, far end and source.

Raises:

Type Description
AccessionError

If an accession is malformed.

ValueError

If the relation name is unknown.

Source code in src/labdata/store/root.py
def links(
    self, accessions: str | Iterable[str], relation: str | None = None
) -> dict[str, list[Link]]:
    """Map each accession to its link rows as read from its end, in one reader call.

    A far end may be an accession the store does not hold.

    Parameters
    ----------
    accessions : str or iterable of str
        Accessions of any record type, ``PMID:<n>`` or ``DOI:<doi>``.
    relation : str or None
        Keep only links of this name as read from the given end, forward or reverse.

    Returns
    -------
    dict
        Each accession, in input order, mapped to its links sorted by relation, far end
        and source.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    ValueError
        If the relation name is unknown.
    """
    accs = _accessions(accessions)
    relations = None if relation is None else [forward_name(relation)]
    found = self._links(set(accs), relations)
    return {
        a: [k for k in found[a] if relation is None or k.relation == relation] for a in accs
    }

follow

follow(
    accessions: str | Iterable[str], relation: str
) -> dict[str, list[str]]

Map each accession to the sorted accessions at the far end of one relation.

Reads links only, in one reader call, so a far end may be an accession the store does not hold. relation takes a forward or a reverse name.

Raises:

Type Description
AccessionError

If an accession is malformed.

ValueError

If the relation name is unknown.

Source code in src/labdata/store/root.py
def follow(self, accessions: str | Iterable[str], relation: str) -> dict[str, list[str]]:
    """Map each accession to the sorted accessions at the far end of one relation.

    Reads links only, in one reader call, so a far end may be an accession the store
    does not hold. ``relation`` takes a forward or a reverse name.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    ValueError
        If the relation name is unknown.
    """
    return {
        a: sorted({k.other for k in links})
        for a, links in self.links(accessions, relation).items()
    }

raw

raw(accessions: Iterable[str]) -> dict[str, RawText]

Map each accession to its raw source text and content_hash, in one call.

Raises:

Type Description
AccessionError

If an accession is malformed.

NotMirrored

If a snapshot lists an accession the store does not hold. The message lists every such accession.

NotFound

If no snapshot lists an accession as public. The message gives the snapshot date.

Source code in src/labdata/store/root.py
def raw(self, accessions: Iterable[str]) -> dict[str, RawText]:
    """Map each accession to its raw source text and ``content_hash``, in one call.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    NotMirrored
        If a snapshot lists an accession the store does not hold. The message lists
        every such accession.
    NotFound
        If no snapshot lists an accession as public. The message gives the snapshot date.
    """
    wanted = set(_accessions(accessions))
    texts = self.reader.raw(wanted) if wanted else {}
    self._raise_absent(wanted - set(texts))
    return dict(sorted(texts.items()))

supplementary_files

supplementary_files(
    accessions: Iterable[str],
) -> dict[str, list[SupplementaryFile]]

Map each accession to the supplementary files its SOFT lists, in two reader calls.

A Series lists its own files; its samples' files are under the samples. An accession the store holds with no files maps to an empty list.

Parameters:

Name Type Description Default
accessions iterable of str

GEO accessions, in the order wanted back.

required

Returns:

Type Description
dict

Each accession, in input order, mapped to its files sorted by name.

Raises:

Type Description
AccessionError

If an accession is malformed.

NotMirrored

If a snapshot lists an accession the store does not hold. The message lists every such accession.

NotFound

If no snapshot lists an accession as public. The message gives the snapshot date.

Source code in src/labdata/store/root.py
def supplementary_files(self, accessions: Iterable[str]) -> dict[str, list[SupplementaryFile]]:
    """Map each accession to the supplementary files its SOFT lists, in two reader calls.

    A Series lists its own files; its samples' files are under the samples. An
    accession the store holds with no files maps to an empty list.

    Parameters
    ----------
    accessions : iterable of str
        GEO accessions, in the order wanted back.

    Returns
    -------
    dict
        Each accession, in input order, mapped to its files sorted by name.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    NotMirrored
        If a snapshot lists an accession the store does not hold. The message lists
        every such accession.
    NotFound
        If no snapshot lists an accession as public. The message gives the snapshot date.
    """
    order = _accessions(accessions)
    self._require_held(set(order))
    out: dict[str, list[SupplementaryFile]] = {a: [] for a in order}
    for file in self.reader.supplementary_files(set(order)) if order else []:
        out[file.owner].append(file)
    return out

experiments_for_many

experiments_for_many(
    accessions: Iterable[str],
    *,
    partial: Literal[False] = False,
) -> dict[str, list[Experiment]]
experiments_for_many(
    accessions: Iterable[str], *, partial: Literal[True]
) -> Partial[dict[str, list[Experiment]]]
experiments_for_many(
    accessions: Iterable[str], *, partial: bool = False
) -> (
    dict[str, list[Experiment]]
    | Partial[dict[str, list[Experiment]]]
)

Map each accession to the SRA experiments under it, built from stored packages.

Walks down one level at a time, with one link lookup and one holdings lookup per level for all roots together.

Parameters:

Name Type Description Default
accessions iterable of str

The roots of the walk.

required
partial bool

Return what is held instead of raising NotMirrored.

False

Returns:

Type Description
dict or Partial

Each root mapped to its sorted experiments. With partial=True, a Partial whose held is that mapping over held records and whose missing lists every accession the walk reached but the store does not hold.

Raises:

Type Description
AccessionError

If an accession is malformed.

NotFound

If no snapshot lists a root as public, even with partial.

NotMirrored

If a root or a record on the way down is not held, unless partial. The message lists every missing accession.

Source code in src/labdata/store/root.py
def experiments_for_many(
    self, accessions: Iterable[str], *, partial: bool = False
) -> dict[str, list[Experiment]] | Partial[dict[str, list[Experiment]]]:
    """Map each accession to the SRA experiments under it, built from stored packages.

    Walks down one level at a time, with one link lookup and one holdings lookup per
    level for all roots together.

    Parameters
    ----------
    accessions : iterable of str
        The roots of the walk.
    partial : bool
        Return what is held instead of raising ``NotMirrored``.

    Returns
    -------
    dict or Partial
        Each root mapped to its sorted experiments. With ``partial=True``, a ``Partial``
        whose ``held`` is that mapping over held records and whose ``missing`` lists every
        accession the walk reached but the store does not hold.

    Raises
    ------
    AccessionError
        If an accession is malformed.
    NotFound
        If no snapshot lists a root as public, even with ``partial``.
    NotMirrored
        If a root or a record on the way down is not held, unless ``partial``. The
        message lists every missing accession.
    """
    roots = _accessions(accessions)
    missing = self._require_held(set(roots), not_held_ok=True)
    found: dict[str, set[str]] = {r: set() for r in roots}
    level = {(r, r) for r in roots if r not in missing}
    seen: set[tuple[str, str]] = set()
    while level:
        seen |= level
        todo = set()
        for root, acc in level:
            if kind_of(acc) == "SRA experiment":
                found[root].add(acc)
            else:
                todo.add((root, acc))
        if not todo:
            break
        children: dict[str, set[str]] = {}
        for row in self.reader.links({acc for _, acc in todo}):
            for end, link in _ends(row):
                if _leads_down(kind_of(end), link):
                    children.setdefault(end, set()).add(link.other)
        reached = {c for cs in children.values() for c in cs}
        held = self.reader.held(reached) if reached else set()
        missing |= reached - held
        level = {
            (root, child)
            for root, acc in todo
            for child in children.get(acc, ())
            if child in held
        } - seen
    if missing and not partial:
        raise NotMirrored(f"not held in this store: {', '.join(sorted(missing))}")
    wanted = {acc for accs in found.values() for acc in accs}
    packages = {a: raw.text for a, raw in (self.reader.raw(wanted) if wanted else {}).items()}
    built = {
        experiment.accession: experiment
        for experiment in experiments_from_package_xml(
            "".join(packages.values()),
            client=OfflineEntrezClient(),
            sdl_client=StoredSdlClient(packages),
        )
    }
    result = {r: [built[a] for a in sorted(found[r]) if a in built] for r in roots}
    return Partial(result, tuple(sorted(missing))) if partial else result

experiments_for

experiments_for(
    accession: str, *, partial: Literal[False] = False
) -> list[Experiment]
experiments_for(
    accession: str, *, partial: Literal[True]
) -> Partial[list[Experiment]]
experiments_for(
    accession: str, *, partial: bool = False
) -> list[Experiment] | Partial[list[Experiment]]

Return the SRA experiments under one accession, sorted, with no network call.

The store twin of :func:labdata.experiments_for: the same Experiment type, built from the stored package XML by the same parser. Their properties, and their runs', never call NCBI. Run.files and Run.original_files list the <SRAFiles> entries of the stored XML, with only the URLs it carries; a live Run gives the SRA Data Locator's locations. With partial=True it returns a Partial of that list and the missing accessions.

Raises:

Type Description
AccessionError

If the accession is malformed.

NotFound

If no snapshot lists the accession as public.

NotMirrored

If the accession, or a record under it, is not held (unless partial).

Source code in src/labdata/store/root.py
def experiments_for(
    self, accession: str, *, partial: bool = False
) -> list[Experiment] | Partial[list[Experiment]]:
    """Return the SRA experiments under one accession, sorted, with no network call.

    The store twin of :func:`labdata.experiments_for`: the same ``Experiment`` type,
    built from the stored package XML by the same parser. Their properties, and their
    runs', never call NCBI. ``Run.files`` and ``Run.original_files`` list the
    ``<SRAFiles>`` entries of the stored XML, with only the URLs it carries; a live
    ``Run`` gives the SRA Data Locator's locations.
    With ``partial=True`` it returns a ``Partial`` of that list and the missing
    accessions.

    Raises
    ------
    AccessionError
        If the accession is malformed.
    NotFound
        If no snapshot lists the accession as public.
    NotMirrored
        If the accession, or a record under it, is not held (unless ``partial``).
    """
    key = _norm(accession)
    if partial:
        walk = self.experiments_for_many([accession], partial=True)
        return Partial(walk.held[key], walk.missing)
    return self.experiments_for_many([accession])[key]

table

table(
    type: str,
    fields: Sequence[str],
    where: str | None = None,
) -> pa.Table

Return one record type as a pyarrow.Table, sorted by accession, in one call.

The only call that imports pyarrow.

Parameters:

Name Type Description Default
type str

A record type, such as "GEO Series" or "SRA experiment".

required
fields sequence of str

Raw field names. Each becomes a list column of every value, in source order.

required
where str or None

A DuckDB condition over the columns, such as "published >= '2020-01-01'". It is trusted SQL from the caller, pasted into the query as written.

None

Returns:

Type Description
Table

accession, type, status, submitted, updated, published, content_hash and snapshot_date, then one column per field.

Raises:

Type Description
ValueError

If the record type is unknown, or where holds a ;.

Source code in src/labdata/store/root.py
def table(self, type: str, fields: Sequence[str], where: str | None = None) -> pa.Table:
    """Return one record type as a ``pyarrow.Table``, sorted by accession, in one call.

    The only call that imports pyarrow.

    Parameters
    ----------
    type : str
        A record type, such as ``"GEO Series"`` or ``"SRA experiment"``.
    fields : sequence of str
        Raw field names. Each becomes a list column of every value, in source order.
    where : str or None
        A DuckDB condition over the columns, such as ``"published >= '2020-01-01'"``.
        It is trusted SQL from the caller, pasted into the query as written.

    Returns
    -------
    pyarrow.Table
        ``accession``, ``type``, ``status``, ``submitted``, ``updated``, ``published``,
        ``content_hash`` and ``snapshot_date``, then one column per field.

    Raises
    ------
    ValueError
        If the record type is unknown, or ``where`` holds a ``;``.
    """
    if type not in _SNAPSHOT_OF:
        raise ValueError(f"unknown record type {type!r}; known: {sorted(_SNAPSHOT_OF)}")
    if where is not None and ";" in where:
        raise ValueError(f"where must be one condition, without ';': {where!r}")
    return self.reader.table(type, list(fields), where)

open_store

open_store(
    root: str | PathLike[str] | None = None,
) -> Store

Open the store, resolving its root without network or prompt.

The root is the first of: the root argument, LABDATA_STORE, then the per-user pointer file written by labdata store configure. Opening writes nothing in the store.

Parameters:

Name Type Description Default
root str or PathLike or None

The store root. None falls back to the environment, then the pointer file.

None

Returns:

Type Description
Store

The opened store.

Raises:

Type Description
StoreNotConfiguredError

If no root is set anywhere.

NotAStoreError

If the root has no labdata-store.toml.

Source code in src/labdata/store/root.py
def open_store(root: str | os.PathLike[str] | None = None) -> Store:
    """Open the store, resolving its root without network or prompt.

    The root is the first of: the ``root`` argument, ``LABDATA_STORE``, then the per-user
    pointer file written by ``labdata store configure``. Opening writes nothing in the store.

    Parameters
    ----------
    root : str or os.PathLike or None
        The store root. ``None`` falls back to the environment, then the pointer file.

    Returns
    -------
    Store
        The opened store.

    Raises
    ------
    StoreNotConfiguredError
        If no root is set anywhere.
    NotAStoreError
        If the root has no ``labdata-store.toml``.
    """
    path = resolve_root(root, STORE_ENV, pointer_path(), "store")
    if path is None:
        raise StoreNotConfiguredError(
            f"store not configured: pass open_store(root=...), set {STORE_ENV}, "
            "or run `labdata store configure <root>`"
        )
    return Store(root=path, settings=read_marker(path))

resolve_root

resolve_root(
    root: str | PathLike[str] | None,
    env: str,
    pointer: Path,
    section: str,
) -> Path | None

Return a root: root, else the env variable, else the pointer file's.

Parameters:

Name Type Description Default
root str or PathLike or None

The root as given; None falls back.

required
env str

The environment variable naming the root.

required
pointer Path

The per-user pointer file, which holds root under [section].

required
section str

The pointer file's table.

required

Returns:

Type Description
Path or None

None when no root is set anywhere.

Source code in src/labdata/store/root.py
def resolve_root(
    root: str | os.PathLike[str] | None, env: str, pointer: Path, section: str
) -> Path | None:
    """Return a root: ``root``, else the ``env`` variable, else the pointer file's.

    Parameters
    ----------
    root : str or os.PathLike or None
        The root as given; ``None`` falls back.
    env : str
        The environment variable naming the root.
    pointer : pathlib.Path
        The per-user pointer file, which holds ``root`` under ``[section]``.
    section : str
        The pointer file's table.

    Returns
    -------
    pathlib.Path or None
        ``None`` when no root is set anywhere.
    """
    if root is None:
        root = os.environ.get(env, "").strip() or _from_pointer(pointer, section)
    return None if root is None else Path(root)

read_marker

read_marker(root: Path) -> dict[str, Any]

Return the store's parsed labdata-store.toml.

Raises:

Type Description
NotAStoreError

If the root has no marker.

Source code in src/labdata/store/root.py
def read_marker(root: Path) -> dict[str, Any]:
    """Return the store's parsed ``labdata-store.toml``.

    Raises
    ------
    NotAStoreError
        If the root has no marker.
    """
    return read_toml_marker(root, MARKER, "a labdata store")

read_toml_marker

read_toml_marker(
    root: Path, marker: str, what: str
) -> dict[str, Any]

Return a root's parsed marker file, marker.

Raises:

Type Description
NotAStoreError

If the root has no marker; the message says the root is not what.

Source code in src/labdata/store/root.py
def read_toml_marker(root: Path, marker: str, what: str) -> dict[str, Any]:
    """Return a root's parsed marker file, ``marker``.

    Raises
    ------
    NotAStoreError
        If the root has no marker; the message says the root is not ``what``.
    """
    path = root / marker
    if not path.is_file():
        raise NotAStoreError(f"{root} is not {what}: it has no {marker}")
    with path.open("rb") as fh:
        return tomllib.load(fh)

init_store

init_store(root: str | PathLike[str]) -> Path

Create the store root if missing and write its marker, format_version = 1.

The marker appears whole or not at all, and nothing else in the root is touched.

Parameters:

Name Type Description Default
root str or PathLike

The store root.

required

Returns:

Type Description
Path

The marker written.

Raises:

Type Description
LabdataError

If the root already has a marker.

Source code in src/labdata/store/root.py
def init_store(root: str | os.PathLike[str]) -> Path:
    """Create the store root if missing and write its marker, ``format_version = 1``.

    The marker appears whole or not at all, and nothing else in the root is touched.

    Parameters
    ----------
    root : str or os.PathLike
        The store root.

    Returns
    -------
    pathlib.Path
        The marker written.

    Raises
    ------
    LabdataError
        If the root already has a marker.
    """
    path = Path(root)
    path.mkdir(parents=True, exist_ok=True)
    marker = path / MARKER
    create_marker(marker, f"format_version = {FORMAT_VERSION}\n", "store")
    return marker

create_marker

create_marker(marker: Path, text: str, what: str) -> None

Write a marker file whole, or fail if it exists, so two inits cannot both win.

Raises:

Type Description
LabdataError

If the marker already exists; the message names what is initialised.

Source code in src/labdata/store/root.py
def create_marker(marker: Path, text: str, what: str) -> None:
    """Write a marker file whole, or fail if it exists, so two inits cannot both win.

    Raises
    ------
    LabdataError
        If the marker already exists; the message names ``what`` is initialised.
    """
    tmp = temp_beside(marker, "init")
    tmp.write_text(text, encoding="utf-8")
    try:
        # A hard link fails if the marker exists.
        os.link(tmp, marker)
    except FileExistsError:
        raise LabdataError(f"{marker} already exists; this {what} is initialised") from None
    finally:
        tmp.unlink(missing_ok=True)

pointer_path

pointer_path() -> Path

Return the path to the per-user pointer file (which may not exist).

Returns:

Type Description
Path

<cache>/store.toml.

Source code in src/labdata/store/root.py
def pointer_path() -> Path:
    """Return the path to the per-user pointer file (which may not exist).

    Returns
    -------
    pathlib.Path
        ``<cache>/store.toml``.
    """
    return liulab_data_cache_dir() / POINTER_FILENAME

save_pointer

save_pointer(root: str | PathLike[str]) -> Path

Write the per-user pointer file, which holds only the store root.

Parameters:

Name Type Description Default
root str or PathLike

The store root; saved as an absolute path.

required

Returns:

Type Description
Path

The path the pointer was written to.

Source code in src/labdata/store/root.py
def save_pointer(root: str | os.PathLike[str]) -> Path:
    """Write the per-user pointer file, which holds only the store root.

    Parameters
    ----------
    root : str or os.PathLike
        The store root; saved as an absolute path.

    Returns
    -------
    pathlib.Path
        The path the pointer was written to.
    """
    return write_pointer(pointer_path(), "store", root)

write_pointer

write_pointer(
    path: Path, section: str, root: str | PathLike[str]
) -> Path

Write a per-user pointer file holding only root, absolute, under [section].

Source code in src/labdata/store/root.py
def write_pointer(path: Path, section: str, root: str | os.PathLike[str]) -> Path:
    """Write a per-user pointer file holding only ``root``, absolute, under ``[section]``."""
    ensure_cache_dir()
    absolute = Path(root).expanduser().absolute()
    write_atomic(path, f"[{section}]\n{toml_line('root', str(absolute))}\n")
    return path

pipeline

Drive one round of the full run with Snakemake: fetch, pack, extract, build, prune, copy.

The DAG lives in the packaged pipeline.smk. Each step is a labdata store subcommand, so labdata owns what a step does and Snakemake owns the order and the resume: a step's marker appears only when the step exits cleanly, and a rerun starts at the first step without one. An ncbi resource caps the network steps apart from --cores, and a mem_mb resource keeps the steps that run at once inside the job's memory.

A round's markers, logs and Snakemake state sit in <root>/work/run/<round>/, where the round names the geo-series snapshot date and the sample fraction, e.g. 2026-10-14-f0.01. Every snakemake call goes through :func:_run_snakemake, the seam tests replace.

memory_plan

memory_plan(total_mb: int) -> dict[str, int]

Return each step's memory in MB, so the steps that can run at once fit in total_mb.

The fetch lanes, the target list and pack run beside a snapshot extract and take a small share each; the extract takes the rest. The build and coverage run alone with all of it.

Source code in src/labdata/store/pipeline.py
def memory_plan(total_mb: int) -> dict[str, int]:
    """Return each step's memory in MB, so the steps that can run at once fit in ``total_mb``.

    The fetch lanes, the target list and pack run beside a snapshot extract and take a small
    share each; the extract takes the rest. The build and coverage run alone with all of it.
    """
    geo, lane = total_mb // 16, total_mb // 8
    return {
        "targets": lane,
        "geo": geo,
        "eutils": lane,
        "pack": lane,
        "extract": total_mb - geo - lane,
        "build": total_mb,
        "coverage": total_mb,
        "prune": lane,
        "copy": lane,
    }

round_dir

round_dir(
    root: Path, date: str, fraction: float | None
) -> Path

Return the folder of one round's markers, logs and Snakemake state.

Source code in src/labdata/store/pipeline.py
def round_dir(root: Path, date: str, fraction: float | None) -> Path:
    """Return the folder of one round's markers, logs and Snakemake state."""
    return root / "work" / "run" / f"{date}-{targets.fraction_name(fraction)}"

run

run(
    root: Path,
    *,
    fraction: float | None = None,
    cores: int | None = None,
    ncbi: int = NCBI_STEPS,
    verbose: bool = True,
) -> int

Run one round: the seeded sample of fraction of the target list, or all of it.

The round uses the newest snapshot of every source and records their dates in run.json in its folder. A rerun of the same round resumes at the first unfinished step; the fetch steps skip every item already saved, by this round or a smaller one. A fetch lane that left items failed runs again, and so does every step after it.

Parameters:

Name Type Description Default
root Path

The store root.

required
fraction float or None

The round's sample of the target list, in (0, 1]; None or 1 for all of it.

None
cores int or None

Cores the steps may use; defaults to :func:labdata.store._limits.default_workers.

None
ncbi int

Network steps that may run at once.

NCBI_STEPS
verbose bool

Show Snakemake's and each step's output.

True

Returns:

Type Description
int

Snakemake's exit code: 0 when every step finished, coverage included.

Raises:

Type Description
StoreBuildError

If a snapshot source has no snapshot.

ValueError

If the fraction is not in (0, 1].

Source code in src/labdata/store/pipeline.py
def run(
    root: Path,
    *,
    fraction: float | None = None,
    cores: int | None = None,
    ncbi: int = NCBI_STEPS,
    verbose: bool = True,
) -> int:
    """Run one round: the seeded sample of ``fraction`` of the target list, or all of it.

    The round uses the newest snapshot of every source and records their dates in
    ``run.json`` in its folder. A rerun of the same round resumes at the first unfinished
    step; the fetch steps skip every item already saved, by this round or a smaller one. A
    fetch lane that left items failed runs again, and so does every step after it.

    Parameters
    ----------
    root : pathlib.Path
        The store root.
    fraction : float or None
        The round's sample of the target list, in (0, 1]; ``None`` or ``1`` for all of it.
    cores : int or None
        Cores the steps may use; defaults to :func:`labdata.store._limits.default_workers`.
    ncbi : int
        Network steps that may run at once.
    verbose : bool
        Show Snakemake's and each step's output.

    Returns
    -------
    int
        Snakemake's exit code: ``0`` when every step finished, coverage included.

    Raises
    ------
    StoreBuildError
        If a snapshot source has no snapshot.
    ValueError
        If the fraction is not in (0, 1].
    """
    fraction = targets.check_fraction(fraction)
    store = open_store(root)
    root = store.root
    dates = {source: snapshots.snapshot(root, source).date for source in extract.SOURCES}
    geo_date = dates[snapshots.GEO_SERIES]
    work = round_dir(root, geo_date, fraction)
    work.mkdir(parents=True, exist_ok=True)
    # --forcerun reruns a rule and every rule after it, even where their markers exist.
    rerun = []
    for lane, rule in LANES.items():
        if (work / f"{lane}.failed").is_file():
            logger.info("the %s lane left items failed last time; it runs again", lane)
            rerun.append(rule)
    cores = cores or _limits.default_workers()
    memory = int(_limits.allocation_mb() * STEPS_SHARE)
    labdata = f"{shlex.quote(sys.executable)} -m labdata"
    config = {
        "labdata": labdata,
        "root": str(root),
        "fraction": "" if fraction is None else repr(fraction),
        "snapshots": dates,
        "targets": str(targets.list_path(root, geo_date, fraction)),
        "copy": bool(store.settings.get(gcs.COPY_TO_KEY)),
        "mem_mb": memory_plan(memory),
    }
    config_path = work / "config.json"
    config_path.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n")
    started = dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
    record = {"fraction": fraction, "snapshots": dates, "started": started}
    (work / "run.json").write_text(json.dumps(record, indent=2, sort_keys=True) + "\n")
    logger.info("round %s: snapshots %s", work.name, dates)
    argv = [
        "--snakefile",
        str(_snakefile()),
        "--directory",
        str(work),
        "--cores",
        str(cores),
        "--resources",
        f"ncbi={ncbi}",
        f"mem_mb={memory}",
        "--configfile",
        str(config_path),
        "--printshellcmds",
        *RESILIENCE_FLAGS,
        *(["--forcerun", *rerun] if rerun else []),
    ]
    return _run_snakemake(argv, verbose=verbose)

targets

The target Series list: the sequencing Series of one dated geo-series snapshot.

A Series is a target when its esummary gdstype names high-throughput sequencing, whatever its SOFT !Series_type says. The list is streamed from the snapshot into <root>/work/targets/geo-series-<date>.parquet once per snapshot date; a build or a coverage check remakes it when missing.

A round of the full run works through a seeded sample of the list. A Series is in the sample of fraction f when its seeded hash falls below f, so a smaller round's sample lies inside every larger one's, whatever the list's order or length.

BuildSource dataclass

BuildSource(root: Path, sample: float | None)

Where a build reads raw/ and snapshots/ from, and the share of them it keeps.

Attributes:

Name Type Description
root Path

The store root read.

sample float or None

The seeded share of raw rows and snapshot records kept (see :func:in_sample); None keeps all.

Tally dataclass

Tally(kept: int = 0, dropped: int = 0)

How many accessions an in_sample SQL function kept and dropped.

in_sample

in_sample(accession: str, fraction: float) -> bool

Return whether an accession is in the seeded sample of fraction.

The same accession and fraction always give the same answer, and an accession in the sample of a smaller fraction is in the sample of every larger one.

Examples:

>>> in_sample("GSE1", 1.0)
True
Source code in src/labdata/store/targets.py
def in_sample(accession: str, fraction: float) -> bool:
    """Return whether an accession is in the seeded sample of ``fraction``.

    The same accession and fraction always give the same answer, and an accession in the
    sample of a smaller fraction is in the sample of every larger one.

    Examples
    --------
    >>> in_sample("GSE1", 1.0)
    True
    """
    digest = hashlib.blake2b(f"{SEED}:{accession}".encode(), digest_size=8).digest()
    return int.from_bytes(digest) < fraction * 2**64

add_in_sample

add_in_sample(
    con: DuckDBPyConnection, fraction: float
) -> Tally

Add the SQL function in_sample(accession) to a connection.

It answers as :func:in_sample at fraction, a whole column batch per call, and returns false for NULL.

Parameters:

Name Type Description Default
con DuckDBPyConnection

The connection to add it to.

required
fraction float

The sample's fraction.

required

Returns:

Type Description
Tally

The accessions the function has kept and dropped, counted as queries call it.

Source code in src/labdata/store/targets.py
def add_in_sample(con: duckdb.DuckDBPyConnection, fraction: float) -> Tally:
    """Add the SQL function ``in_sample(accession)`` to a connection.

    It answers as :func:`in_sample` at ``fraction``, a whole column batch per call, and
    returns false for NULL.

    Parameters
    ----------
    con : duckdb.DuckDBPyConnection
        The connection to add it to.
    fraction : float
        The sample's fraction.

    Returns
    -------
    Tally
        The accessions the function has kept and dropped, counted as queries call it.
    """
    tally = Tally()
    lock = threading.Lock()

    def keep(accessions: pa.Array) -> pa.Array:
        kept = [a is not None and in_sample(a, fraction) for a in accessions.to_pylist()]
        with lock:
            tally.kept += sum(kept)
            tally.dropped += len(kept) - sum(kept)
        return pa.array(kept, pa.bool_())

    from duckdb.func import PythonUDFType
    from duckdb.sqltypes import BOOLEAN, VARCHAR

    con.create_function("in_sample", keep, [VARCHAR], BOOLEAN, type=PythonUDFType.ARROW)
    return tally

check_fraction

check_fraction(fraction: float | None) -> float | None

Return fraction, or None for the whole list (None or 1).

Raises:

Type Description
ValueError

If the fraction is not in (0, 1].

Source code in src/labdata/store/targets.py
def check_fraction(fraction: float | None) -> float | None:
    """Return ``fraction``, or ``None`` for the whole list (``None`` or ``1``).

    Raises
    ------
    ValueError
        If the fraction is not in (0, 1].
    """
    if fraction is None or fraction == 1:
        return None
    if not 0 < fraction < 1:
        raise ValueError(f"a round's fraction must be in (0, 1], not {fraction}")
    return fraction

fraction_name

fraction_name(fraction: float | None) -> str

Return the file-name tag of a round's sample: all, or e.g. f0.01.

Source code in src/labdata/store/targets.py
def fraction_name(fraction: float | None) -> str:
    """Return the file-name tag of a round's sample: ``all``, or e.g. ``f0.01``."""
    fraction = check_fraction(fraction)
    return "all" if fraction is None else f"f{fraction:g}"

is_target

is_target(gdstype: str) -> bool

Return whether an esummary gdstype puts a Series on the target list.

Source code in src/labdata/store/targets.py
def is_target(gdstype: str) -> bool:
    """Return whether an ``esummary`` ``gdstype`` puts a Series on the target list."""
    return SEQUENCING in gdstype.lower()

list_path

list_path(
    root: Path, date: str, fraction: float | None = None
) -> Path

Return where :func:target_list writes the list of one snapshot date and fraction.

Source code in src/labdata/store/targets.py
def list_path(root: Path, date: str, fraction: float | None = None) -> Path:
    """Return where :func:`target_list` writes the list of one snapshot date and fraction."""
    fraction = check_fraction(fraction)
    tag = "" if fraction is None else f"-{fraction_name(fraction)}"
    return root / "work" / "targets" / f"geo-series-{date}{tag}.parquet"

target_list

target_list(
    root: Path,
    date: str | None = None,
    fraction: float | None = None,
    *,
    source: BuildSource | None = None,
) -> Path

Return the target Series list of one geo-series snapshot, writing it if missing.

Parameters:

Name Type Description Default
root Path

The store root.

required
date str or None

The snapshot date; defaults to the newest snapshot.

None
fraction float or None

A round's seeded sample of the list (see :func:in_sample); None or 1 for all.

None
source BuildSource or None

Where the snapshot is read; defaults to root, which holds the list.

None

Returns:

Type Description
Path

A Parquet file of accession, gdstype and bioproject (empty when the snapshot gives none), one row per target Series, in snapshot order.

Raises:

Type Description
StoreBuildError

If the store has no such snapshot.

ValueError

If the fraction is not in (0, 1].

Source code in src/labdata/store/targets.py
def target_list(
    root: Path,
    date: str | None = None,
    fraction: float | None = None,
    *,
    source: BuildSource | None = None,
) -> Path:
    """Return the target Series list of one ``geo-series`` snapshot, writing it if missing.

    Parameters
    ----------
    root : pathlib.Path
        The store root.
    date : str or None
        The snapshot date; defaults to the newest snapshot.
    fraction : float or None
        A round's seeded sample of the list (see :func:`in_sample`); ``None`` or ``1`` for all.
    source : BuildSource or None
        Where the snapshot is read; defaults to ``root``, which holds the list.

    Returns
    -------
    pathlib.Path
        A Parquet file of ``accession``, ``gdstype`` and ``bioproject`` (empty when the
        snapshot gives none), one row per target Series, in snapshot order.

    Raises
    ------
    StoreBuildError
        If the store has no such snapshot.
    ValueError
        If the fraction is not in (0, 1].
    """
    fraction = check_fraction(fraction)
    snap = snapshots.snapshot(root if source is None else source.root, snapshots.GEO_SERIES, date)
    out = list_path(root, snap.date, fraction)
    if out.is_file():
        return out
    out.parent.mkdir(parents=True, exist_ok=True)
    tmp = temp_beside(out)
    try:
        with pq.ParquetWriter(tmp, SCHEMA) as writer:
            batch: list[tuple[str, str, str]] = []
            for row in _targets(snap.path):
                if fraction is not None and not in_sample(row[0], fraction):
                    continue
                batch.append(row)
                if len(batch) == _BATCH:
                    writer.write_table(_table(batch))
                    batch = []
            writer.write_table(_table(batch))
        tmp.replace(out)
    finally:
        tmp.unlink(missing_ok=True)
    return out

read_targets

read_targets(
    root: Path, date: str, fraction: float | None = None
) -> set[str]

Return the target Series of one snapshot date and fraction, writing nothing.

Reads the list :func:target_list wrote, else streams the snapshot.

Raises:

Type Description
StoreBuildError

If neither the list nor the snapshot is in the store.

Source code in src/labdata/store/targets.py
def read_targets(root: Path, date: str, fraction: float | None = None) -> set[str]:
    """Return the target Series of one snapshot date and fraction, writing nothing.

    Reads the list :func:`target_list` wrote, else streams the snapshot.

    Raises
    ------
    StoreBuildError
        If neither the list nor the snapshot is in the store.
    """
    fraction = check_fraction(fraction)
    path = list_path(root, date, fraction)
    if path.is_file():
        return set(pq.read_table(path, columns=["accession"]).column(0).to_pylist())
    snap = snapshots.snapshot(root, snapshots.GEO_SERIES, date)
    return {
        acc for acc, _, _ in _targets(snap.path) if fraction is None or in_sample(acc, fraction)
    }
summary_links(path: Path) -> Iterator[LinkRow]

Yield each target Series' snapshot BioProject as a GEO summary link, batch by batch.

Parameters:

Name Type Description Default
path Path

A target Series list from :func:target_list.

required
Source code in src/labdata/store/targets.py
def summary_links(path: Path) -> Iterator[LinkRow]:
    """Yield each target Series' snapshot BioProject as a ``GEO summary`` link, batch by batch.

    Parameters
    ----------
    path : pathlib.Path
        A target Series list from :func:`target_list`.
    """
    for batch in pq.ParquetFile(path).iter_batches(columns=["accession", "bioproject"]):
        for gse, prj in zip(*batch.to_pydict().values(), strict=True):
            if prj:
                yield LinkRow(gse, prj, "BioProject", GEO_SUMMARY)

geo_lane

The GEO lane: family SOFT and filelist.txt of every target Series, from GEO's site.

Each Series' family SOFT is streamed over a few kept-open HTTPS connections with its sample and platform data tables stripped on the way; the stored text starts with the original file's size and sha256. Every Series and SubSeries met is fetched whatever its SOFT type. A SOFT 404 or exhausted retries is saved as a raw/geo-gaps/ row. A fetch-failed Series is asked once more before the lane ends, and again on the next run; a soft-missing one is not. Each held Series then gets its filelist.txt, a 404 saved as an empty text. Results are written as parts for the pack step; a saved item makes no request.

LaneReport dataclass

LaneReport(
    held: int,
    soft_missing: int,
    fetch_failed: int,
    filelists: int,
    refetched: int = 0,
)

What one run of the lane holds: Series with SOFT, gaps by reason, filelists.

refetched counts the re-fetch Series whose SOFT is saved again since the last pack.

run

run(
    root: Path,
    series: Iterable[str],
    *,
    client: EntrezClient,
    date: str | None = None,
    connections: int = CONNECTIONS,
    refetch: Iterable[str] = (),
) -> LaneReport

Fetch the family SOFT of each Series and its SubSeries, then each held one's filelist.

A refetch Series is fetched again though raw/ holds it, its filelist too once its SOFT is saved again. So is every Series a relation line in its new SOFT names (see :data:labdata.store.changes.RELATION).

Parameters:

Name Type Description Default
root Path

The store root; parts go under work/parts/.

required
series iterable of str

The target Series.

required
client EntrezClient

The NCBI seam; only its stream is used.

required
date str or None

The fetch date to record; defaults to today's UTC date.

None
connections int

Connections kept open; halved on HTTP 429, 503 or repeated timeouts.

CONNECTIONS
refetch iterable of str

Series to fetch again, the re-fetch list; a part holding one saves it.

()

Returns:

Type Description
LaneReport

Counts over every Series met, saved before this run or now.

Source code in src/labdata/store/geo_lane.py
def run(
    root: Path,
    series: Iterable[str],
    *,
    client: EntrezClient,
    date: str | None = None,
    connections: int = CONNECTIONS,
    refetch: Iterable[str] = (),
) -> LaneReport:
    """Fetch the family SOFT of each Series and its SubSeries, then each held one's filelist.

    A ``refetch`` Series is fetched again though ``raw/`` holds it, its filelist too once its
    SOFT is saved again. So is every Series a relation line in its new SOFT names (see
    :data:`labdata.store.changes.RELATION`).

    Parameters
    ----------
    root : pathlib.Path
        The store root; parts go under ``work/parts/``.
    series : iterable of str
        The target Series.
    client : EntrezClient
        The NCBI seam; only its ``stream`` is used.
    date : str or None
        The fetch date to record; defaults to today's UTC date.
    connections : int
        Connections kept open; halved on HTTP 429, 503 or repeated timeouts.
    refetch : iterable of str
        Series to fetch again, the re-fetch list; a part holding one saves it.

    Returns
    -------
    LaneReport
        Counts over every Series met, saved before this run or now.
    """
    date = date or dt.datetime.now(dt.UTC).date().isoformat()
    again = {s.strip().upper() for s in refetch}
    todo = sorted({s.strip().upper() for s in series} | again)
    seen: set[str] = set()
    retried = False
    with _Pool(client, connections) as pool:
        rounds = 0
        while todo:
            rounds += 1
            seen |= set(todo)
            what = "retry of failed" if retried else f"round {rounds}"
            _fetch_soft(root, pool, todo, date, f"Series SOFT, {what}", again)
            named = raw.scan(
                root, raw.GEO_SOFT, again.intersection(todo), RELATION, parts_only=True
            )
            late = named - again
            again |= late
            todo = sorted(_named_subseries(root, set(todo)) - seen | late)
            if not todo and not retried:
                retried = True
                unsaved = seen - _soft_held(root, seen)
                unsaved |= again - raw.held(root, raw.GEO_SOFT, again, parts_only=True)
                todo = sorted(_failed(root, unsaved))
        held = sorted(_soft_held(root, seen))
        renewed = raw.held(root, raw.GEO_SOFT, again, parts_only=True)
        for _ in range(2):
            if raw.pieces(root, raw.GEO_FILELIST, held, renewed):
                _fetch_filelists(root, pool, held, date, renewed)
    reasons = Counter(text.split("\n", 1)[0] for text in _gap_texts(root, seen - set(held)))
    return LaneReport(
        held=len(held),
        soft_missing=reasons[gaps.SOFT_MISSING],
        fetch_failed=reasons[gaps.FETCH_FAILED],
        filelists=len(raw.held(root, raw.GEO_FILELIST, held)),
        refetched=len(renewed),
    )

original

original(text: str) -> tuple[int, str] | None

Return the size and sha256 of the file a stored SOFT text was stripped from.

Returns:

Type Description
tuple of (int, str) or None

The original's bytes and hex sha256, or None for a text stored unstripped.

Source code in src/labdata/store/geo_lane.py
def original(text: str) -> tuple[int, str] | None:
    """Return the size and sha256 of the file a stored SOFT text was stripped from.

    Returns
    -------
    tuple of (int, str) or None
        The original's bytes and hex sha256, or ``None`` for a text stored unstripped.
    """
    head = text.split("\n", 2)
    if len(head) < 3 or not head[0].startswith(SIZE_LINE) or not head[1].startswith(SHA256_LINE):
        return None
    return int(head[0].removeprefix(SIZE_LINE)), head[1].removeprefix(SHA256_LINE)

strip_soft

strip_soft(chunks: Iterable[bytes]) -> str

Return family SOFT without its sample and platform data tables, size and sha256 first.

Each table goes with its begin and end lines; every other line is kept as served.

Source code in src/labdata/store/geo_lane.py
def strip_soft(chunks: Iterable[bytes]) -> str:
    """Return family SOFT without its sample and platform data tables, size and sha256 first.

    Each table goes with its begin and end lines; every other line is kept as served.
    """
    digest = hashlib.sha256()
    size = 0
    kept: list[bytes] = []
    inside = False
    carry = b""
    for chunk in itertools.chain(chunks, [None]):
        if chunk is None:
            block, carry = carry, b""
        else:
            digest.update(chunk)
            size += len(chunk)
            cut = chunk.rfind(b"\n") + 1
            if not cut:
                carry += chunk
                continue
            block, carry = carry + chunk[:cut], chunk[cut:]
        at = 0
        while at < len(block):
            match = (_TABLE_END if inside else _TABLE_BEGIN).search(block, at)
            if not inside:
                kept.append(block[at : match.start() if match else len(block)])
            if not match:
                break
            at = match.end() if inside else match.start()
            inside = not inside
    body = b"".join(kept).decode("utf-8", errors="replace")
    return f"{SIZE_LINE}{size}\n{SHA256_LINE}{digest.hexdigest()}\n{body}"

eutils

The E-utilities lane: the SRA EXPERIMENT_PACKAGE of every GEO-linked experiment.

The experiment list is the union of the SRX accessions in saved SOFT !Sample_relation lines and the SRA_Accessions.tab experiments with Center=GEO and a GSM alias, each with its tab status. Packages come from efetch in batches, several calls in flight under the client's shared rate cap. Each bucket's piece is split into parts of a few thousand experiments, so a preempted run loses at most a few open parts. An experiment NCBI serves no package for is saved as an sra-xml row with empty text, which the build lists in its notes. Each run also asks again for a saved package that is empty while the tab says live, or that the current build notes as not well-formed. Every step streams: the tab, the SOFT text and the list are read in chunks and spilled to Parquet under work/eutils/.

LaneReport dataclass

LaneReport(
    fetched: int,
    missing: int,
    failed: int,
    reasked: int = 0,
    recovered: int = 0,
    unserved: frozenset[str] = frozenset(),
)

What one lane run did: packages saved, new experiments with none, experiments to retry.

reasked counts saved bad packages asked for again, recovered those that came back good. A bad saved package NCBI again serves none for is kept as saved and is not in missing; nor is a re-fetch experiment's, which unserved lists.

experiment_list

experiment_list(
    root: Path,
    *,
    date: str | None = None,
    series: Iterable[str] | None = None,
    out: Path | None = None,
    from_tab: bool = True,
) -> Path

Write the lane's experiment list and return its path.

Parameters:

Name Type Description Default
root Path

The store root.

required
date str or None

The SRA_Accessions.tab snapshot date; defaults to the newest.

None
series iterable of str or None

Only these GEO Series' experiments: SOFT relations of these Series, and tab rows whose GSM the newest geo-series snapshot lists under one of them. Every Series by default.

None
out Path or None

Where to write the list; defaults to :func:list_path.

None
from_tab bool

Whether the tab's GEO rows add experiments; without, only the SOFT names them and the tab gives each its status.

True

Returns:

Type Description
Path

work/eutils/experiments-<date>.parquet: accession and status (empty when the tab does not list it), one row per experiment, in bucket order. It is rewritten on every call, as the saved SOFT may have grown.

Raises:

Type Description
StoreBuildError

If the store has no such snapshot.

Source code in src/labdata/store/eutils.py
def experiment_list(
    root: Path,
    *,
    date: str | None = None,
    series: Iterable[str] | None = None,
    out: Path | None = None,
    from_tab: bool = True,
) -> Path:
    """Write the lane's experiment list and return its path.

    Parameters
    ----------
    root : pathlib.Path
        The store root.
    date : str or None
        The ``SRA_Accessions.tab`` snapshot date; defaults to the newest.
    series : iterable of str or None
        Only these GEO Series' experiments: SOFT relations of these Series, and tab rows
        whose GSM the newest ``geo-series`` snapshot lists under one of them. Every
        Series by default.
    out : pathlib.Path or None
        Where to write the list; defaults to :func:`list_path`.
    from_tab : bool
        Whether the tab's GEO rows add experiments; without, only the SOFT names them and
        the tab gives each its status.

    Returns
    -------
    pathlib.Path
        ``work/eutils/experiments-<date>.parquet``: ``accession`` and ``status`` (empty when
        the tab does not list it), one row per experiment, in bucket order. It is rewritten
        on every call, as the saved SOFT may have grown.

    Raises
    ------
    StoreBuildError
        If the store has no such snapshot.
    """
    snap = snapshots.snapshot(root, snapshots.SRA_ACCESSIONS, date)
    folder = root / "work" / "eutils"
    tab = _tab_experiments(snap, folder / f"tab-{snap.date}.parquet")
    wanted = None if series is None else set(series)
    soft = _soft_experiments(root, folder / "soft.parquet", wanted)
    geo = "geo"
    if not from_tab:
        geo = "false"
    elif wanted is not None:
        samples = _samples(root, folder / "samples.parquet", wanted)
        geo = f"geo AND gsm IN (SELECT accession FROM read_parquet('{samples}'))"
    out = out or list_path(root, snap.date)
    tmp = temp_beside(out)
    try:
        with extract.connect(root) as con:
            con.execute(
                f"""
                COPY (
                  WITH tab AS (
                    SELECT accession, any_value(status) AS status, bool_or({geo}) AS geo
                    FROM read_parquet('{tab}') GROUP BY accession
                  ),
                  wanted AS (
                    SELECT accession FROM tab WHERE geo
                    UNION SELECT accession FROM read_parquet('{soft}')
                  )
                  SELECT w.accession, coalesce(t.status, '') AS status
                  FROM wanted w LEFT JOIN tab t USING (accession)
                  WHERE regexp_matches(w.accession, '^[A-Z]+[0-9]+$')
                  ORDER BY regexp_extract(w.accession, '^[A-Z]+'), length(w.accession),
                           w.accession
                ) TO '{tmp}' (FORMAT parquet)
                """
            )
        tmp.replace(out)
    finally:
        tmp.unlink(missing_ok=True)
    return out

list_path

list_path(root: Path, date: str) -> Path

Return where :func:experiment_list writes the list for one tab snapshot date.

Source code in src/labdata/store/eutils.py
def list_path(root: Path, date: str) -> Path:
    """Return where :func:`experiment_list` writes the list for one tab snapshot date."""
    return root / "work" / "eutils" / f"experiments-{date}.parquet"

fetch_packages

fetch_packages(
    root: Path,
    experiments: Path,
    *,
    client: EntrezClient,
    date: str,
    batch: int = BATCH,
    inflight: int = INFLIGHT,
    part_size: int = EXPERIMENTS_PER_PART,
    log_every: float = EVERY,
    refetch: Iterable[str] = (),
) -> LaneReport

Fetch the package of every listed experiment no part or raw bucket holds yet.

A refetch experiment, listed or not, is fetched again though a raw bucket holds it; only a part saves it.

Calls finish in any order: a slow call holds back only its own part, while at most :data:OPEN_PARTS parts are open at once.

Parameters:

Name Type Description Default
root Path

The store root.

required
experiments Path

The list from :func:experiment_list.

required
client EntrezClient

The Entrez seam; its limiter sets the rate.

required
date str

The fetch date recorded on every row.

required
batch int

Experiments per call, calls in flight, experiments per part.

BATCH
inflight int

Experiments per call, calls in flight, experiments per part.

BATCH
part_size int

Experiments per call, calls in flight, experiments per part.

BATCH
log_every float

Seconds between progress lines.

EVERY
refetch iterable of str

Experiments to fetch again.

()

Returns:

Type Description
LaneReport

Counts for this run. An experiment whose call failed is asked once more before the lane ends; if that fails too it is not saved, so the next run asks for it again. A new one NCBI has no package for is saved with an empty text and counted in missing. Saved bad packages are asked again: reasked counts them, recovered those that came back good and replace the saved copy. A re-fetch experiment NCBI serves none for keeps its saved package and is listed in unserved.

Source code in src/labdata/store/eutils.py
def fetch_packages(
    root: Path,
    experiments: Path,
    *,
    client: EntrezClient,
    date: str,
    batch: int = BATCH,
    inflight: int = INFLIGHT,
    part_size: int = EXPERIMENTS_PER_PART,
    log_every: float = EVERY,
    refetch: Iterable[str] = (),
) -> LaneReport:
    """Fetch the package of every listed experiment no part or raw bucket holds yet.

    A ``refetch`` experiment, listed or not, is fetched again though a raw bucket holds it;
    only a part saves it.

    Calls finish in any order: a slow call holds back only its own part, while at most
    :data:`OPEN_PARTS` parts are open at once.

    Parameters
    ----------
    root : pathlib.Path
        The store root.
    experiments : pathlib.Path
        The list from :func:`experiment_list`.
    client : EntrezClient
        The Entrez seam; its limiter sets the rate.
    date : str
        The fetch date recorded on every row.
    batch, inflight, part_size : int
        Experiments per call, calls in flight, experiments per part.
    log_every : float
        Seconds between progress lines.
    refetch : iterable of str
        Experiments to fetch again.

    Returns
    -------
    LaneReport
        Counts for this run. An experiment whose call failed is asked once more before the
        lane ends; if that fails too it is not saved, so the next run asks for it again. A new
        one NCBI has no package for is saved with an empty text and counted in ``missing``.
        Saved bad packages are asked again: ``reasked`` counts them, ``recovered`` those that
        came back good and replace the saved copy. A re-fetch experiment NCBI serves none
        for keeps its saved package and is listed in ``unserved``.
    """
    sizes = (batch, inflight, part_size)
    noted = gaps.noted(root, gaps.UNPARSABLE_PACKAGE)
    again = frozenset(refetch)
    first, settled = _fetch_once(
        root, experiments, client, date, sizes, log_every, noted, set(), again
    )
    report = first
    if first.failed:
        logger.info("asking again for the %d experiments whose calls failed", first.failed)
        last, _ = _fetch_once(
            root, experiments, client, date, sizes, log_every, noted, settled, again
        )
        report = LaneReport(
            first.fetched + last.fetched,
            first.missing + last.missing,
            last.failed,
            len(settled) + last.reasked,
            first.recovered + last.recovered,
            first.unserved | last.unserved,
        )
    logger.info(
        "asked again for %d saved bad packages; %d came back good",
        report.reasked,
        report.recovered,
    )
    return report

raw

Raw text in buckets: fetch lanes write parts under work/parts/, pack merges them.

A raw row is accession, raw_text, hash and fetch_date. A source's rows sit in raw/<source>/<bucket>.parquet, a bucket being a range of :data:WIDTHS accession numbers.

A fetch lane splits its work into :class:Piece s, one per bucket, and writes each finished piece as one part file that appears by a rename. A rerun skips every accession a part or a raw bucket already holds, so a saved piece makes no request. A re-fetch accession is fetched again though a raw bucket holds it; only a part saves it. A removal part lists accessions to delete instead of rows. Only :mod:labdata.store.pack writes raw/. Writer side only, so pyarrow is imported here freely.

RawRow dataclass

RawRow(accession: str, raw_text: str, fetch_date: str)

One raw item: the accession it was fetched for, its text and the fetch date.

Piece dataclass

Piece(source: str, bucket: str, todo: tuple[str, ...])

One unit of fetch work: one source's bucket's unsaved and re-fetch accessions.

sha256

sha256(text: str) -> str

Return the hex sha256 of a text's UTF-8 bytes: every hash and content_hash.

Source code in src/labdata/store/raw.py
def sha256(text: str) -> str:
    """Return the hex sha256 of a text's UTF-8 bytes: every ``hash`` and ``content_hash``."""
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

bucket_of

bucket_of(source: str, accession: str) -> str

Return the bucket an accession's rows go in within one source.

The bucket keeps the accession's prefix and its number with the last digits of the source's width written n: at width 10,000, GSE246310 -> GSE24nnnn.

Raises:

Type Description
ValueError

If the accession is not letters then digits.

KeyError

If the source has no width in :data:WIDTHS.

Source code in src/labdata/store/raw.py
def bucket_of(source: str, accession: str) -> str:
    """Return the bucket an accession's rows go in within one source.

    The bucket keeps the accession's prefix and its number with the last digits of the
    source's width written ``n``: at width 10,000, ``GSE246310`` -> ``GSE24nnnn``.

    Raises
    ------
    ValueError
        If the accession is not letters then digits.
    KeyError
        If the source has no width in :data:`WIDTHS`.
    """
    digits = len(str(WIDTHS[source])) - 1
    if match := _ACCESSION.match(accession):
        return f"{match[1]}{match[2][:-digits]}{'n' * digits}"
    raise ValueError(f"no raw bucket for {accession!r}")

parts_dir

parts_dir(root: Path, source: str) -> Path

Return the folder of a source's parts, work/parts/<source>/.

Source code in src/labdata/store/raw.py
def parts_dir(root: Path, source: str) -> Path:
    """Return the folder of a source's parts, ``work/parts/<source>/``."""
    return root / "work" / "parts" / source

part_files

part_files(root: Path, source: str) -> list[Path]

Return a source's finished part files, oldest first.

Source code in src/labdata/store/raw.py
def part_files(root: Path, source: str) -> list[Path]:
    """Return a source's finished part files, oldest first."""
    folder = parts_dir(root, source)
    if not folder.is_dir():
        return []
    files = [p for p in folder.glob("*.parquet") if not p.name.startswith(".")]
    return sorted(files, key=lambda p: (p.stat().st_mtime_ns, p.name))

part_bucket

part_bucket(path: Path) -> str

Return the bucket a part file belongs to (its name up to the first dot).

Source code in src/labdata/store/raw.py
def part_bucket(path: Path) -> str:
    """Return the bucket a part file belongs to (its name up to the first dot)."""
    return path.name.split(".", 1)[0]

pieces

pieces(
    root: Path,
    source: str,
    accessions: Iterable[str],
    refetch: Iterable[str] = (),
) -> list[Piece]

Split accessions into pieces, one per bucket, leaving out every saved accession.

An accession is saved when a raw bucket row or a part holds it, or a part's piece asked for it and got no row. A refetch accession, wanted too, is fetched even though a raw bucket holds it: only a part saves it. A bucket with nothing left to fetch yields no piece.

Source code in src/labdata/store/raw.py
def pieces(
    root: Path, source: str, accessions: Iterable[str], refetch: Iterable[str] = ()
) -> list[Piece]:
    """Split accessions into pieces, one per bucket, leaving out every saved accession.

    An accession is saved when a raw bucket row or a part holds it, or a part's piece asked
    for it and got no row. A ``refetch`` accession, wanted too, is fetched even though a raw
    bucket holds it: only a part saves it. A bucket with nothing left to fetch yields no piece.
    """
    by_bucket: dict[str, set[str]] = defaultdict(set)
    again = set(refetch)
    for accession in itertools.chain(accessions, again):
        by_bucket[bucket_of(source, accession)].add(accession)
    packed, parted = _done(root, source, set(by_bucket))
    out = []
    for bucket, wanted in sorted(by_bucket.items()):
        left = wanted - parted[bucket] - (packed[bucket] - again)
        if left:
            out.append(Piece(source, bucket, tuple(sorted(left))))
    return out

held

held(
    root: Path,
    source: str,
    accessions: Iterable[str] | None = None,
    *,
    parts_only: bool = False,
) -> set[str]

Return which of accessions have a row in source, in raw or a part.

With accessions None, every accession with a row. With parts_only, only rows in parts count: rows fetched since the last pack.

Source code in src/labdata/store/raw.py
def held(
    root: Path, source: str, accessions: Iterable[str] | None = None, *, parts_only: bool = False
) -> set[str]:
    """Return which of ``accessions`` have a row in ``source``, in raw or a part.

    With ``accessions`` None, every accession with a row. With ``parts_only``, only rows in
    parts count: rows fetched since the last pack.
    """
    if accessions is None:
        paths = part_files(root, source) if parts_only else source_files(root, source)
        return {
            a
            for path in paths
            for a in pq.read_table(path, columns=["accession"]).column(0).to_pylist()
        }
    wanted = set(accessions)
    found: set[str] = set()
    for path in _bucket_paths(root, source, wanted, parts_only):
        found |= wanted.intersection(
            pq.read_table(path, columns=["accession"]).column(0).to_pylist()
        )
    return found

source_files

source_files(root: Path, source: str) -> list[Path]

Return every file holding a source's rows: its raw buckets, then its parts, oldest first.

Source code in src/labdata/store/raw.py
def source_files(root: Path, source: str) -> list[Path]:
    """Return every file holding a source's rows: its raw buckets, then its parts, oldest first."""
    return sorted((root / "raw" / source).glob("*.parquet")) + part_files(root, source)

scan

scan(
    root: Path,
    source: str,
    accessions: Iterable[str],
    pattern: Pattern[str],
    *,
    parts_only: bool = False,
) -> set[str]

Return what pattern's first group matches in the saved text of accessions.

Every saved copy is read, raw and part alike, a few rows at a time; with parts_only, only the parts.

Source code in src/labdata/store/raw.py
def scan(
    root: Path,
    source: str,
    accessions: Iterable[str],
    pattern: re.Pattern[str],
    *,
    parts_only: bool = False,
) -> set[str]:
    """Return what ``pattern``'s first group matches in the saved text of ``accessions``.

    Every saved copy is read, raw and part alike, a few rows at a time; with ``parts_only``,
    only the parts.
    """
    wanted = set(accessions)
    paths = _bucket_paths(root, source, wanted, parts_only)
    found: set[str] = set()
    for path in paths:
        batches = pq.ParquetFile(path).iter_batches(
            batch_size=64, columns=["accession", "raw_text"]
        )
        for batch in batches:
            for accession, text in zip(*batch.to_pydict().values(), strict=True):
                if accession in wanted:
                    found.update(pattern.findall(text))
    return found

asked

asked(path: Path) -> set[str]

Return the accessions a part's piece asked for.

Source code in src/labdata/store/raw.py
def asked(path: Path) -> set[str]:
    """Return the accessions a part's piece asked for."""
    return _listed(path, ASKED_KEY)

removed

removed(path: Path) -> list[str]

Return the accessions a removal part deletes, sorted; empty for a row part.

Source code in src/labdata/store/raw.py
def removed(path: Path) -> list[str]:
    """Return the accessions a removal part deletes, sorted; empty for a row part."""
    return sorted(_listed(path, REMOVED_KEY))

write_removals

write_removals(
    root: Path, source: str, accessions: Iterable[str]
) -> list[Path]

Write removal parts deleting these accessions' rows from one source, one per bucket.

Pack applies a removal in part order: it deletes the rows saved before it, and a row saved after it wins. A removal part already written is left as it is, keeping its place.

Returns:

Type Description
list of pathlib.Path

The removal parts, written or already there.

Source code in src/labdata/store/raw.py
def write_removals(root: Path, source: str, accessions: Iterable[str]) -> list[Path]:
    """Write removal parts deleting these accessions' rows from one source, one per bucket.

    Pack applies a removal in part order: it deletes the rows saved before it, and a row
    saved after it wins. A removal part already written is left as it is, keeping its place.

    Returns
    -------
    list of pathlib.Path
        The removal parts, written or already there.
    """
    by_bucket: dict[str, set[str]] = defaultdict(set)
    for accession in accessions:
        by_bucket[bucket_of(source, accession)].add(accession)
    folder = parts_dir(root, source)
    written = []
    for bucket, members in sorted(by_bucket.items()):
        listed = "\n".join(sorted(members))
        tag = hashlib.sha256(listed.encode()).hexdigest()[:12]
        folder.mkdir(parents=True, exist_ok=True)
        path = folder / f"{bucket}.removed-{tag}.parquet"
        written.append(path)
        if path.is_file():
            continue
        write_sorted_runs(path, [], SCHEMA.with_metadata({REMOVED_KEY: listed.encode()}))
    return written

fetch_pieces

fetch_pieces(
    root: Path,
    source: str,
    accessions: Iterable[str],
    get: Callable[[list[str]], Iterable[tuple[str, str]]],
    date: str,
    *,
    remember_misses: bool = True,
) -> list[Path]

Fetch every unsaved piece of a source and write each as a part.

Parameters:

Name Type Description Default
root Path

The store root.

required
source str

The raw folder name, e.g. "sra-xml".

required
accessions iterable of str

Every accession the lane wants saved.

required
get callable

Takes one piece's accessions and yields (accession, raw_text) pairs; it may stream, as rows are spilled to the part in chunks.

required
date str

The fetch date recorded on every row.

required
remember_misses bool

Whether an accession get gave no row for counts as saved until the next pack.

True

Returns:

Type Description
list of pathlib.Path

The parts written; a saved piece writes none and makes no call to get.

Source code in src/labdata/store/raw.py
def fetch_pieces(
    root: Path,
    source: str,
    accessions: Iterable[str],
    get: Callable[[list[str]], Iterable[tuple[str, str]]],
    date: str,
    *,
    remember_misses: bool = True,
) -> list[Path]:
    """Fetch every unsaved piece of a source and write each as a part.

    Parameters
    ----------
    root : pathlib.Path
        The store root.
    source : str
        The raw folder name, e.g. ``"sra-xml"``.
    accessions : iterable of str
        Every accession the lane wants saved.
    get : callable
        Takes one piece's accessions and yields ``(accession, raw_text)`` pairs; it may
        stream, as rows are spilled to the part in chunks.
    date : str
        The fetch date recorded on every row.
    remember_misses : bool
        Whether an accession ``get`` gave no row for counts as saved until the next pack.

    Returns
    -------
    list of pathlib.Path
        The parts written; a saved piece writes none and makes no call to ``get``.
    """
    return [
        write_part(
            root,
            piece,
            (RawRow(a, text, date) for a, text in get(list(piece.todo))),
            record_asked=remember_misses,
        )
        for piece in pieces(root, source, accessions)
    ]

write_part

write_part(
    root: Path,
    piece: Piece,
    rows: Iterable[RawRow],
    *,
    record_asked: bool = True,
    chunk_bytes: int = SORT_CHUNK_BYTES,
) -> Path

Write one piece's rows as a part file, chunk by chunk, renamed into place when whole.

Each chunk is sorted by accession and written as one row group, so memory holds one chunk. With record_asked, the part records piece.todo as asked, so an accession with no row is not fetched again before the next pack.

Raises:

Type Description
ValueError

If a row belongs to another bucket.

Source code in src/labdata/store/raw.py
def write_part(
    root: Path,
    piece: Piece,
    rows: Iterable[RawRow],
    *,
    record_asked: bool = True,
    chunk_bytes: int = SORT_CHUNK_BYTES,
) -> Path:
    """Write one piece's rows as a part file, chunk by chunk, renamed into place when whole.

    Each chunk is sorted by accession and written as one row group, so memory holds one
    chunk. With ``record_asked``, the part records ``piece.todo`` as asked, so an accession
    with no row is not fetched again before the next pack.

    Raises
    ------
    ValueError
        If a row belongs to another bucket.
    """
    folder = parts_dir(root, piece.source)
    folder.mkdir(parents=True, exist_ok=True)
    tag = hashlib.sha256("\n".join(piece.todo).encode()).hexdigest()[:12]
    path = folder / f"{piece.bucket}.{tag}.parquet"

    def checked() -> Iterator[RawRow]:
        for row in rows:
            if bucket_of(piece.source, row.accession) != piece.bucket:
                raise ValueError(f"{row.accession} is not in bucket {piece.bucket}")
            yield row

    listed = "\n".join(piece.todo) if record_asked else ""
    schema = SCHEMA.with_metadata({ASKED_KEY: listed.encode()})
    write_sorted_runs(path, checked(), schema, chunk_bytes=chunk_bytes)
    return path

write_sorted_runs

write_sorted_runs(
    path: Path,
    rows: Iterable[RawRow],
    schema: Schema,
    *,
    chunk_bytes: int = SORT_CHUNK_BYTES,
) -> None

Write rows to path one chunk at a time, each chunk sorted by accession.

A chunk is written as :data:GROUPS_PER_CHUNK row groups, so a reader merging many sorted runs holds one small row group of each. The file appears by a rename once whole. Rows already in order make a sorted file.

Source code in src/labdata/store/raw.py
def write_sorted_runs(
    path: Path, rows: Iterable[RawRow], schema: pa.Schema, *, chunk_bytes: int = SORT_CHUNK_BYTES
) -> None:
    """Write rows to ``path`` one chunk at a time, each chunk sorted by accession.

    A chunk is written as :data:`GROUPS_PER_CHUNK` row groups, so a reader merging many
    sorted runs holds one small row group of each. The file appears by a rename once whole.
    Rows already in order make a sorted file.
    """
    tmp = writer_temp(path)
    try:
        with pq.ParquetWriter(tmp, schema) as writer:
            chunk: list[RawRow] = []
            size = 0
            for row in rows:
                chunk.append(row)
                size += len(row.raw_text)
                if size >= chunk_bytes or len(chunk) >= SORT_CHUNK_ROWS:
                    _write_chunk(writer, chunk, schema, chunk_bytes)
                    chunk, size = [], 0
            if chunk:
                _write_chunk(writer, chunk, schema, chunk_bytes)
        tmp.replace(path)
    finally:
        tmp.unlink(missing_ok=True)

writer_temp

writer_temp(path: Path) -> Path

Return a hidden temporary path beside path, unique to this process and thread.

Source code in src/labdata/store/raw.py
def writer_temp(path: Path) -> Path:
    """Return a hidden temporary path beside ``path``, unique to this process and thread."""
    return path.with_name(f".{path.name}.{os.getpid()}-{threading.get_ident()}.tmp")

saved

saved(
    root: Path,
    source: str,
    accessions: Iterable[str],
    *,
    parts_only: bool = False,
) -> dict[str, str]

Return the saved raw text of those accessions that have a row, in raw or a part.

A part's row wins over a raw bucket's, and a newer part's over an older one's; a removal part drops the rows saved before it. With parts_only, only rows in parts count.

Source code in src/labdata/store/raw.py
def saved(
    root: Path, source: str, accessions: Iterable[str], *, parts_only: bool = False
) -> dict[str, str]:
    """Return the saved raw text of those ``accessions`` that have a row, in raw or a part.

    A part's row wins over a raw bucket's, and a newer part's over an older one's; a removal
    part drops the rows saved before it. With ``parts_only``, only rows in parts count.
    """
    wanted = set(accessions)
    paths = _bucket_paths(root, source, wanted, parts_only)
    out: dict[str, str] = {}
    for path in paths:
        for accession in removed(path) if path.is_file() else []:
            out.pop(accession, None)
        out |= {row.accession: row.raw_text for row in _read(path) if row.accession in wanted}
    return out

fetch_dates

fetch_dates(
    root: Path, source: str, accessions: Iterable[str]
) -> dict[str, str]

Return the fetch date of each of accessions saved, as :func:saved picks the row.

Only the accession and date columns are read.

Source code in src/labdata/store/raw.py
def fetch_dates(root: Path, source: str, accessions: Iterable[str]) -> dict[str, str]:
    """Return the fetch date of each of ``accessions`` saved, as :func:`saved` picks the row.

    Only the accession and date columns are read.
    """
    wanted = set(accessions)
    out: dict[str, str] = {}
    for path in _bucket_paths(root, source, wanted, False):
        for accession in removed(path):
            out.pop(accession, None)
        data = pq.read_table(path, columns=["accession", "fetch_date"]).to_pydict()
        out |= {
            a: d for a, d in zip(data["accession"], data["fetch_date"], strict=True) if a in wanted
        }
    return out

read_rows

read_rows(root: Path, source: str) -> list[RawRow]

Return every raw row of one source, sorted by accession.

Source code in src/labdata/store/raw.py
def read_rows(root: Path, source: str) -> list[RawRow]:
    """Return every raw row of one source, sorted by accession."""
    folder = root / "raw" / source
    rows = [row for path in sorted(folder.glob("*.parquet")) for row in _read(path)]
    return sorted(rows, key=_key)

pack

Pack: merge parts into raw/<source>/<bucket>.parquet, the only writer of raw/.

A bucket is rewritten whole by a k-way merge of its sorted inputs, read a few rows at a time and written a chunk at a time, so memory holds a chunk, never a bucket or a source. For one accession, a part's row replaces the bucket's, and a newer part's an older one's. A removal part deletes the rows saved before it; a bucket left with no rows is deleted. A bucket file named for another width is first split into parts, so a width change repacks the same rows.

pack

pack(
    root: Path,
    sources: Iterable[str] | None = None,
    *,
    chunk_bytes: int = raw.SORT_CHUNK_BYTES,
    counts: Path | None = None,
) -> list[Path]

Merge every source's parts into its raw buckets and document the folders changed.

Parameters:

Name Type Description Default
root Path

The store root.

required
sources iterable of str or None

The raw folders to pack; defaults to every source with parts or a bucket file.

None
chunk_bytes int

Raw text held before a row group is written.

SORT_CHUNK_BYTES
counts Path or None

A file to append one JSON line to per bucket rewritten: its source, bucket, and the records added, replaced (held with another text) and removed.

None

Returns:

Type Description
list of pathlib.Path

The bucket files rewritten or deleted. Each source's count of removed records is logged.

Source code in src/labdata/store/pack.py
def pack(
    root: Path,
    sources: Iterable[str] | None = None,
    *,
    chunk_bytes: int = raw.SORT_CHUNK_BYTES,
    counts: Path | None = None,
) -> list[Path]:
    """Merge every source's parts into its raw buckets and document the folders changed.

    Parameters
    ----------
    root : pathlib.Path
        The store root.
    sources : iterable of str or None
        The raw folders to pack; defaults to every source with parts or a bucket file.
    chunk_bytes : int
        Raw text held before a row group is written.
    counts : pathlib.Path or None
        A file to append one JSON line to per bucket rewritten: its source, bucket, and the
        records added, replaced (held with another text) and removed.

    Returns
    -------
    list of pathlib.Path
        The bucket files rewritten or deleted. Each source's count of removed records is logged.
    """
    if sources is None:
        found = {p.name for p in (root / "work" / "parts").glob("*") if p.is_dir()}
        found |= {p.name for p in (root / "raw").glob("*") if p.is_dir()}
        sources = sorted(found)
    written: list[Path] = []
    for source in sources:
        done, removed = _pack_source(root, source, chunk_bytes, counts)
        written += done
        if removed:
            logger.info("%s: removed %d record%s", source, removed, "" if removed == 1 else "s")
        if done:
            provenance.document_raw(root, source)
    return written

write_rows

write_rows(
    root: Path, source: str, rows: list[RawRow]
) -> list[Path]

Save rows through parts and pack them: a row replaces any row of its accession.

Returns:

Type Description
list of pathlib.Path

The bucket files rewritten.

Source code in src/labdata/store/pack.py
def write_rows(root: Path, source: str, rows: list[RawRow]) -> list[Path]:
    """Save rows through parts and pack them: a row replaces any row of its accession.

    Returns
    -------
    list of pathlib.Path
        The bucket files rewritten.
    """
    by_bucket: dict[str, list[RawRow]] = defaultdict(list)
    for row in rows:
        by_bucket[raw.bucket_of(source, row.accession)].append(row)
    for bucket, chunk in sorted(by_bucket.items()):
        piece = raw.Piece(source, bucket, tuple(sorted({row.accession for row in chunk})))
        raw.write_part(root, piece, chunk)
    return pack(root, [source])

read_counts

read_counts(path: Path) -> dict[str, dict[str, int]]

Return the records added, replaced and removed per source, from a pack's counts file.

A bucket packed again from the same parts, after a kill, counts once: its first record.

Source code in src/labdata/store/pack.py
def read_counts(path: Path) -> dict[str, dict[str, int]]:
    """Return the records added, replaced and removed per source, from a pack's counts file.

    A bucket packed again from the same parts, after a kill, counts once: its first record.
    """
    out: dict[str, dict[str, int]] = defaultdict(lambda: dict.fromkeys(COUNTS, 0))
    seen: set[tuple[str, str, str]] = set()
    lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
    for line in lines:
        try:
            row = json.loads(line)
        except json.JSONDecodeError:  # a line cut off by a kill
            continue
        key = (row["source"], row["bucket"], row.get("parts", ""))
        if key in seen:
            continue
        seen.add(key)
        for name in COUNTS:
            out[row["source"]][name] += row[name]
    return dict(sorted(out.items()))

gaps

Gaps, build notes and the coverage check of the target Series list.

The GEO fetch records each target Series it could not save as a raw/geo-gaps/ row made by :func:gap_row. The build writes gaps.parquet: every target Series it does not hold, with the recorded reason, or unexplained when none was recorded. A Series held later leaves the gaps whatever its old row says. Everything here reads and writes Parquet files through DuckDB, so memory is bounded by DuckDB's cap, not by the store.

Note dataclass

Note(accession: str, kind: str, detail: str)

One build note: the accession it is about, its kind and a one-line detail.

Coverage dataclass

Coverage(
    snapshot_date: str,
    target: int,
    held: int,
    gaps: dict[str, int],
    failing: tuple[tuple[str, str], ...],
    failures: int,
    failed_experiments: int = 0,
)

Check 1 over one build.

Parameters:

Name Type Description Default
snapshot_date str

The geo-series snapshot the target list comes from.

required
target int

Target Series, and how many of them the build holds.

required
held int

Target Series, and how many of them the build holds.

required
gaps dict of str to int

Gap rows by reason, every reason listed.

required
failing tuple of (str, str)

Up to NAMED (accession, problem) pairs, sorted.

required
failures int

Every failing Series, named or not.

required
failed_experiments int

SRA experiments whose package fetch failed, from the build notes; not Series gaps.

0

passed property

passed: bool

Whether target = held + gaps and no gap is unexplained.

gap_row

gap_row(
    series: str,
    reason: str,
    fetch_date: str,
    detail: str = "",
) -> raw.RawRow

Return the raw/geo-gaps/ row recording why the fetch could not save a Series.

Parameters:

Name Type Description Default
series str

The target Series.

required
reason str

SOFT_MISSING or FETCH_FAILED.

required
fetch_date str

The fetch date.

required
detail str

One line, such as the last error; optional.

''

Raises:

Type Description
ValueError

If the reason is not a recordable one.

Examples:

>>> gap_row("GSE1", SOFT_MISSING, "2026-10-14").raw_text
'soft-missing'
Source code in src/labdata/store/gaps.py
def gap_row(series: str, reason: str, fetch_date: str, detail: str = "") -> raw.RawRow:
    """Return the ``raw/geo-gaps/`` row recording why the fetch could not save a Series.

    Parameters
    ----------
    series : str
        The target Series.
    reason : str
        ``SOFT_MISSING`` or ``FETCH_FAILED``.
    fetch_date : str
        The fetch date.
    detail : str
        One line, such as the last error; optional.

    Raises
    ------
    ValueError
        If the reason is not a recordable one.

    Examples
    --------
    >>> gap_row("GSE1", SOFT_MISSING, "2026-10-14").raw_text
    'soft-missing'
    """
    if reason not in (SOFT_MISSING, FETCH_FAILED):
        raise ValueError(f"not a recordable gap reason: {reason!r}")
    line = " ".join(detail.split())
    return raw.RawRow(series, f"{reason}\n{line}" if line else reason, fetch_date)

write_gaps

write_gaps(
    root: Path,
    build_dir: Path,
    target_list: Path,
    date: str,
    fraction: float | None = None,
    *,
    source: BuildSource | None = None,
) -> dict[str, object]

Write gaps.parquet from the target list, holdings.parquet and raw/geo-gaps/.

fraction names the round's sample the target list is, so coverage re-reads the same. raw/ is read from source, which defaults to root.

Returns:

Type Description
dict

snapshot_date, target and held counts, and gaps: the count by reason, every reason listed; fraction too for a round's sample.

Source code in src/labdata/store/gaps.py
def write_gaps(
    root: Path,
    build_dir: Path,
    target_list: Path,
    date: str,
    fraction: float | None = None,
    *,
    source: targets.BuildSource | None = None,
) -> dict[str, object]:
    """Write ``gaps.parquet`` from the target list, ``holdings.parquet`` and ``raw/geo-gaps/``.

    ``fraction`` names the round's sample the target list is, so coverage re-reads the same.
    ``raw/`` is read from ``source``, which defaults to ``root``.

    Returns
    -------
    dict
        ``snapshot_date``, ``target`` and ``held`` counts, and ``gaps``: the count by reason,
        every reason listed; ``fraction`` too for a round's sample.
    """
    out = GAPS.path(build_dir)
    metadata = {DATE_KEY: date}
    if fraction is not None:
        metadata[FRACTION_KEY] = repr(fraction)
    reasons = ", ".join(sql_string(r) for r in REASONS[:-1])
    with extract.connect(root) as con:
        layout.write(
            con,
            GAPS,
            out,
            f"""WITH g AS ({_gap_rows(root if source is None else source.root)})
            SELECT t.accession,
              CASE WHEN g.reason IN ({reasons}) THEN g.reason ELSE '{UNEXPLAINED}' END
                AS reason,
              CASE WHEN g.reason IS NULL OR g.reason IN ({reasons}) THEN g.detail
                ELSE 'recorded reason: ' || g.reason END AS detail
            FROM (SELECT DISTINCT accession FROM {_read(target_list)}) t
            LEFT JOIN g USING (accession)
            WHERE t.accession NOT IN ({_held_series(build_dir)})""",
            metadata,
        )
        counts = dict(
            con.execute(f"SELECT reason, count(*) FROM {_read(out)} GROUP BY 1").fetchall()
        )
        target = _count(con, f"SELECT count(DISTINCT accession) FROM {_read(target_list)}")
    by_reason = {reason: int(counts.get(reason, 0)) for reason in REASONS}
    summary: dict[str, object] = {"snapshot_date": date}
    if fraction is not None:
        summary["fraction"] = fraction
    return summary | {
        "target": target,
        "held": target - sum(by_reason.values()),
        "gaps": by_reason,
    }

write_notes

write_notes(
    root: Path,
    build_dir: Path,
    target_list: Path,
    extra: Iterable[Note] = (),
    experiments: Path | None = None,
    *,
    source: BuildSource | None = None,
) -> dict[str, int]

Write notes.parquet: type disagreements, failed package fetches, then extra.

A failed package fetch is an experiment on the E-utilities lane's list experiments that raw/sra-xml/ of source (default root) does not hold. When source samples, only the experiments in its seeded sample are noted.

Returns:

Type Description
dict of str to int

The note count by kind.

Source code in src/labdata/store/gaps.py
def write_notes(
    root: Path,
    build_dir: Path,
    target_list: Path,
    extra: Iterable[Note] = (),
    experiments: Path | None = None,
    *,
    source: targets.BuildSource | None = None,
) -> dict[str, int]:
    """Write ``notes.parquet``: type disagreements, failed package fetches, then ``extra``.

    A failed package fetch is an experiment on the E-utilities lane's list ``experiments``
    that ``raw/sra-xml/`` of ``source`` (default ``root``) does not hold. When ``source``
    samples, only the experiments in its seeded sample are noted.

    Returns
    -------
    dict of str to int
        The note count by kind.
    """
    out = NOTES.path(build_dir)
    series = records_path(build_dir, "GEO Series")
    rows = list(extra)
    extra_table = pa.table(
        {
            "accession": [n.accession for n in rows],
            "kind": [n.kind for n in rows],
            "detail": [n.detail for n in rows],
        },
        schema=NOTES.schema(),
    )
    disagree = (
        f"""SELECT t.accession, '{TYPE_DISAGREES}' AS kind,
              'snapshot: ' || t.gdstype || ' | SOFT: ' || coalesce(s.types, 'no type') AS detail
            FROM {_read(target_list)} t
            JOIN (SELECT accession FROM {_read(series)}) r USING (accession)
            LEFT JOIN (
              SELECT accession, string_agg(DISTINCT f.value, '; ' ORDER BY f.value) AS types,
                bool_or(contains(lower(f.value), {sql_string(targets.SEQUENCING)}))
                  AS sequencing
              FROM (SELECT accession, unnest(fields) AS f FROM {_read(series)})
              WHERE f.name = 'Series_type' GROUP BY accession
            ) s USING (accession)
            WHERE NOT coalesce(s.sequencing, false)"""
        if series.is_file()
        else "SELECT * FROM extra WHERE false"
    )
    unfetched = "SELECT * FROM extra WHERE false"
    sample = None if source is None else source.sample
    saved = (root if source is None else source.root) / "raw" / raw.SRA_XML
    if experiments is not None and experiments.is_file():
        held = (
            f"SELECT accession FROM {_read(saved / '*.parquet')}"
            if any(saved.glob("*.parquet"))
            else "SELECT NULL::VARCHAR AS accession WHERE false"
        )
        unfetched = (
            f"SELECT accession, '{PACKAGE_FETCH_FAILED}' AS kind, "
            f"'no package saved; a rerun asks again' AS detail FROM {_read(experiments)} "
            f"WHERE accession NOT IN ({held})"
            + ("" if sample is None else " AND in_sample(accession)")
        )
    with extract.connect(root) as con:
        con.register("extra", extra_table)
        if sample is not None:
            targets.add_in_sample(con, sample)
        layout.write(
            con, NOTES, out, f"{disagree} UNION ALL {unfetched} UNION ALL SELECT * FROM extra"
        )
        counts = con.execute(f"SELECT kind, count(*) FROM {_read(out)} GROUP BY 1 ORDER BY 1")
        return {str(kind): int(n) for kind, n in counts.fetchall()}

noted

noted(root: Path, kind: str) -> set[str]

Return the accessions the current build notes as kind; none before a first build.

Source code in src/labdata/store/gaps.py
def noted(root: Path, kind: str) -> set[str]:
    """Return the accessions the current build notes as ``kind``; none before a first build."""
    if current_build(root) is None:
        return set()
    notes = NOTES.path(current_path(root))
    if not notes.is_file():
        return set()
    table = pq.read_table(notes, columns=["accession"], filters=[("kind", "==", kind)])
    return set(table.column(0).to_pylist())

build_targets

build_targets(root: Path, build: Path) -> set[str]

Return the target Series of the list a build's gaps were made from, writing nothing.

Raises:

Type Description
StoreBuildError

If the build has no gaps.parquet, or its target list and snapshot are gone.

Source code in src/labdata/store/gaps.py
def build_targets(root: Path, build: Path) -> set[str]:
    """Return the target Series of the list a build's gaps were made from, writing nothing.

    Raises
    ------
    StoreBuildError
        If the build has no ``gaps.parquet``, or its target list and snapshot are gone.
    """
    gaps_file = GAPS.path(build)
    if not gaps_file.is_file():
        raise StoreBuildError(f"{build} has no {GAPS}; rebuild with `labdata store build`")
    meta = pq.read_metadata(gaps_file).metadata or {}
    fraction = meta.get(FRACTION_KEY.encode())
    return targets.read_targets(
        root, meta[DATE_KEY.encode()].decode(), None if fraction is None else float(fraction)
    )

coverage

coverage(root: Path, build: BuildReader) -> Coverage

Check one build against the target list of the snapshot its gaps were made from.

The target list is re-read from the snapshot, so a stale or partial gaps.parquet fails rather than agreeing with itself. A round's build is checked against its sample.

Raises:

Type Description
StoreBuildError

If the build has no gaps.parquet or its snapshot is gone.

Source code in src/labdata/store/gaps.py
def coverage(root: Path, build: BuildReader) -> Coverage:
    """Check one build against the target list of the snapshot its gaps were made from.

    The target list is re-read from the snapshot, so a stale or partial ``gaps.parquet``
    fails rather than agreeing with itself. A round's build is checked against its sample.

    Raises
    ------
    StoreBuildError
        If the build has no ``gaps.parquet`` or its snapshot is gone.
    """
    gaps_file = GAPS.path(build.path)
    if not gaps_file.is_file():
        raise StoreBuildError(f"{build.path} has no {GAPS}; rebuild with `labdata store build`")
    meta = pq.read_metadata(gaps_file).metadata or {}
    date = meta[DATE_KEY.encode()].decode()
    fraction = meta.get(FRACTION_KEY.encode())
    target_list = targets.target_list(root, date, None if fraction is None else float(fraction))
    with extract.connect(root) as con:
        con.execute(f"CREATE VIEW t AS SELECT DISTINCT accession FROM {_read(target_list)}")
        con.execute(f"CREATE VIEW h AS {_held_series(build.path)}")
        con.execute(f"CREATE VIEW g AS SELECT accession, reason, detail FROM {_read(gaps_file)}")
        target = _count(con, "SELECT count(*) FROM t")
        held = _count(con, "SELECT count(*) FROM t WHERE accession IN (SELECT accession FROM h)")
        by_reason = dict(con.execute("SELECT reason, count(*) FROM g GROUP BY 1").fetchall())
        problems = f"""
            SELECT accession, 'not held and no gap row' FROM t
              WHERE accession NOT IN (SELECT accession FROM h)
              AND accession NOT IN (SELECT accession FROM g)
            UNION ALL SELECT accession, 'gap row for a held Series' FROM g
              WHERE accession IN (SELECT accession FROM h)
            UNION ALL SELECT accession, 'gap row for a Series not on the target list' FROM g
              WHERE accession NOT IN (SELECT accession FROM t)
            UNION ALL SELECT accession, '{UNEXPLAINED}' || coalesce(': ' || detail, '') FROM g
              WHERE reason = '{UNEXPLAINED}'"""
        failures = _count(con, f"SELECT count(*) FROM ({problems})")
        failing = con.execute(f"SELECT * FROM ({problems}) ORDER BY 1, 2 LIMIT {NAMED}").fetchall()
        notes = NOTES.path(build.path)
        failed_experiments = (
            _count(
                con,
                f"SELECT count(*) FROM {_read(notes)} WHERE kind = '{PACKAGE_FETCH_FAILED}'",
            )
            if notes.is_file()
            else 0
        )
    return Coverage(
        snapshot_date=date,
        target=target,
        held=held,
        gaps={reason: int(by_reason.get(reason, 0)) for reason in (*REASONS, *by_reason)},
        failing=tuple((str(a), str(p)) for a, p in failing),
        failures=failures,
        failed_experiments=failed_experiments,
    )

prune

Delete the builds and snapshots the store no longer reads, after a grace period.

A non-current build goes once the store switched away from it more than the grace period ago, GRACE by default. A snapshot goes once it is older than the one the current build reads and every build that read it went out of use more than the grace period ago; its extract parts under work/ go with it. A snapshot newer than the current build's is waiting for the next build and stays. Neither the current build nor what it reads is ever deleted.

PruneReport dataclass

PruneReport(deleted: list[Path] = list())

What one prune deleted: build folders first, then snapshot folders.

prune

prune(
    root: Path,
    *,
    now: datetime | None = None,
    grace: timedelta = GRACE,
) -> PruneReport

Delete old builds and unread snapshots, keeping everything inside the grace period.

A build switched away from before switch times were recorded counts as switched away when the current build was switched to. After deleting anything, it rewrites the root README.md so its sizes stay current.

Parameters:

Name Type Description Default
root Path

The store root.

required
now datetime or None

The time to measure the grace period from; defaults to the current UTC time.

None
grace timedelta

How long a build stays after the store switched away from it: GRACE, 24 hours, by default. Zero deletes at once.

GRACE

Returns:

Type Description
PruneReport

The folders deleted.

Raises:

Type Description
StoreBuildError

If a build, prune or copy of this store is running.

Source code in src/labdata/store/prune.py
def prune(
    root: Path, *, now: dt.datetime | None = None, grace: dt.timedelta = GRACE
) -> PruneReport:
    """Delete old builds and unread snapshots, keeping everything inside the grace period.

    A build switched away from before switch times were recorded counts as switched away
    when the current build was switched to. After deleting anything, it rewrites the root
    ``README.md`` so its sizes stay current.

    Parameters
    ----------
    root : pathlib.Path
        The store root.
    now : datetime.datetime or None
        The time to measure the grace period from; defaults to the current UTC time.
    grace : datetime.timedelta
        How long a build stays after the store switched away from it: ``GRACE``, 24 hours,
        by default. Zero deletes at once.

    Returns
    -------
    PruneReport
        The folders deleted.

    Raises
    ------
    StoreBuildError
        If a build, prune or copy of this store is running.
    """
    now = now or dt.datetime.now(dt.UTC)
    with build_lock(root):
        marker = root / MARKER
        settings = read_marker(root)
        current = settings.get(BUILD_KEY)
        if not current:
            return PruneReport()
        switched = _time(settings.get(SWITCHED_KEY)) or dt.datetime.fromtimestamp(
            marker.stat().st_mtime, dt.UTC
        )
        away: dict[str, str] = settings.get(SWITCHED_AWAY_KEY, {})
        builds = build_folders(root)
        reads = {b.name: _reads(b) for b in builds}
        unused_since = {
            b.name: _time(away.get(b.name)) or switched for b in builds if b.name != current
        }
        old = [build_path(root, name) for name, t in unused_since.items() if now - t > grace]
        latest = dict(reads.get(str(current), set()))
        snaps = []
        for folder in sorted((root / "snapshots").glob("*/*")):
            source, date = folder.parent.name, folder.name
            if not (folder.is_dir() and _DATE.match(date)) or date >= latest.get(source, date):
                continue
            readers = [n for n, t in unused_since.items() if (source, date) in reads[n]]
            if now - max((unused_since[n] for n in readers), default=switched) > grace:
                snaps.append(folder)
        for folder in [*old, *snaps]:
            shutil.rmtree(folder)
        for folder in snaps:
            shutil.rmtree(
                extract.extract_dir(root, folder.parent.name, folder.name), ignore_errors=True
            )
        if any(p.name in away for p in old):
            settings[SWITCHED_AWAY_KEY] = {k: v for k, v in away.items() if not _gone(root, k)}
            write_marker(root, settings)
        if old or snaps:
            readme.try_write(root)
        return PruneReport([*old, *snaps])

readme

The store's root README.md, filled from the store's current state.

The text is the packaged store_readme.md; this module fills in the current build, its switch time, the snapshots it reads, the commit and version that made it, and folder sizes.

write

write(root: Path) -> Path

Write <root>/README.md for the build the marker names.

Parameters:

Name Type Description Default
root Path

The store root.

required

Returns:

Type Description
Path

The README written.

Raises:

Type Description
NotAStoreError

If the root has no marker, or the marker names no build.

Source code in src/labdata/store/readme.py
def write(root: Path) -> Path:
    """Write ``<root>/README.md`` for the build the marker names.

    Parameters
    ----------
    root : pathlib.Path
        The store root.

    Returns
    -------
    pathlib.Path
        The README written.

    Raises
    ------
    NotAStoreError
        If the root has no marker, or the marker names no build.
    """
    settings = read_marker(root)
    folder = current_path(root)
    build = folder.name
    path = manifest_path(folder)
    manifest = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
    made = manifest.get("produced_by")
    snaps = manifest.get("inputs", {}).get("snapshots", {})
    sizes = [(f"{folder.relative_to(root).as_posix()}/", _size(folder))] + [
        (f"{name}/", _size(root / name)) for name in _SIZED if (root / name).is_dir()
    ]
    text = string.Template(_template()).substitute(
        build=build,
        switched=settings.get("switched", "an unrecorded time"),
        made_by=_made_by(made),
        snapshot_table=_table(
            ("Source", "Date", "File"),
            [(f"`{s}`", v["date"], f"`{v['name']}`") for s, v in sorted(snaps.items())],
        ),
        size_table=_table(("Folder", "Size"), [(f"`{n}`", _human(b)) for n, b in sizes]),
    )
    out = root / README
    write_atomic(out, text)
    return out

try_write

try_write(root: Path) -> Path | None

Write the README as write does, but log a warning instead of raising.

Returns:

Type Description
Path or None

The README written, or None if it could not be.

Source code in src/labdata/store/readme.py
def try_write(root: Path) -> Path | None:
    """Write the README as ``write`` does, but log a warning instead of raising.

    Returns
    -------
    pathlib.Path or None
        The README written, or ``None`` if it could not be.
    """
    try:
        return write(root)
    except Exception as err:
        logger.warning(
            "could not write %s: %s; run `labdata store readme` to write it", root / README, err
        )
        return None

duckdb_examples

duckdb_examples(text: str | None = None) -> list[str]

Return the SQL of each duckdb.sql example in the README, in order.

Each query still holds {build}, the build folder it reads.

Parameters:

Name Type Description Default
text str or None

The README text to read; None reads the packaged template.

None

Returns:

Type Description
list of str

The queries, as written.

Raises:

Type Description
ValueError

If the text holds no DuckDB example.

Source code in src/labdata/store/readme.py
def duckdb_examples(text: str | None = None) -> list[str]:
    """Return the SQL of each ``duckdb.sql`` example in the README, in order.

    Each query still holds ``{build}``, the build folder it reads.

    Parameters
    ----------
    text : str or None
        The README text to read; ``None`` reads the packaged template.

    Returns
    -------
    list of str
        The queries, as written.

    Raises
    ------
    ValueError
        If the text holds no DuckDB example.
    """
    found = _DUCKDB_EXAMPLE.findall(_template() if text is None else text)
    if not found:
        raise ValueError('the README holds no DuckDB example, `duckdb.sql(f"""...""")`')
    return found

gcs

Copy the store's current state to a bucket with rclone.

The copy holds the marker, the root README.md, the snapshots the current build read, raw/ and the current build; work/, support/, the status record and older builds are left out, and the bucket's copies of them are deleted. One listing call first checks that rclone's login has not expired. A first rclone copy uploads everything but the marker, then an rclone sync uploads the marker and deletes the rest, so the bucket's marker never names a build it lacks. rclone skips files already uploaded, so a cut-off copy resumes when rerun.

StoreCopyError

Bases: LabdataError

The store cannot be copied: it does not open, has no destination, or rclone failed.

CopyResult dataclass

CopyResult(
    destination: str,
    skipped: bool = False,
    message: str = "",
)

The outcome of one copy: done, or skipped because rclone's login expired.

copy

copy(
    root: Path, destination: str | None = None
) -> CopyResult

Copy the store's current state to destination with rclone.

Parameters:

Name Type Description Default
root Path

The store root.

required
destination str or None

An rclone path, <remote>:<bucket>/<path>; defaults to the marker's copy_to.

None

Returns:

Type Description
CopyResult

skipped is true, with a message, when rclone's Google login has expired.

Raises:

Type Description
StoreCopyError

If the store does not open, its current build is incomplete, no destination is set, or rclone fails for another reason. rclone is not called when the store is refused.

StoreBuildError

If a build, prune or copy of this store is running.

Source code in src/labdata/store/gcs.py
def copy(root: Path, destination: str | None = None) -> CopyResult:
    """Copy the store's current state to ``destination`` with rclone.

    Parameters
    ----------
    root : pathlib.Path
        The store root.
    destination : str or None
        An rclone path, ``<remote>:<bucket>/<path>``; defaults to the marker's ``copy_to``.

    Returns
    -------
    CopyResult
        ``skipped`` is true, with a message, when rclone's Google login has expired.

    Raises
    ------
    StoreCopyError
        If the store does not open, its current build is incomplete, no destination is set,
        or rclone fails for another reason. rclone is not called when the store is refused.
    StoreBuildError
        If a build, prune or copy of this store is running.
    """
    try:
        store = open_store(root)
    except NotAStoreError as err:
        raise StoreCopyError(f"copy refused: {err}") from None
    dest = destination or store.settings.get(COPY_TO_KEY)
    if not dest:
        raise StoreCopyError(
            f"no destination: pass --to <remote>:<bucket>/<path> or set "
            f"{COPY_TO_KEY} in {root / MARKER}"
        )
    with build_lock(root):
        selected = _selected(root, store.settings.get("build"))
        done = _rclone.mirror(
            str(root),
            str(dest),
            [*selected, "--filter=- **"],
            [*selected, f"--filter=+ /{MARKER}", "--filter=- **"],
            run=_run_rclone,
            error=StoreCopyError,
        )
    if not done:
        message = _rclone.expired_message(str(dest), "store", "labdata store copy")
        return CopyResult(str(dest), skipped=True, message=message)
    return CopyResult(str(dest))

root

Find the paper store root, make one, lock it, and place each paper's folder in it.

The pattern is the GEO/SRA store's: a marker file in the root, labdata-papers.toml, found through LABDATA_PAPERS or a per-user pointer file beside store.toml.

PaperStore

PaperStore(root: Path, settings: dict[str, Any])

An opened paper store.

Parameters:

Name Type Description Default
root Path

The paper store root.

required
settings dict

The parsed labdata-papers.toml.

required
Source code in src/labdata/papers/root.py
def __init__(self, root: Path, settings: dict[str, Any]) -> None:
    self.root = root
    self.settings = settings

store cached property

store: Store

The GEO/SRA store whose current build links the papers, opened on first use.

reader cached property

reader: PaperReader

The reader over this paper store and the GEO/SRA store's current build.

__repr__

__repr__() -> str

Return the root.

Source code in src/labdata/papers/root.py
def __repr__(self) -> str:
    """Return the root."""
    return f"PaperStore({str(self.root)!r})"

open_paper_store

open_paper_store(
    root: str | PathLike[str] | None = None,
) -> PaperStore

Open the paper store, resolving its root without network or prompt.

The root is the first of: the root argument, LABDATA_PAPERS, then the per-user pointer file written by labdata papers init.

Raises:

Type Description
StoreNotConfiguredError

If no root is set anywhere.

NotAStoreError

If the root has no labdata-papers.toml.

Source code in src/labdata/papers/root.py
def open_paper_store(root: str | os.PathLike[str] | None = None) -> PaperStore:
    """Open the paper store, resolving its root without network or prompt.

    The root is the first of: the ``root`` argument, ``LABDATA_PAPERS``, then the per-user
    pointer file written by ``labdata papers init``.

    Raises
    ------
    StoreNotConfiguredError
        If no root is set anywhere.
    NotAStoreError
        If the root has no ``labdata-papers.toml``.
    """
    path = resolve_root(root, PAPERS_ENV, pointer_path(), "papers")
    if path is None:
        raise StoreNotConfiguredError(
            f"paper store not configured: pass open_paper_store(root=...), set {PAPERS_ENV}, "
            "or run `labdata papers init <root>`"
        )
    return PaperStore(path, read_toml_marker(path, MARKER, "a paper store"))

init_paper_store

init_paper_store(
    root: str | PathLike[str],
    store: str | PathLike[str] | None = None,
    *,
    copy_to: str = "",
    drive_remote: str = "",
    drive_folder: str = "",
) -> Path

Make a paper store: its marker and README.md, and the per-user pointer to it.

Parameters:

Name Type Description Default
root str or PathLike

The paper store root; created if missing.

required
store str or PathLike or None

The GEO/SRA store it reads; None takes the one open_store() finds.

None
copy_to str

The rclone path the paper store is copied to, <remote>:<bucket>/<path>.

''
drive_remote str

The rclone remote of the Google Drive that holds the Paperpile folder.

''
drive_folder str

The Drive ID of the Paperpile folder.

''

Returns:

Type Description
Path

The marker written.

Raises:

Type Description
LabdataError

If the root already has a marker, or store is not a GEO/SRA store.

Source code in src/labdata/papers/root.py
def init_paper_store(
    root: str | os.PathLike[str],
    store: str | os.PathLike[str] | None = None,
    *,
    copy_to: str = "",
    drive_remote: str = "",
    drive_folder: str = "",
) -> Path:
    """Make a paper store: its marker and ``README.md``, and the per-user pointer to it.

    Parameters
    ----------
    root : str or os.PathLike
        The paper store root; created if missing.
    store : str or os.PathLike or None
        The GEO/SRA store it reads; ``None`` takes the one ``open_store()`` finds.
    copy_to : str
        The rclone path the paper store is copied to, ``<remote>:<bucket>/<path>``.
    drive_remote : str
        The rclone remote of the Google Drive that holds the Paperpile folder.
    drive_folder : str
        The Drive ID of the Paperpile folder.

    Returns
    -------
    pathlib.Path
        The marker written.

    Raises
    ------
    LabdataError
        If the root already has a marker, or ``store`` is not a GEO/SRA store.
    """
    geo = open_store(store).root.expanduser().absolute()
    path = Path(root).expanduser().absolute()
    path.mkdir(parents=True, exist_ok=True)
    marker = path / MARKER
    lines = [
        toml_line("format_version", FORMAT_VERSION),
        toml_line("store", str(geo)),
        toml_line("copy_to", copy_to),
        toml_line("drive_remote", drive_remote),
        toml_line("drive_folder", drive_folder),
    ]
    create_marker(marker, "\n".join(lines) + "\n", "paper store")
    readme = importlib.resources.files("labdata.papers").joinpath("papers_readme.md")
    write_atomic(path / "README.md", readme.read_text(encoding="utf-8"))
    save_pointer(path)
    return marker

pointer_path

pointer_path() -> Path

Return the per-user pointer file, <cache>/papers.toml, which may not exist.

Source code in src/labdata/papers/root.py
def pointer_path() -> Path:
    """Return the per-user pointer file, ``<cache>/papers.toml``, which may not exist."""
    return liulab_data_cache_dir() / POINTER_FILENAME

save_pointer

save_pointer(root: str | PathLike[str]) -> Path

Write the per-user pointer file, which holds only the paper store root.

Returns:

Type Description
Path

The pointer file written.

Source code in src/labdata/papers/root.py
def save_pointer(root: str | os.PathLike[str]) -> Path:
    """Write the per-user pointer file, which holds only the paper store root.

    Returns
    -------
    pathlib.Path
        The pointer file written.
    """
    return write_pointer(pointer_path(), "papers", root)

paper_lock

paper_lock(root: Path) -> Iterator[None]

Hold the paper store's lock, <root>/.lock; a second holder waits for it.

The daily pass and the PMC sync each hold it for their whole run, so they never overlap.

Source code in src/labdata/papers/root.py
@contextmanager
def paper_lock(root: Path) -> Iterator[None]:
    """Hold the paper store's lock, ``<root>/.lock``; a second holder waits for it.

    The daily pass and the PMC sync each hold it for their whole run, so they never overlap.
    """
    lock = root / LOCK
    with lock.open("a") as fh:
        try:
            fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            print(f"waiting for {lock}: another paper pass is running", file=sys.stderr, flush=True)
            fcntl.flock(fh, fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.flock(fh, fcntl.LOCK_UN)

paper_key

paper_key(text: str) -> str

Return the paper key of a PMID or DOI, as written in a link or by hand.

A PMID, bare or as PMID:<n>, gives its digits; a DOI, bare, as DOI:<doi> or as a doi.org URL, gives the DOI in lower case.

Raises:

Type Description
ValueError

If the text is neither a PMID nor a DOI.

Examples:

>>> paper_key("PMID:39637862"), paper_key("https://doi.org/10.1101/2024.01.15.575678")
('39637862', '10.1101/2024.01.15.575678')
Source code in src/labdata/papers/root.py
def paper_key(text: str) -> str:
    """Return the paper key of a PMID or DOI, as written in a link or by hand.

    A PMID, bare or as ``PMID:<n>``, gives its digits; a DOI, bare, as ``DOI:<doi>`` or as a
    ``doi.org`` URL, gives the DOI in lower case.

    Raises
    ------
    ValueError
        If the text is neither a PMID nor a DOI.

    Examples
    --------
    >>> paper_key("PMID:39637862"), paper_key("https://doi.org/10.1101/2024.01.15.575678")
    ('39637862', '10.1101/2024.01.15.575678')
    """
    text = text.strip()
    if m := _PMID.fullmatch(text):
        return m.group(1)
    doi = norm_doi(text)
    if not doi.startswith("10.") or "/" not in doi:
        raise ValueError(f"not a PMID or a DOI: {text!r}")
    return doi

paper_dir

paper_dir(key: str) -> Path

Return a paper's folder, relative to the paper store root.

A PMID's folder sits in a group of 1,000, as GEO's FTP groups Series; a DOI's folder is the DOI with / written %2F, so the name converts back.

Examples:

>>> paper_dir("39637862").as_posix()
'papers/39637nnn/39637862'
>>> paper_dir("10.1101/2024.01.15.575678").as_posix()
'papers/doi/10.1101%2F2024.01.15.575678'
Source code in src/labdata/papers/root.py
def paper_dir(key: str) -> Path:
    """Return a paper's folder, relative to the paper store root.

    A PMID's folder sits in a group of 1,000, as GEO's FTP groups Series; a DOI's folder is
    the DOI with ``/`` written ``%2F``, so the name converts back.

    Examples
    --------
    >>> paper_dir("39637862").as_posix()
    'papers/39637nnn/39637862'
    >>> paper_dir("10.1101/2024.01.15.575678").as_posix()
    'papers/doi/10.1101%2F2024.01.15.575678'
    """
    if key.isdigit():
        return Path(PAPERS_DIR, f"{key[:-3]}nnn", key)
    return Path(PAPERS_DIR, "doi", urllib.parse.quote(key, safe=""))

tables

The paper store's four tables: their declarations, and the only code that writes them.

Each table is one Parquet file at the paper store root, sorted by its key columns, in row groups of :data:ROW_GROUP_ROWS rows. A step reads a table whole, changes its rows and writes it back through :func:write, which replaces the file by a rename, so a reader sees the old table or the new one, never a part.

PaperTable dataclass

PaperTable(
    name: str,
    schema: Schema,
    sort_by: tuple[str, ...],
    key: tuple[str, ...] = (),
    allowed: Mapping[str, tuple[str, ...]] | None = None,
)

One paper store table: its file name, columns and sort order.

Parameters:

Name Type Description Default
name str

The file's stem at the paper store root.

required
schema Schema

The columns; paper comes first and is never null.

required
sort_by tuple of str

The columns rows are sorted by.

required
key tuple of str

The columns that identify a row; empty when rows may repeat.

()
allowed mapping of str to tuple of str

The values a column may take, where it is a fixed vocabulary.

None

path

path(root: Path) -> Path

Return the table's file under the paper store root.

Source code in src/labdata/papers/tables.py
def path(self, root: Path) -> Path:
    """Return the table's file under the paper store ``root``."""
    return root / f"{self.name}.parquet"

PaperTables dataclass

PaperTables(
    papers: dict[str, dict[str, Any]],
    info: dict[str, dict[str, Any]],
    files: list[dict[str, Any]],
    attempts: list[dict[str, Any]],
)

The four tables of one paper store, loaded whole for a pass to change.

Attributes:

Name Type Description
papers, info dict of str to dict

papers and paper_info rows by paper.

files, attempts list of dict

files and attempts rows.

load classmethod

load(root: Path) -> PaperTables

Read every table of the paper store at root.

Source code in src/labdata/papers/tables.py
@classmethod
def load(cls, root: Path) -> PaperTables:
    """Read every table of the paper store at ``root``."""
    return cls(
        papers={r["paper"]: r for r in read(root, PAPERS)},
        info={r["paper"]: r for r in read(root, PAPER_INFO)},
        files=read(root, FILES),
        attempts=read(root, ATTEMPTS),
    )

save

save(root: Path) -> None

Write every table back, each through a temp file and a rename.

Source code in src/labdata/papers/tables.py
def save(self, root: Path) -> None:
    """Write every table back, each through a temp file and a rename."""
    write(root, PAPERS, self.papers.values())
    write(root, PAPER_INFO, self.info.values())
    write(root, FILES, self.files)
    write(root, ATTEMPTS, self.attempts)

held

held() -> set[str]

Return the papers whose XML or PDF the store holds.

Source code in src/labdata/papers/tables.py
def held(self) -> set[str]:
    """Return the papers whose XML or PDF the store holds."""
    return {f["paper"] for f in self.files if f["kind"] in ("xml", "pdf") and f["path"]}

read

read(root: Path, table: PaperTable) -> list[dict[str, Any]]

Return every row of one table, in its sort order; no rows when the file is missing.

Parameters:

Name Type Description Default
root Path

The paper store root.

required
table PaperTable

One of :data:TABLES.

required

Returns:

Type Description
list of dict

One dict per row, keyed by column name; dates are datetime.date.

Source code in src/labdata/papers/tables.py
def read(root: Path, table: PaperTable) -> list[dict[str, Any]]:
    """Return every row of one table, in its sort order; no rows when the file is missing.

    Parameters
    ----------
    root : pathlib.Path
        The paper store root.
    table : PaperTable
        One of :data:`TABLES`.

    Returns
    -------
    list of dict
        One dict per row, keyed by column name; dates are ``datetime.date``.
    """
    path = table.path(root)
    if not path.is_file():
        return []
    return pq.read_table(path, schema=table.schema).to_pylist()

write

write(
    root: Path,
    table: PaperTable,
    rows: Iterable[Mapping[str, Any]],
) -> Path

Replace one table with rows, sorted, through a temp file and a rename.

The same rows, in any order, give the same file. A write stopped before the rename leaves the old table in place.

Parameters:

Name Type Description Default
root Path

The paper store root.

required
table PaperTable

One of :data:TABLES.

required
rows iterable of mapping

Every row of the new table, keyed by column name; a missing column is null.

required

Returns:

Type Description
Path

The table's file.

Raises:

Type Description
ValueError

If a row names an unknown column, has no paper, repeats a key, or holds a value outside a column's fixed vocabulary.

Source code in src/labdata/papers/tables.py
def write(root: Path, table: PaperTable, rows: Iterable[Mapping[str, Any]]) -> Path:
    """Replace one table with ``rows``, sorted, through a temp file and a rename.

    The same rows, in any order, give the same file. A write stopped before the rename
    leaves the old table in place.

    Parameters
    ----------
    root : pathlib.Path
        The paper store root.
    table : PaperTable
        One of :data:`TABLES`.
    rows : iterable of mapping
        Every row of the new table, keyed by column name; a missing column is null.

    Returns
    -------
    pathlib.Path
        The table's file.

    Raises
    ------
    ValueError
        If a row names an unknown column, has no ``paper``, repeats a key, or holds a value
        outside a column's fixed vocabulary.
    """
    names = set(table.schema.names)
    checked = []
    for row in rows:
        unknown = set(row) - names
        if unknown:
            raise ValueError(f"{table.name}: unknown columns {sorted(unknown)}")
        if not row.get("paper"):
            raise ValueError(f"{table.name}: a row has no paper: {dict(row)}")
        for column, values in (table.allowed or {}).items():
            if row.get(column) is not None and row[column] not in values:
                raise ValueError(f"{table.name}: {column} {row[column]!r} is not one of {values}")
        checked.append(row)
    data = pa.Table.from_pylist(checked, schema=table.schema).sort_by(
        [(c, "ascending") for c in table.sort_by]
    )
    if table.key:
        keys = list(zip(*(data.column(c).to_pylist() for c in table.key), strict=True))
        if len(set(keys)) != len(keys):
            raise ValueError(f"{table.name}: a row's {', '.join(table.key)} appears twice")
    path = table.path(root)
    tmp = temp_beside(path)
    try:
        pq.write_table(
            data, tmp, row_group_size=ROW_GROUP_ROWS, compression="zstd", compression_level=3
        )
        tmp.replace(path)
    finally:
        tmp.unlink(missing_ok=True)
    return path

status

paper-status.json: what the paper job did last and where the papers stand.

The paper job reads it at start to find what is due: a day with no daily-pass success, a month with no PMC sync success.

PaperStatus dataclass

PaperStatus(
    last_pass: dict[str, Any] = dict(),
    last_daily_success: str | None = None,
    last_pmc_sync_success: str | None = None,
    fetches: dict[str, Any] = dict(),
    quota: dict[str, Any] = dict(),
    counts: dict[str, dict[str, int]] = dict(),
    paperpile_pending: bool = False,
    copy_pending: bool = False,
)

The contents of paper-status.json.

Parameters:

Name Type Description Default
last_pass dict

The last pass run: kind (daily or pmc-sync), start and end (UTC, ISO 8601) and outcome (running, success or failed); a PMC sync also gives its sample share, None for all papers, and on success its PMC bucket bucket_retries by HTTP status or failure and bucket_throttled by status.

dict()
last_daily_success str or None

The UTC date of the last daily pass that succeeded.

None
last_pmc_sync_success str or None

The UTC date of the last PMC sync over all papers that succeeded.

None
fetches dict

date and, under by_tier, the papers fetched that day per tier.

dict()
quota dict

date and, under by_key, the OpenAlex downloads spent that day per key.

dict()
counts dict

Papers per status, then per reason.

dict()
paperpile_pending bool

The Paperpile step was skipped because the Drive login has expired.

False
copy_pending bool

The GCS copy was skipped because its login has expired.

False

read_status

read_status(root: Path) -> PaperStatus

Return the paper store's status; an empty one before the first pass.

Parameters:

Name Type Description Default
root Path

The paper store root.

required

Returns:

Type Description
PaperStatus

The status; keys the file holds that PaperStatus lacks are ignored.

Source code in src/labdata/papers/status.py
def read_status(root: Path) -> PaperStatus:
    """Return the paper store's status; an empty one before the first pass.

    Parameters
    ----------
    root : pathlib.Path
        The paper store root.

    Returns
    -------
    PaperStatus
        The status; keys the file holds that ``PaperStatus`` lacks are ignored.
    """
    path = root / STATUS
    if not path.is_file():
        return PaperStatus()
    data = json.loads(path.read_text(encoding="utf-8"))
    names = {f.name for f in fields(PaperStatus)}
    return PaperStatus(**{k: v for k, v in data.items() if k in names})

write_status

write_status(root: Path, status: PaperStatus) -> Path

Write paper-status.json whole, through a temp file and a rename.

Parameters:

Name Type Description Default
root Path

The paper store root.

required
status PaperStatus

The status to write.

required

Returns:

Type Description
Path

The file written.

Source code in src/labdata/papers/status.py
def write_status(root: Path, status: PaperStatus) -> Path:
    """Write ``paper-status.json`` whole, through a temp file and a rename.

    Parameters
    ----------
    root : pathlib.Path
        The paper store root.
    status : PaperStatus
        The status to write.

    Returns
    -------
    pathlib.Path
        The file written.
    """
    path = root / STATUS
    write_atomic(path, json.dumps(asdict(status), indent=2, sort_keys=True) + "\n")
    return path

reader

The paper reader: a paper's details, status and files, and the papers a record links.

A record's papers are joined on demand from the GEO/SRA store's current build, read through its :class:~labdata.store.reader.BuildReader, to the paper store's tables. Nothing per record is stored, so a new build never leaves the answer stale.

Author dataclass

Author(name: str, affiliations: tuple[str, ...])

One author, with affiliations in the order the paper gives them.

PaperInfo dataclass

PaperInfo(
    title: str | None,
    journal: str | None,
    published: date | None,
    authors: tuple[Author, ...],
    abstract: str | None,
    publication_type: tuple[str, ...],
)

A paper's details, from PubMed or, for a DOI-only paper, OpenAlex.

PaperFile dataclass

PaperFile(
    kind: str,
    name: str,
    path: Path | None,
    size: int | None,
    md5: str | None,
    url: str | None,
    tier: str | None,
    label: str | None,
    caption: str | None,
    fetched: date | None,
    gone: date | None,
)

One file of a paper, held or only listed.

path is absolute, and None for a file only listed; a file stored compressed ends in .zst, and :meth:open reads it decompressed. size and md5 are of the plain bytes. gone is the date its source stopped serving it.

open

open() -> io.BufferedIOBase

Open the held file for reading, decompressed.

Returns:

Type Description
BufferedIOBase

A binary file object; close it, or use it in a with block.

Raises:

Type Description
FileNotFoundError

If the file is only listed, not held.

Source code in src/labdata/papers/reader.py
def open(self) -> io.BufferedIOBase:
    """Open the held file for reading, decompressed.

    Returns
    -------
    io.BufferedIOBase
        A binary file object; close it, or use it in a ``with`` block.

    Raises
    ------
    FileNotFoundError
        If the file is only listed, not held.
    """
    if self.path is None:
        raise FileNotFoundError(f"{self.name} is listed, not held; its URL is {self.url}")
    return open_plain(self.path)

Paper dataclass

Paper(
    key: str,
    status: str | None,
    reason: str | None,
    pmid: int | None,
    pmcid: str | None,
    doi: str | None,
    openalex: str | None,
    published: date | None,
    xml_tier: str | None,
    pdf_tier: str | None,
    info: PaperInfo | None,
    files: tuple[PaperFile, ...],
)

One paper: its IDs, status, details and files.

LinkedPaper dataclass

LinkedPaper(
    key: str, status: str | None, reason: str | None
)

A paper a record links, with its status.

status is None when the paper store has not taken the paper in yet.

PaperLinks(accession: str, papers: tuple[LinkedPaper, ...])

The papers one Series or BioProject links, sorted by key.

linked property

linked: bool

Whether the record links any paper; False means "no paper linked".

fetched property

fetched: bool

Whether any linked paper is fetched.

PaperReader

PaperReader(root: Path, build: BuildReader)

Read the paper store, joined to one GEO/SRA build's paper links.

Parameters:

Name Type Description Default
root Path

The paper store root.

required
build BuildReader

The reader of the GEO/SRA store's current build.

required
Source code in src/labdata/papers/reader.py
def __init__(self, root: Path, build: BuildReader) -> None:
    self.root = root
    self.build = build

paper

paper(key: str) -> Paper

Return one paper: its IDs, status, details and files.

Parameters:

Name Type Description Default
key str

A PMID, bare or as PMID:<n>, or a DOI; a DOI finds the paper it belongs to even when that paper is keyed by its PMID.

required

Returns:

Type Description
Paper

The paper; info is None before its details are fetched.

Raises:

Type Description
ValueError

If key is neither a PMID nor a DOI.

NotMirrored

If the paper store does not hold the paper.

Source code in src/labdata/papers/reader.py
def paper(self, key: str) -> Paper:
    """Return one paper: its IDs, status, details and files.

    Parameters
    ----------
    key : str
        A PMID, bare or as ``PMID:<n>``, or a DOI; a DOI finds the paper it belongs to
        even when that paper is keyed by its PMID.

    Returns
    -------
    Paper
        The paper; ``info`` is ``None`` before its details are fetched.

    Raises
    ------
    ValueError
        If ``key`` is neither a PMID nor a DOI.
    NotMirrored
        If the paper store does not hold the paper.
    """
    wanted = paper_key(key)
    found = self.papers([wanted])
    if wanted not in found:
        raise NotMirrored(f"not in the paper store: {wanted}")
    return found[wanted]

papers

papers(keys: Iterable[str]) -> dict[str, Paper]

Return many papers, reading each table once; use it to walk many papers.

Parameters:

Name Type Description Default
keys iterable of str

PMIDs, bare or as PMID:<n>, or DOIs, as :meth:paper takes them.

required

Returns:

Type Description
dict of str to Paper

Each held key, as :func:~labdata.papers.root.paper_key gives it, in input order, mapped to its paper; a key the store does not hold is left out.

Raises:

Type Description
ValueError

If a key is neither a PMID nor a DOI.

Source code in src/labdata/papers/reader.py
def papers(self, keys: Iterable[str]) -> dict[str, Paper]:
    """Return many papers, reading each table once; use it to walk many papers.

    Parameters
    ----------
    keys : iterable of str
        PMIDs, bare or as ``PMID:<n>``, or DOIs, as :meth:`paper` takes them.

    Returns
    -------
    dict of str to Paper
        Each held key, as :func:`~labdata.papers.root.paper_key` gives it, in input
        order, mapped to its paper; a key the store does not hold is left out.

    Raises
    ------
    ValueError
        If a key is neither a PMID nor a DOI.
    """
    wanted = list(dict.fromkeys(paper_key(k) for k in keys))
    if not wanted:
        return {}
    dois = [w for w in wanted if not w.isdigit()]
    rows = self._rows(tables.PAPERS, wanted, dois=dois)
    by_key = {r["paper"]: r for r in rows}
    by_doi = {r["doi"]: r for r in rows if r["doi"]}
    hits = {w: row for w in wanted if (row := by_key.get(w) or by_doi.get(w))}
    held = {row["paper"] for row in hits.values()}
    info = {r["paper"]: r for r in self._rows(tables.PAPER_INFO, held)} if held else {}
    files: dict[str, list[PaperFile]] = {}
    for f in self._rows(tables.FILES, held) if held else []:
        files.setdefault(f["paper"], []).append(_file(self.root, f))
    return {
        w: Paper(
            key=row["paper"],
            status=row["status"],
            reason=row["reason"],
            pmid=row["pmid"],
            pmcid=row["pmcid"],
            doi=row["doi"],
            openalex=row["openalex"],
            published=row["published"],
            xml_tier=row["xml_tier"],
            pdf_tier=row["pdf_tier"],
            info=_info(info[row["paper"]]) if row["paper"] in info else None,
            files=tuple(files.get(row["paper"], ())),
        )
        for w, row in hits.items()
    }

papers_for

papers_for(
    accessions: Iterable[str],
) -> dict[str, PaperLinks]

Map each Series or BioProject to the papers its build links, with their status.

A DOI link counts only when the paper store holds that DOI's paper, or when the record links no PMID: the paper job takes in a DOI-only paper only for such records.

Parameters:

Name Type Description Default
accessions iterable of str

GEO Series and BioProject accessions, in the order wanted back.

required

Returns:

Type Description
dict of str to PaperLinks

Each accession, in input order, mapped to its linked papers; an empty papers means "no paper linked".

Raises:

Type Description
AccessionError

If an accession is malformed.

Source code in src/labdata/papers/reader.py
def papers_for(self, accessions: Iterable[str]) -> dict[str, PaperLinks]:
    """Map each Series or BioProject to the papers its build links, with their status.

    A DOI link counts only when the paper store holds that DOI's paper, or when the
    record links no PMID: the paper job takes in a DOI-only paper only for such records.

    Parameters
    ----------
    accessions : iterable of str
        GEO Series and BioProject accessions, in the order wanted back.

    Returns
    -------
    dict of str to PaperLinks
        Each accession, in input order, mapped to its linked papers; an empty
        ``papers`` means "no paper linked".

    Raises
    ------
    AccessionError
        If an accession is malformed.
    """
    order = [a.strip().upper() for a in accessions]
    for accession in order:
        kind_of(accession)
    targets: dict[str, set[str]] = {a: set() for a in order}
    for src, dst, _relation, _source in self.build.links(set(order)) if order else []:
        if src in targets and dst.startswith((_PMID, _DOI)) and _is_paper(dst):
            targets[src].add(dst)
    linked = {t for ts in targets.values() for t in ts}
    pmids = {paper_key(t) for t in linked if t.startswith(_PMID)}
    dois = {paper_key(t) for t in linked if t.startswith(_DOI)}
    rows = self._rows(tables.PAPERS, pmids | dois, dois=dois) if pmids | dois else []
    by_key = {r["paper"]: r for r in rows}
    by_doi = {r["doi"]: r for r in rows if r["doi"]}
    out = {}
    for accession in order:
        found: dict[str, LinkedPaper] = {}
        has_pmid = any(t.startswith(_PMID) for t in targets[accession])
        for target in targets[accession]:
            key = paper_key(target)
            row = by_key.get(key) or by_doi.get(key)
            if row is None and target.startswith(_DOI) and has_pmid:
                continue
            if row is None:
                found[key] = LinkedPaper(key, None, None)
            else:
                found[row["paper"]] = LinkedPaper(row["paper"], row["status"], row["reason"])
        out[accession] = PaperLinks(accession, tuple(found[k] for k in sorted(found)))
    return out