Skip to content

API reference

Generated from the docstrings in the source, so it cannot fall out of step with the code.

pymzlib.pride

pride

PRIDE Archive access, backed by mzLib's PrideArchiveClient.

The PRIDE Archive (https://www.ebi.ac.uk/pride/archive/) is EBI's public proteomics data repository. This module lets a Python user list what is in a project and pull files down, using the same paging, URL-resolution, and safe-download logic that mzLib uses in C#.

>>> import pymzlib
>>> files = pymzlib.pride.list_files("PXD000001")
>>> len(files)
8
>>> files[0].file_name
'PRIDE_Exp_Complete_Ac_22134.pride.mztab.gz'
>>> raw = [f for f in files if f.category == "RAW"]
>>> pymzlib.pride.download("PXD000001", "downloads", category="RAW")

ProjectNotFoundError

Bases: PyMzLibError

No project with that accession exists, or it has no files.

PRIDE answers an unknown accession with an empty result rather than a 404, so earlier versions of pyMzLib returned an empty list. That was a mistake: an empty list is indistinguishable from "this project genuinely has nothing matching", so a typo'd accession produced a script that reported "0 files, done" and moved on. A wrong answer that looks like a right answer is worse than an error.

Source code in pkg/python/src/pymzlib/pride.py
class ProjectNotFoundError(_bridge.PyMzLibError):
    """No project with that accession exists, or it has no files.

    PRIDE answers an unknown accession with an empty result rather than a 404, so earlier versions
    of pyMzLib returned an empty list. That was a mistake: an empty list is indistinguishable from
    "this project genuinely has nothing matching", so a typo'd accession produced a script that
    reported "0 files, done" and moved on. A wrong answer that looks like a right answer is worse
    than an error.
    """

PrideFile dataclass

One file belonging to a PRIDE Archive project.

Attributes:

Name Type Description
file_name str

The file's name, e.g. "run1.raw".

file_size_bytes int

Size in bytes as reported by PRIDE.

checksum str

The repository's checksum, or "" if it provides none.

category str

The file category, e.g. "RAW", "PEAK", "SEARCH", "OTHER".

https_url str | None

A direct HTTPS download URL, or None when the file is only reachable by a protocol that cannot be fetched over HTTPS (Aspera-only files).

locations list[dict[str, str]]

Every published location as {"accession", "name", "value"} dicts, for callers that want the raw controlled-vocabulary terms.

submission_date / publication_date / updated_date

Repository timestamps.

Source code in pkg/python/src/pymzlib/pride.py
@dataclass(frozen=True)
class PrideFile:
    """One file belonging to a PRIDE Archive project.

    Attributes:
        file_name: The file's name, e.g. ``"run1.raw"``.
        file_size_bytes: Size in bytes as reported by PRIDE.
        checksum: The repository's checksum, or ``""`` if it provides none.
        category: The file category, e.g. ``"RAW"``, ``"PEAK"``, ``"SEARCH"``, ``"OTHER"``.
        https_url: A direct HTTPS download URL, or ``None`` when the file is only reachable
            by a protocol that cannot be fetched over HTTPS (Aspera-only files).
        locations: Every published location as ``{"accession", "name", "value"}`` dicts, for
            callers that want the raw controlled-vocabulary terms.
        submission_date / publication_date / updated_date: Repository timestamps.
    """

    file_name: str
    file_size_bytes: int
    checksum: str
    category: str
    category_accession: str
    https_url: str | None
    locations: list[dict[str, str]] = field(default_factory=list)
    submission_date: datetime | None = None
    publication_date: datetime | None = None
    updated_date: datetime | None = None
    project_accession: str = ""

    @property
    def size_mb(self) -> float:
        """The file size in megabytes, for the common case of eyeballing a manifest."""
        return self.file_size_bytes / 1_000_000

    @property
    def extension(self) -> str:
        """The file's lowercase extension including the dot, e.g. ``".raw"``. Empty if none."""
        return Path(self.file_name).suffix.lower()

    @property
    def downloadable(self) -> bool:
        """Whether this file can be fetched by :func:`download` (i.e. has an HTTPS location)."""
        return self.https_url is not None

    def as_dict(self) -> dict[str, Any]:
        """Return every attribute, **including the computed ones**, as a plain dict.

        Use this rather than ``vars(f)`` when building a table. ``size_mb``, ``extension`` and
        ``downloadable`` are properties, so ``vars()`` and ``dataclasses.asdict()`` both skip
        them — which silently produced a DataFrame missing the three attributes the
        documentation pushes hardest, including the ``downloadable`` flag used to filter out
        files that cannot be fetched.

        Example:
            >>> import pandas as pd                                    # doctest: +SKIP
            >>> df = pd.DataFrame([f.as_dict() for f in files])        # doctest: +SKIP
        """
        record = asdict(self)
        record["size_mb"] = self.size_mb
        record["extension"] = self.extension
        record["downloadable"] = self.downloadable
        return record

    @classmethod
    def _from_wire(cls, payload: dict[str, Any], project_accession: str = "") -> "PrideFile":
        return cls(
            file_name=payload.get("file_name", ""),
            file_size_bytes=int(payload.get("file_size_bytes", 0)),
            checksum=payload.get("checksum", ""),
            category=payload.get("category", ""),
            category_accession=payload.get("category_accession", ""),
            https_url=payload.get("https_url"),
            locations=list(payload.get("locations") or []),
            submission_date=_parse_timestamp(payload.get("submission_date")),
            publication_date=_parse_timestamp(payload.get("publication_date")),
            updated_date=_parse_timestamp(payload.get("updated_date")),
            project_accession=project_accession,
        )
size_mb property
size_mb: float

The file size in megabytes, for the common case of eyeballing a manifest.

extension property
extension: str

The file's lowercase extension including the dot, e.g. ".raw". Empty if none.

downloadable property
downloadable: bool

Whether this file can be fetched by :func:download (i.e. has an HTTPS location).

as_dict
as_dict() -> dict[str, Any]

Return every attribute, including the computed ones, as a plain dict.

Use this rather than vars(f) when building a table. size_mb, extension and downloadable are properties, so vars() and dataclasses.asdict() both skip them — which silently produced a DataFrame missing the three attributes the documentation pushes hardest, including the downloadable flag used to filter out files that cannot be fetched.

Example

import pandas as pd # doctest: +SKIP df = pd.DataFrame([f.as_dict() for f in files]) # doctest: +SKIP

Source code in pkg/python/src/pymzlib/pride.py
def as_dict(self) -> dict[str, Any]:
    """Return every attribute, **including the computed ones**, as a plain dict.

    Use this rather than ``vars(f)`` when building a table. ``size_mb``, ``extension`` and
    ``downloadable`` are properties, so ``vars()`` and ``dataclasses.asdict()`` both skip
    them — which silently produced a DataFrame missing the three attributes the
    documentation pushes hardest, including the ``downloadable`` flag used to filter out
    files that cannot be fetched.

    Example:
        >>> import pandas as pd                                    # doctest: +SKIP
        >>> df = pd.DataFrame([f.as_dict() for f in files])        # doctest: +SKIP
    """
    record = asdict(self)
    record["size_mb"] = self.size_mb
    record["extension"] = self.extension
    record["downloadable"] = self.downloadable
    return record

PrideFtpFile dataclass

One file found by walking a PRIDE project's FTP directory tree — the complete listing.

This is what :func:list_ftp_files returns, and the difference from :class:PrideFile is the whole point: the FTP walk sees everything the project holds, including the files PRIDE's REST manifest omits and files nested in subdirectories. The trade-off is the size: PRIDE's directory index rounds it to about three significant figures, so it is approximate_size_bytes — a good project-size estimate, not the exact number of bytes you will transfer.

Attributes:

Name Type Description
relative_path str

Path relative to the project's FTP root, e.g. "run1.raw" or, for a file in a subdirectory, "generated/summary.mztab".

file_name str

The bare file name — the last segment of relative_path.

url str

The HTTPS URL the file can be downloaded from.

approximate_size_bytes int

PRIDE's rounded index size in bytes. For the exact transfer size of one file, issue an HTTP HEAD against url and read its Content-Length.

project_accession str

The accession this file was listed under.

Source code in pkg/python/src/pymzlib/pride.py
@dataclass(frozen=True)
class PrideFtpFile:
    """One file found by walking a PRIDE project's **FTP directory tree** — the complete listing.

    This is what :func:`list_ftp_files` returns, and the difference from :class:`PrideFile` is the
    whole point: the FTP walk sees everything the project holds, including the files PRIDE's REST
    manifest omits and files nested in subdirectories. The trade-off is the size: PRIDE's directory
    index rounds it to about three significant figures, so it is ``approximate_size_bytes`` — a good
    project-size estimate, not the exact number of bytes you will transfer.

    Attributes:
        relative_path: Path relative to the project's FTP root, e.g. ``"run1.raw"`` or, for a file
            in a subdirectory, ``"generated/summary.mztab"``.
        file_name: The bare file name — the last segment of ``relative_path``.
        url: The HTTPS URL the file can be downloaded from.
        approximate_size_bytes: PRIDE's rounded index size in bytes. For the exact transfer size of
            one file, issue an HTTP HEAD against ``url`` and read its ``Content-Length``.
        project_accession: The accession this file was listed under.
    """

    relative_path: str
    file_name: str
    url: str
    approximate_size_bytes: int
    project_accession: str = ""

    @property
    def approximate_size_mb(self) -> float:
        """The approximate size in megabytes, for eyeballing a project's footprint."""
        return self.approximate_size_bytes / 1_000_000

    @property
    def extension(self) -> str:
        """The file's lowercase extension including the dot, e.g. ``".raw"``. Empty if none."""
        return Path(self.file_name).suffix.lower()

    def as_dict(self) -> dict[str, Any]:
        """Return every attribute, **including the computed ones**, as a plain dict for a DataFrame."""
        record = asdict(self)
        record["approximate_size_mb"] = self.approximate_size_mb
        record["extension"] = self.extension
        return record

    @classmethod
    def _from_wire(cls, payload: dict[str, Any], project_accession: str = "") -> "PrideFtpFile":
        return cls(
            relative_path=payload.get("relative_path", ""),
            file_name=payload.get("file_name", ""),
            url=payload.get("url", ""),
            approximate_size_bytes=int(payload.get("approximate_size_bytes", 0)),
            project_accession=project_accession,
        )
approximate_size_mb property
approximate_size_mb: float

The approximate size in megabytes, for eyeballing a project's footprint.

extension property
extension: str

The file's lowercase extension including the dot, e.g. ".raw". Empty if none.

as_dict
as_dict() -> dict[str, Any]

Return every attribute, including the computed ones, as a plain dict for a DataFrame.

Source code in pkg/python/src/pymzlib/pride.py
def as_dict(self) -> dict[str, Any]:
    """Return every attribute, **including the computed ones**, as a plain dict for a DataFrame."""
    record = asdict(self)
    record["approximate_size_mb"] = self.approximate_size_mb
    record["extension"] = self.extension
    return record

PrideProjectSearchResult dataclass

One hit from :func:search.

This is not a project's full metadata, and the two are not interchangeable. PRIDE serves search from a separate Elasticsearch projection in which every controlled-vocabulary field has been flattened to a display string: the same project reports its instruments as ["Q Exactive"] here and as structured terms with accessions from the metadata endpoint, contacts collapse from ten-field objects to a display name, and publications to a single pre-formatted citation string. That is a property of PRIDE's wire, not a simplification chosen here — the accessions are simply not sent. Follow :attr:accession when you need the vocabulary.

A zero or an empty list means "not reported", never a measured zero. PRIDE omits nothing as null, so absence arrives as 0, "" or [], and several fields are genuinely sparse — sampled across 1,600 hits, :attr:project_tags was populated on 2.6%, :attr:sdrf on 2.4%, :attr:other_omics_links on 18%, and the bot/hub/organic trio on under half. Do not read a download_count of 0 as "nobody downloaded it".

Attributes:

Name Type Description
accession str

The project accession — the key to everything else in this module. Usually a PXD accession, but search also returns legacy PRD and affinity PAD ones, so treat it as an opaque key rather than assuming a prefix.

title str

The project title.

project_description str

The submitter's free-text description.

sample_processing_protocol str

How the sample was prepared, as free text.

data_processing_protocol str

How the data were searched and processed, as free text.

doi str

The dataset DOI, or "" if PRIDE has not minted one.

submission_type str

"COMPLETE", "PARTIAL", "AFFINITY", or "PRIDE" for legacy submissions. Not a closed set — PRIDE has added values before.

sdrf str

The project's SDRF metadata as a single space-joined bag of term values, flattened by the search index. Not a file, filename or URL — nothing can be fetched with it and the row/column structure is gone. For a real SDRF see :mod:pymzlib.sdrf.

submission_date / publication_date / updated_date

Calendar :class:~datetime.date values, not timestamps — see :func:_parse_date. None when PRIDE reported none.

project_tags list[str]

PRIDE's coarse classification tags.

keywords list[str]

The submitter's free-text keywords. May contain empty and whitespace-only strings — PRIDE ships them on roughly 9% of hits. They are passed through rather than filtered, because dropping them here would make this module disagree with mzLib and with the Rust and R bindings about what a project's keywords are. Filter before joining.

submitters / lab_pis / affiliations

Display names and affiliations, flattened from the structured contact objects the metadata endpoint returns.

instruments / softwares / quantification_methods

Display names.

sample_attributes list[str]

Sample characteristics by display value (e.g. "liver"). Flattened: which characteristic each value describes is not recoverable from a search hit.

organisms / organism_parts / diseases

Display names.

references list[str]

Publications, each a single pre-formatted citation string. A PubMed ID or DOI cannot be read out of one without parsing the string PRIDE assembled.

experiment_types list[str]

e.g. "Data-independent acquisition".

project_file_names list[str]

File names only — a search convenience, not the manifest. It carries no sizes, categories or download locations. Use :func:list_files, or :func:list_ftp_files for the complete list, to act on the files.

other_omics_links list[str]

Links to related datasets in other omics repositories.

highlights dict[str, list[str]]

Why this project matched, keyed by the field each match was found in, with the matched terms wrapped in <em> markup. The keys vary per hit and per query. This is the one thing search returns that the metadata endpoint cannot.

yearly_downloads list[dict[str, Any]]

{"year", "count"} dicts.

download_count / avg_downloads_per_file / percentile

Download popularity.

bot_count / hub_count / organic_count

Downloads split by traffic kind.

Source code in pkg/python/src/pymzlib/pride.py
@dataclass(frozen=True)
class PrideProjectSearchResult:
    """One hit from :func:`search`.

    **This is not a project's full metadata, and the two are not interchangeable.** PRIDE serves
    search from a separate Elasticsearch projection in which every controlled-vocabulary field has
    been **flattened to a display string**: the same project reports its instruments as
    ``["Q Exactive"]`` here and as structured terms with accessions from the metadata endpoint,
    contacts collapse from ten-field objects to a display name, and publications to a single
    pre-formatted citation string. That is a property of PRIDE's wire, not a simplification chosen
    here — the accessions are simply not sent. Follow :attr:`accession` when you need the
    vocabulary.

    **A zero or an empty list means "not reported", never a measured zero.** PRIDE omits nothing as
    null, so absence arrives as ``0``, ``""`` or ``[]``, and several fields are genuinely sparse —
    sampled across 1,600 hits, :attr:`project_tags` was populated on 2.6%, :attr:`sdrf` on 2.4%,
    :attr:`other_omics_links` on 18%, and the bot/hub/organic trio on under half. Do not read a
    ``download_count`` of 0 as "nobody downloaded it".

    Attributes:
        accession: The project accession — the key to everything else in this module. Usually a
            ``PXD`` accession, but search also returns legacy ``PRD`` and affinity ``PAD`` ones, so
            treat it as an opaque key rather than assuming a prefix.
        title: The project title.
        project_description: The submitter's free-text description.
        sample_processing_protocol: How the sample was prepared, as free text.
        data_processing_protocol: How the data were searched and processed, as free text.
        doi: The dataset DOI, or ``""`` if PRIDE has not minted one.
        submission_type: ``"COMPLETE"``, ``"PARTIAL"``, ``"AFFINITY"``, or ``"PRIDE"`` for legacy
            submissions. Not a closed set — PRIDE has added values before.
        sdrf: The project's SDRF metadata as a single space-joined bag of term *values*, flattened
            by the search index. **Not a file, filename or URL** — nothing can be fetched with it
            and the row/column structure is gone. For a real SDRF see :mod:`pymzlib.sdrf`.
        submission_date / publication_date / updated_date: Calendar :class:`~datetime.date` values,
            not timestamps — see :func:`_parse_date`. ``None`` when PRIDE reported none.
        project_tags: PRIDE's coarse classification tags.
        keywords: The submitter's free-text keywords. **May contain empty and whitespace-only
            strings** — PRIDE ships them on roughly 9% of hits. They are passed through rather than
            filtered, because dropping them here would make this module disagree with mzLib and with
            the Rust and R bindings about what a project's keywords are. Filter before joining.
        submitters / lab_pis / affiliations: Display names and affiliations, flattened from the
            structured contact objects the metadata endpoint returns.
        instruments / softwares / quantification_methods: Display names.
        sample_attributes: Sample characteristics by display *value* (e.g. ``"liver"``). Flattened:
            which characteristic each value describes is **not recoverable** from a search hit.
        organisms / organism_parts / diseases: Display names.
        references: Publications, each a single pre-formatted citation string. A PubMed ID or DOI
            cannot be read out of one without parsing the string PRIDE assembled.
        experiment_types: e.g. ``"Data-independent acquisition"``.
        project_file_names: File *names* only — a search convenience, **not the manifest**. It
            carries no sizes, categories or download locations. Use :func:`list_files`, or
            :func:`list_ftp_files` for the complete list, to act on the files.
        other_omics_links: Links to related datasets in other omics repositories.
        highlights: Why this project matched, keyed by the field each match was found in, with the
            matched terms wrapped in ``<em>`` markup. The keys vary per hit and per query. This is
            the one thing search returns that the metadata endpoint cannot.
        yearly_downloads: ``{"year", "count"}`` dicts.
        download_count / avg_downloads_per_file / percentile: Download popularity.
        bot_count / hub_count / organic_count: Downloads split by traffic kind.
    """

    accession: str
    title: str = ""
    project_description: str = ""
    sample_processing_protocol: str = ""
    data_processing_protocol: str = ""
    doi: str = ""
    submission_type: str = ""
    sdrf: str = ""
    submission_date: date | None = None
    publication_date: date | None = None
    updated_date: date | None = None
    project_tags: list[str] = field(default_factory=list)
    keywords: list[str] = field(default_factory=list)
    submitters: list[str] = field(default_factory=list)
    lab_pis: list[str] = field(default_factory=list)
    affiliations: list[str] = field(default_factory=list)
    instruments: list[str] = field(default_factory=list)
    softwares: list[str] = field(default_factory=list)
    quantification_methods: list[str] = field(default_factory=list)
    sample_attributes: list[str] = field(default_factory=list)
    organisms: list[str] = field(default_factory=list)
    organism_parts: list[str] = field(default_factory=list)
    diseases: list[str] = field(default_factory=list)
    references: list[str] = field(default_factory=list)
    experiment_types: list[str] = field(default_factory=list)
    project_file_names: list[str] = field(default_factory=list)
    other_omics_links: list[str] = field(default_factory=list)
    highlights: dict[str, list[str]] = field(default_factory=dict)
    yearly_downloads: list[dict[str, Any]] = field(default_factory=list)
    download_count: int = 0
    avg_downloads_per_file: float = 0.0
    percentile: int = 0
    bot_count: int = 0
    hub_count: int = 0
    organic_count: int = 0

    @property
    def matched_fields(self) -> list[str]:
        """Which PRIDE fields the query hit, from :attr:`highlights`. Empty if PRIDE reported none."""
        return sorted(self.highlights)

    def as_dict(self) -> dict[str, Any]:
        """Every attribute, **including the computed ones**, as a plain dict.

        Use this rather than ``vars()`` when building a table: ``matched_fields`` is a property, so
        ``dataclasses.asdict()`` silently omits it. Same reasoning as :meth:`PrideFile.as_dict`.
        """
        record = asdict(self)
        record["matched_fields"] = self.matched_fields
        return record

    @classmethod
    def _from_wire(cls, payload: dict[str, Any]) -> "PrideProjectSearchResult":
        def strings(name: str) -> list[str]:
            return [str(x) for x in (payload.get(name) or [])]

        return cls(
            accession=payload.get("accession", ""),
            title=payload.get("title", ""),
            project_description=payload.get("project_description", ""),
            sample_processing_protocol=payload.get("sample_processing_protocol", ""),
            data_processing_protocol=payload.get("data_processing_protocol", ""),
            doi=payload.get("doi", ""),
            submission_type=payload.get("submission_type", ""),
            sdrf=payload.get("sdrf", ""),
            submission_date=_parse_date(payload.get("submission_date")),
            publication_date=_parse_date(payload.get("publication_date")),
            updated_date=_parse_date(payload.get("updated_date")),
            project_tags=strings("project_tags"),
            keywords=strings("keywords"),
            submitters=strings("submitters"),
            lab_pis=strings("lab_pis"),
            affiliations=strings("affiliations"),
            instruments=strings("instruments"),
            softwares=strings("softwares"),
            quantification_methods=strings("quantification_methods"),
            sample_attributes=strings("sample_attributes"),
            organisms=strings("organisms"),
            organism_parts=strings("organism_parts"),
            diseases=strings("diseases"),
            references=strings("references"),
            experiment_types=strings("experiment_types"),
            project_file_names=strings("project_file_names"),
            other_omics_links=strings("other_omics_links"),
            highlights={
                str(k): [str(v) for v in (vs or [])]
                for k, vs in (payload.get("highlights") or {}).items()
            },
            yearly_downloads=list(payload.get("yearly_downloads") or []),
            download_count=int(payload.get("download_count", 0)),
            avg_downloads_per_file=float(payload.get("avg_downloads_per_file", 0.0)),
            percentile=int(payload.get("percentile", 0)),
            bot_count=int(payload.get("bot_count", 0)),
            hub_count=int(payload.get("hub_count", 0)),
            organic_count=int(payload.get("organic_count", 0)),
        )
matched_fields property
matched_fields: list[str]

Which PRIDE fields the query hit, from :attr:highlights. Empty if PRIDE reported none.

as_dict
as_dict() -> dict[str, Any]

Every attribute, including the computed ones, as a plain dict.

Use this rather than vars() when building a table: matched_fields is a property, so dataclasses.asdict() silently omits it. Same reasoning as :meth:PrideFile.as_dict.

Source code in pkg/python/src/pymzlib/pride.py
def as_dict(self) -> dict[str, Any]:
    """Every attribute, **including the computed ones**, as a plain dict.

    Use this rather than ``vars()`` when building a table: ``matched_fields`` is a property, so
    ``dataclasses.asdict()`` silently omits it. Same reasoning as :meth:`PrideFile.as_dict`.
    """
    record = asdict(self)
    record["matched_fields"] = self.matched_fields
    return record

list_files

list_files(accession: str, page_size: int = 100, timeout: float | None = 300) -> list[PrideFile]

Return the file manifest of a PRIDE Archive project.

This is what PRIDE's REST API publishes, which is not always everything in the project. For PXD000001 the API returns 8 files while the FTP tree holds 13, and the five it omits include the two largest: ...60min_01-20141210.mzML (450 MB) and the matching .mzXML (472 MB), exactly the modern open-format conversions most people want. The omission is PRIDE's, not mzLib's. If completeness matters, use :func:list_ftp_files, which walks the project's FTP directory and returns everything it actually holds.

Paging is handled for you: however many pages the project spans, you get one list.

Parameters:

Name Type Description Default
accession str

The project accession, e.g. "PXD000001".

required
page_size int

How many files to request per underlying API call. Only affects how the manifest is fetched, never what you get back.

100
timeout float | None

Seconds to allow for the whole fetch.

300

Returns:

Type Description
list[PrideFile]

Every file in the project, in repository order. An unknown accession yields an empty

list[PrideFile]

list rather than an error — that is PRIDE's own behavior, preserved here.

Raises:

Type Description
UsageError

the accession is blank or the page size is not positive.

BridgeError

PRIDE returned an error status or was unreachable.

Source code in pkg/python/src/pymzlib/pride.py
def list_files(accession: str, page_size: int = 100, timeout: float | None = 300) -> list[PrideFile]:
    """Return the file manifest of a PRIDE Archive project.

    **This is what PRIDE's REST API publishes, which is not always everything in the
    project.** For PXD000001 the API returns **8** files while the FTP tree holds **13**, and
    the five it omits include the two largest: ``...60min_01-20141210.mzML`` (450 MB) and the
    matching ``.mzXML`` (472 MB), exactly the modern open-format conversions most people want.
    The omission is PRIDE's, not mzLib's. **If completeness matters, use :func:`list_ftp_files`,**
    which walks the project's FTP directory and returns everything it actually holds.

    Paging is handled for you: however many pages the project spans, you get one list.

    Args:
        accession: The project accession, e.g. ``"PXD000001"``.
        page_size: How many files to request per underlying API call. Only affects how the
            manifest is fetched, never what you get back.
        timeout: Seconds to allow for the whole fetch.

    Returns:
        Every file in the project, in repository order. An unknown accession yields an empty
        list rather than an error — that is PRIDE's own behavior, preserved here.

    Raises:
        UsageError: the accession is blank or the page size is not positive.
        BridgeError: PRIDE returned an error status or was unreachable.
    """
    canonical = _normalise_accession(accession)
    if isinstance(page_size, bool) or not isinstance(page_size, int):
        raise _bridge.UsageError(
            f"page_size must be a whole number; got {type(page_size).__name__} ({page_size!r})."
        )
    if page_size <= 0:
        raise _bridge.UsageError(f"page_size must be positive; got {page_size}.")
    if page_size > 2_147_483_647:
        raise _bridge.UsageError(f"page_size is larger than the API allows; got {page_size}.")

    data = _bridge.invoke(
        "pride", "files",
        "--accession", canonical,
        "--page-size", str(page_size),
        timeout=timeout,
    )
    files = [PrideFile._from_wire(item, canonical) for item in data.get("files", [])]

    if not files:
        raise ProjectNotFoundError(
            f"PRIDE returned no files for '{canonical}'. Either the accession does not exist "
            "(check for a typo) or the project is private. PRIDE does not distinguish the two, "
            "so neither can pyMzLib."
        )
    return files

list_ftp_files

list_ftp_files(accession: str, timeout: float | None = 300) -> list[PrideFtpFile]

Return the complete file list of a PRIDE project, read from its FTP directory tree.

This is the authoritative counterpart to :func:list_files. Where list_files returns PRIDE's REST manifest — which is knowingly incomplete, omitting for PXD000001 five of the project's 13 files, including the two largest — this walks the FTP directory (subdirectories included) and returns everything the project actually holds. Reach for it whenever completeness or a true project size matters; use :func:list_files when you want the rich metadata (category, checksum, controlled-vocabulary locations) that the REST manifest carries and the directory index does not.

This is a listing surface only. :func:download and :func:download_files operate on the REST manifest, so a file that appears only here — the whole point of this function — is not accepted by them; fetch it directly from its :attr:PrideFtpFile.url with an ordinary HTTPS client (e.g. urllib.request.urlretrieve(f.url, f.file_name)).

The sizes are approximate: PRIDE's directory index rounds them (see :attr:PrideFtpFile.approximate_size_bytes), so :func:approximate_total_size_bytes is an estimate — but an estimate over the whole project, unlike :func:total_size_bytes.

>>> ftp = pymzlib.pride.list_ftp_files("PXD000001")           # doctest: +SKIP
>>> len(ftp)                                                  # doctest: +SKIP
13
>>> nested = [f.relative_path for f in ftp if "/" in f.relative_path]   # doctest: +SKIP

Parameters:

Name Type Description Default
accession str

The project accession, e.g. "PXD000001".

required
timeout float | None

Seconds to allow for the whole walk, which spans one request per directory.

300

Returns:

Type Description
list[PrideFtpFile]

Every file under the project's FTP root, subdirectories included, in the order the walk

list[PrideFtpFile]

encounters them. Never empty — an empty result is raised as an error (see below).

Raises:

Type Description
UsageError

the accession is blank or malformed.

ProjectNotFoundError

no project has that accession (or it lacks the publication date that locates its FTP directory), or the directory listed no files. Same "no such project" signal :func:list_files raises, so one except catches both — the difference from list_files is only how it is detected (mzLib resolves the project before walking, so a typo fails loudly rather than looking like an empty project).

ServiceUnavailableError / BridgeError

PRIDE was unreachable, or a directory fetch failed.

Source code in pkg/python/src/pymzlib/pride.py
def list_ftp_files(accession: str, timeout: float | None = 300) -> list[PrideFtpFile]:
    """Return the **complete** file list of a PRIDE project, read from its FTP directory tree.

    This is the authoritative counterpart to :func:`list_files`. Where ``list_files`` returns
    PRIDE's REST manifest — which is knowingly incomplete, omitting for PXD000001 five of the
    project's 13 files, including the two largest — this walks the FTP directory (subdirectories
    included) and returns everything the project actually holds. Reach for it whenever completeness
    or a true project size matters; use :func:`list_files` when you want the rich metadata (category,
    checksum, controlled-vocabulary locations) that the REST manifest carries and the directory
    index does not.

    This is a **listing** surface only. :func:`download` and :func:`download_files` operate on the
    REST manifest, so a file that appears *only* here — the whole point of this function — is not
    accepted by them; fetch it directly from its :attr:`PrideFtpFile.url` with an ordinary HTTPS
    client (e.g. ``urllib.request.urlretrieve(f.url, f.file_name)``).

    The sizes are approximate: PRIDE's directory index rounds them (see
    :attr:`PrideFtpFile.approximate_size_bytes`), so :func:`approximate_total_size_bytes` is an
    estimate — but an estimate over the *whole* project, unlike :func:`total_size_bytes`.

        >>> ftp = pymzlib.pride.list_ftp_files("PXD000001")           # doctest: +SKIP
        >>> len(ftp)                                                  # doctest: +SKIP
        13
        >>> nested = [f.relative_path for f in ftp if "/" in f.relative_path]   # doctest: +SKIP

    Args:
        accession: The project accession, e.g. ``"PXD000001"``.
        timeout: Seconds to allow for the whole walk, which spans one request per directory.

    Returns:
        Every file under the project's FTP root, subdirectories included, in the order the walk
        encounters them. Never empty — an empty result is raised as an error (see below).

    Raises:
        UsageError: the accession is blank or malformed.
        ProjectNotFoundError: no project has that accession (or it lacks the publication date that
            locates its FTP directory), or the directory listed no files. Same "no such project"
            signal :func:`list_files` raises, so one ``except`` catches both — the difference from
            ``list_files`` is only *how* it is detected (mzLib resolves the project before walking,
            so a typo fails loudly rather than looking like an empty project).
        ServiceUnavailableError / BridgeError: PRIDE was unreachable, or a directory fetch failed.
    """
    canonical = _normalise_accession(accession)

    try:
        data = _bridge.invoke(
            "pride", "ftp-files",
            "--accession", canonical,
            timeout=timeout,
        )
    except _bridge.BridgeError as exc:
        # mzLib resolves the project (and its publication date) before walking, so an unknown
        # accession comes back as an MzLibException rather than an empty list. Re-map it to the same
        # ProjectNotFoundError that list_files() raises so callers catch one type for "not there".
        # ServiceUnavailableError (a BridgeError subclass) has a different error_type and is left to
        # propagate — an outage is not a missing project.
        if exc.error_type == "MzLibException":
            raise ProjectNotFoundError(
                f"PRIDE has no project '{canonical}' (or it lacks the publication date needed to "
                "locate its FTP directory). Check for a typo — a private project looks the same."
            ) from exc
        raise

    files = [PrideFtpFile._from_wire(item, canonical) for item in data.get("files", [])]
    if not files:
        # The project resolved but its FTP directory listed nothing. Almost always PRIDE changing
        # its autoindex format, never a real published project. Raise rather than hand back [], the
        # same "0 files, done" trap list_files() defends against.
        raise ProjectNotFoundError(
            f"The FTP directory for '{canonical}' listed no files. Either the project is genuinely "
            "empty or PRIDE's directory-index format has changed; list_files() may still return "
            "its REST manifest."
        )
    return files

download

download(accession: str, destination: str | Path, category: str | None = None, extensions: Sequence[str] | None = None, overwrite: bool = True, timeout: float | None = None) -> list[Path]

Download a project's files, optionally filtered, and return where they landed.

Files are streamed to a temporary name and moved into place only once complete, so an interrupted download never leaves a truncated file behind.

Parameters:

Name Type Description Default
accession str

The project accession, e.g. "PXD000001".

required
destination str | Path

Directory to write into. Created if it does not exist.

required
category str | None

Keep only files of this category, e.g. "RAW". None keeps all.

None
extensions Sequence[str] | None

Keep only files with these extensions, e.g. [".raw", ".mzML"]. None keeps all. Combined with category as AND.

None
overwrite bool

When False, a file already present at the destination is left alone and not re-fetched — a cheap resume for a large project.

True
timeout float | None

Seconds to allow. None (the default) waits as long as it takes, which is usually what you want for multi-gigabyte projects.

None

Returns:

Type Description
list[Path]

The paths written, in manifest order.

Raises:

Type Description
UsageError

the accession or destination is blank.

BridgeError

a request failed, or a selected file has no HTTPS location.

Source code in pkg/python/src/pymzlib/pride.py
def download(
    accession: str,
    destination: str | Path,
    category: str | None = None,
    extensions: Sequence[str] | None = None,
    overwrite: bool = True,
    timeout: float | None = None,
) -> list[Path]:
    """Download a project's files, optionally filtered, and return where they landed.

    Files are streamed to a temporary name and moved into place only once complete, so an
    interrupted download never leaves a truncated file behind.

    Args:
        accession: The project accession, e.g. ``"PXD000001"``.
        destination: Directory to write into. Created if it does not exist.
        category: Keep only files of this category, e.g. ``"RAW"``. ``None`` keeps all.
        extensions: Keep only files with these extensions, e.g. ``[".raw", ".mzML"]``.
            ``None`` keeps all. Combined with ``category`` as AND.
        overwrite: When ``False``, a file already present at the destination is left alone
            and not re-fetched — a cheap resume for a large project.
        timeout: Seconds to allow. ``None`` (the default) waits as long as it takes, which
            is usually what you want for multi-gigabyte projects.

    Returns:
        The paths written, in manifest order.

    Raises:
        UsageError: the accession or destination is blank.
        BridgeError: a request failed, or a selected file has no HTTPS location.
    """
    canonical = _normalise_accession(accession)
    target = _normalise_destination(destination)
    wanted = _normalise_extensions(extensions)

    args = ["pride", "download", "--accession", canonical, "--dest", str(target)]
    if category is not None:
        if not isinstance(category, str):
            raise _bridge.UsageError(
                f"category must be a string like 'RAW'; got {type(category).__name__} ({category!r})."
            )
        if not category.strip():
            raise _bridge.UsageError(
                "category is empty. Omit it to download every category, rather than passing a "
                "blank value — a filter that selects nothing must not silently select everything."
            )
        args += ["--category", _reject_flag_like("category", category.strip())]
    if wanted:
        args += ["--ext", _reject_flag_like("extensions", ",".join(wanted))]
    if not overwrite:
        args.append("--no-overwrite")

    data = _bridge.invoke(*args, timeout=timeout)
    written = [Path(p) for p in data.get("paths", [])]

    # Same doctrine as list_files and download_files, which this function was inconsistent with:
    # a filter that matched nothing is nearly always a filter that does not mean what its author
    # thought, and reporting success with an empty list lets a batch script carry on as though
    # the work had been done.
    if not written and (category is not None or wanted):
        raise _bridge.UsageError(
            f"No file in {canonical} matched "
            f"{'category ' + repr(category) if category else ''}"
            f"{' and ' if category and wanted else ''}"
            f"{'extensions ' + repr(wanted) if wanted else ''}. "
            "Use list_files() to see what the project actually contains — note that "
            "compressed files such as 'x.mgf.gz' have the extension '.gz'."
        )
    return written

download_files

download_files(files: Iterable[PrideFile], destination: str | Path, overwrite: bool = True, timeout: float | None = None) -> list[Path]

Download exactly the files you selected, and nothing else.

This is the counterpart to :func:list_files, and usually the one you want. Filter the manifest however you like — in Python, with the full expressiveness of Python — and hand the result straight back:

>>> files = list_files("PXD000001")                             # doctest: +SKIP
>>> small = [f for f in files if f.size_mb < 5 and f.downloadable]
>>> download_files(small, "downloads")                          # doctest: +SKIP

:func:download's category and extensions filters can only express what they were built to express; "under 5 MB", "the three newest", or "everything except the MGF" cannot be said in that vocabulary at all. They can all be said in a list comprehension.

Parameters:

Name Type Description Default
files Iterable[PrideFile]

The :class:PrideFile objects to fetch, from one project.

required
destination str | Path

Directory to write into. Created if it does not exist.

required
overwrite bool

When False, files already present are left alone and not re-fetched.

True
timeout float | None

Seconds to allow. None waits as long as it takes.

None

Returns:

Type Description
list[Path]

The paths written, in the order the repository lists them.

Raises:

Type Description
UsageError

the selection is empty, spans several projects, or includes a file with no HTTPS location.

Source code in pkg/python/src/pymzlib/pride.py
def download_files(
    files: Iterable[PrideFile],
    destination: str | Path,
    overwrite: bool = True,
    timeout: float | None = None,
) -> list[Path]:
    """Download exactly the files you selected, and nothing else.

    This is the counterpart to :func:`list_files`, and usually the one you want. Filter the
    manifest however you like — in Python, with the full expressiveness of Python — and hand the
    result straight back:

        >>> files = list_files("PXD000001")                             # doctest: +SKIP
        >>> small = [f for f in files if f.size_mb < 5 and f.downloadable]
        >>> download_files(small, "downloads")                          # doctest: +SKIP

    :func:`download`'s ``category`` and ``extensions`` filters can only express what they were
    built to express; "under 5 MB", "the three newest", or "everything except the MGF" cannot be
    said in that vocabulary at all. They can all be said in a list comprehension.

    Args:
        files: The :class:`PrideFile` objects to fetch, from one project.
        destination: Directory to write into. Created if it does not exist.
        overwrite: When ``False``, files already present are left alone and not re-fetched.
        timeout: Seconds to allow. ``None`` waits as long as it takes.

    Returns:
        The paths written, in the order the repository lists them.

    Raises:
        UsageError: the selection is empty, spans several projects, or includes a file with no
            HTTPS location.
    """
    target = _normalise_destination(destination)
    selected = list(files)

    if not selected:
        raise _bridge.UsageError(
            "No files selected. An empty selection is almost always a filter that did not match "
            "what you expected, so pyMzLib refuses it rather than reporting success."
        )
    for item in selected:
        if not isinstance(item, PrideFile):
            raise _bridge.UsageError(
                f"download_files expects PrideFile objects from list_files(); got "
                f"{type(item).__name__} ({item!r})."
            )

    unreachable = [f.file_name for f in selected if not f.downloadable]
    if unreachable:
        raise _bridge.UsageError(
            f"{len(unreachable)} of {len(selected)} selected files have no HTTPS location and "
            f"cannot be downloaded (e.g. {unreachable[0]!r}). Filter on `.downloadable` first."
        )

    accessions = {f.project_accession for f in selected if f.project_accession}
    if len(accessions) > 1:
        raise _bridge.UsageError(
            f"All files must come from one project; got {sorted(accessions)}."
        )
    if not accessions:
        raise _bridge.UsageError(
            "These PrideFile objects carry no project accession, so pyMzLib cannot tell which "
            "project to fetch from. Obtain them from list_files()."
        )

    args = [
        "pride", "download",
        "--accession", accessions.pop(),
        "--dest", str(target),
        "--names-from-stdin",
    ]
    if not overwrite:
        args.append("--no-overwrite")

    # The selection travels on stdin rather than argv: a few thousand names would blow the ~32 KB
    # command-line ceiling. The framing is newline-delimited, which is *almost* general — a POSIX
    # file name may legally contain a newline, so such a name would split into two and silently
    # select the wrong files. PRIDE has never published one, but "never seen it" is not a contract,
    # so it is refused explicitly rather than mis-parsed quietly.
    embedded_newline = [f.file_name for f in selected if "\n" in f.file_name or "\r" in f.file_name]
    if embedded_newline:
        raise _bridge.UsageError(
            f"Cannot select {embedded_newline[0]!r}: the file name contains a line break, which the "
            "selection format cannot represent. Please open an issue — this is a limitation worth "
            "fixing properly if a real repository ever publishes such a name."
        )

    payload = "\n".join(f.file_name for f in selected)
    data = _bridge.invoke(*args, stdin=payload, timeout=timeout)
    return [Path(p) for p in data.get("paths", [])]

total_size_bytes

total_size_bytes(files: Iterable[PrideFile]) -> int

Sum the sizes of some files.

This is the size PRIDE reports, which is not the number of bytes you will transfer. For compressed files PRIDE frequently reports the decompressed size: in PXD000001 the reported size of PRIDE_Exp_Complete_Ac_22134.pride.mgf.gz is 16,448,103 bytes, exactly what gzip -l gives as its uncompressed length, while the actual download is 5,984,662 bytes, 2.75x smaller.

It is also a sum over an incomplete manifest. For PXD000001 this returns 0.51 GB; the project on disk is 1.44 GB, because PRIDE's API omits five files including the two largest (see :func:list_files). The two errors run in opposite directions and do not cancel. For a size that covers the whole project, use :func:approximate_total_size_bytes over :func:list_ftp_files.

files = list_files("PXD000001") # doctest: +SKIP total_size_bytes(f for f in files if f.category == "RAW") / 1e9 # doctest: +SKIP 0.51

Source code in pkg/python/src/pymzlib/pride.py
def total_size_bytes(files: Iterable[PrideFile]) -> int:
    """Sum the sizes of some files.

    **This is the size PRIDE reports, which is not the number of bytes you will transfer.**
    For compressed files PRIDE frequently reports the *decompressed* size: in PXD000001 the
    reported size of ``PRIDE_Exp_Complete_Ac_22134.pride.mgf.gz`` is 16,448,103 bytes, exactly
    what ``gzip -l`` gives as its uncompressed length, while the actual download is 5,984,662
    bytes, 2.75x smaller.

    **It is also a sum over an incomplete manifest.** For PXD000001 this returns 0.51 GB; the
    project on disk is 1.44 GB, because PRIDE's API omits five files including the two largest
    (see :func:`list_files`). The two errors run in opposite directions and do **not** cancel. For
    a size that covers the whole project, use :func:`approximate_total_size_bytes` over
    :func:`list_ftp_files`.

    >>> files = list_files("PXD000001")           # doctest: +SKIP
    >>> total_size_bytes(f for f in files if f.category == "RAW") / 1e9   # doctest: +SKIP
    0.51
    """
    return sum(f.file_size_bytes for f in files)

approximate_total_size_bytes

approximate_total_size_bytes(files: Iterable[PrideFtpFile]) -> int

Sum the approximate sizes of some FTP files.

This is the honest project-size number, and the counterpart to :func:total_size_bytes with the trade-offs reversed. It sums over the complete FTP listing (:func:list_ftp_files), so no files are missing — but each size is PRIDE's directory-index value, rounded to about three significant figures, so the total is an estimate, not an exact byte count. For PXD000001 it lands near the true 1.44 GB, where :func:total_size_bytes reports 0.51 GB over the incomplete REST manifest. When you need the exact bytes for one file, HTTP HEAD its :attr:PrideFtpFile.url and read Content-Length.

ftp = list_ftp_files("PXD000001") # doctest: +SKIP approximate_total_size_bytes(ftp) / 1e9 # doctest: +SKIP 1.44

Source code in pkg/python/src/pymzlib/pride.py
def approximate_total_size_bytes(files: Iterable[PrideFtpFile]) -> int:
    """Sum the approximate sizes of some FTP files.

    This is the honest project-size number, and the counterpart to :func:`total_size_bytes` with the
    trade-offs reversed. It sums over the **complete** FTP listing (:func:`list_ftp_files`), so no
    files are missing — but each size is PRIDE's directory-index value, rounded to about three
    significant figures, so the total is an **estimate**, not an exact byte count. For PXD000001 it
    lands near the true 1.44 GB, where :func:`total_size_bytes` reports 0.51 GB over the incomplete
    REST manifest. When you need the exact bytes for one file, HTTP HEAD its
    :attr:`PrideFtpFile.url` and read ``Content-Length``.

    >>> ftp = list_ftp_files("PXD000001")                       # doctest: +SKIP
    >>> approximate_total_size_bytes(ftp) / 1e9                 # doctest: +SKIP
    1.44
    """
    return sum(f.approximate_size_bytes for f in files)

search

search(keyword: str, page_size: int = 100, timeout: float | None = 300) -> list[PrideProjectSearchResult]

Find PRIDE projects by keyword.

The discovery entry point. Every other function here takes an accession you already have; this is the one that produces them, so you can go from a subject to a dataset without leaving Python.

Paging is handled for you: however many pages the result set spans, you get one list, with no accession repeated.

Parameters:

Name Type Description Default
keyword str

What to search for, e.g. "phosphoproteome". Matched across titles, descriptions, keywords, organisms and more — :attr:~PrideProjectSearchResult.highlights on each hit says which fields actually matched.

required
page_size int

How many hits to request per underlying API call. Only affects how the result set is fetched, never what you get back.

100
timeout float | None

Seconds to allow for the whole fetch.

300

Returns:

Type Description
list[PrideProjectSearchResult]

Every matching project. An empty list is a real answer — PRIDE reports no hits as an

list[PrideProjectSearchResult]

empty result rather than an error, so unlike :func:list_files this does not raise

list[PrideProjectSearchResult]

class:ProjectNotFoundError: there is no accession here that could have been a typo.

Raises:

Type Description
UsageError

the keyword is blank, over MAX_KEYWORD_LENGTH, or begins with -; or the page size is not positive.

BridgeError

PRIDE returned an error status or was unreachable.

Note

PRIDE pages a live index with no stable cursor, so a result set that changes during a multi-page fetch shifts its own paging. A project published mid-fetch is served on two pages and deduplicated, so it comes back once; a project removed mid-fetch can fall between two pages and be missed. A search whose hits fit on one page cannot be affected.

Example

hits = search("plasmodium falciparum schizont") # doctest: +SKIP hits[0].accession, hits[0].organisms # doctest: +SKIP ('PXD070842', ['Homo sapiens (human)', 'Plasmodium falciparum (isolate 3d7)']) hits[0].matched_fields # doctest: +SKIP ['references', 'title'] files = list_files(hits[0].accession) # doctest: +SKIP

Source code in pkg/python/src/pymzlib/pride.py
def search(
    keyword: str,
    page_size: int = 100,
    timeout: float | None = 300,
) -> list[PrideProjectSearchResult]:
    """Find PRIDE projects by keyword.

    **The discovery entry point.** Every other function here takes an accession you already have;
    this is the one that produces them, so you can go from a subject to a dataset without leaving
    Python.

    Paging is handled for you: however many pages the result set spans, you get one list, with no
    accession repeated.

    Args:
        keyword: What to search for, e.g. ``"phosphoproteome"``. Matched across titles,
            descriptions, keywords, organisms and more — :attr:`~PrideProjectSearchResult.highlights`
            on each hit says which fields actually matched.
        page_size: How many hits to request per underlying API call. Only affects how the result
            set is fetched, never what you get back.
        timeout: Seconds to allow for the whole fetch.

    Returns:
        Every matching project. **An empty list is a real answer** — PRIDE reports no hits as an
        empty result rather than an error, so unlike :func:`list_files` this does not raise
        :class:`ProjectNotFoundError`: there is no accession here that could have been a typo.

    Raises:
        UsageError: the keyword is blank, over ``MAX_KEYWORD_LENGTH``, or begins with ``-``; or the
            page size is not positive.
        BridgeError: PRIDE returned an error status or was unreachable.

    Note:
        PRIDE pages a **live index** with no stable cursor, so a result set that changes during a
        multi-page fetch shifts its own paging. A project published mid-fetch is served on two pages
        and deduplicated, so it comes back once; a project *removed* mid-fetch can fall between two
        pages and be missed. A search whose hits fit on one page cannot be affected.

    Example:
        >>> hits = search("plasmodium falciparum schizont")        # doctest: +SKIP
        >>> hits[0].accession, hits[0].organisms                   # doctest: +SKIP
        ('PXD070842', ['Homo sapiens (human)', 'Plasmodium falciparum (isolate 3d7)'])
        >>> hits[0].matched_fields                                 # doctest: +SKIP
        ['references', 'title']
        >>> files = list_files(hits[0].accession)                  # doctest: +SKIP
    """
    if not isinstance(keyword, str) or not keyword.strip():
        raise _bridge.UsageError("A search keyword is required, e.g. 'phosphoproteome'.")

    canonical = _reject_flag_like("keyword", keyword.strip())
    if len(canonical) > MAX_KEYWORD_LENGTH:
        raise _bridge.UsageError(
            f"keyword may be at most {MAX_KEYWORD_LENGTH} characters; got {len(canonical)}. "
            "PRIDE answers a longer keyword with HTTP 500, which cannot be told apart from the "
            "service being down."
        )

    if isinstance(page_size, bool) or not isinstance(page_size, int):
        raise _bridge.UsageError(
            f"page_size must be a whole number; got {type(page_size).__name__} ({page_size!r})."
        )
    if page_size <= 0:
        raise _bridge.UsageError(f"page_size must be positive; got {page_size}.")
    if page_size > 2_147_483_647:
        raise _bridge.UsageError(f"page_size is larger than the API allows; got {page_size}.")

    data = _bridge.invoke(
        "pride", "search",
        "--keyword", canonical,
        "--page-size", str(page_size),
        timeout=timeout,
    )
    return [PrideProjectSearchResult._from_wire(item) for item in data.get("results", [])]

pymzlib.peptidoform

peptidoform

Peptidoform-level questions: digest an annotated protein and fragment its peptides.

The question this answers is the one a mass spectrometrist actually asks — what fragments would I see for this protein's peptides? — in one call:

>>> import pymzlib
>>> digest = pymzlib.peptidoform.fragments("P02768")          # doctest: +SKIP
>>> len(digest.peptides)                                      # doctest: +SKIP
303

The defaults are opinions, not placeholders. Tryptic with the proline rule, two missed cleavages, ETD, both termini, UniProt's annotated modifications applied. They are the choices this lab makes when it does not have a reason to choose otherwise, so the common question needs no parameters — and every one of them is reachable, because the point is to open the doors, not to hide them.

Fragment dataclass

One backbone fragment ion.

Attributes:

Name Type Description
product_type str

The ion series, e.g. "c" or "zDot" for ETD, "b"/"y" for CID.

fragment_number int

Position in the series — c3 is the third from the N-terminus.

neutral_mass float

Monoisotopic neutral mass in daltons. Not an m/z: no proton has been added and no charge assumed.

Fragments deliberately expose no mz(). Converting one correctly requires the fixed charge within this fragment's span — a c or z ion carries only the permanently charged modifications on the residues it contains, not the whole peptide's :attr:Peptide.fixed_charges. For an unmodified or neutrally-modified peptide, (neutral_mass + z * PROTON_MASS) / z is correct; for a fragment bearing a quaternary-ammonium modification (e.g. trimethyllysine) it is not, and per-fragment charge accounting is not yet provided.

neutral_loss float

Neutral loss in daltons, 0.0 when there is none.

residue_position int

One-based residue position in the peptide.

Source code in pkg/python/src/pymzlib/peptidoform.py
@dataclass(frozen=True)
class Fragment:
    """One backbone fragment ion.

    Attributes:
        product_type: The ion series, e.g. ``"c"`` or ``"zDot"`` for ETD, ``"b"``/``"y"`` for CID.
        fragment_number: Position in the series — ``c3`` is the third from the N-terminus.
        neutral_mass: Monoisotopic neutral mass in daltons. **Not** an m/z: no proton has been
            added and no charge assumed.

            Fragments deliberately expose no ``mz()``. Converting one correctly requires the fixed
            charge *within this fragment's span* — a c or z ion carries only the permanently
            charged modifications on the residues it contains, not the whole peptide's
            :attr:`Peptide.fixed_charges`. For an unmodified or neutrally-modified peptide,
            ``(neutral_mass + z * PROTON_MASS) / z`` is correct; for a fragment bearing a
            quaternary-ammonium modification (e.g. trimethyllysine) it is not, and per-fragment
            charge accounting is not yet provided.
        neutral_loss: Neutral loss in daltons, ``0.0`` when there is none.
        residue_position: One-based residue position in the peptide.
    """

    product_type: str
    fragment_number: int
    neutral_mass: float
    neutral_loss: float
    residue_position: int

Peptide dataclass

One digested peptide, with its modifications and fragment ions.

Attributes:

Name Type Description
base_sequence str

The bare amino-acid sequence.

full_sequence str

The sequence with modifications written inline, as mzLib renders them.

monoisotopic_mass float

The neutral monoisotopic mass, modifications included.

one_based_start / one_based_end

Position within the parent protein.

missed_cleavages int

How many cleavage sites the peptide spans.

fixed_charges int

Charges the peptide carries before any protonation, from modifications that leave a permanently charged residue. :meth:mz accounts for these.

modifications list[dict[str, Any]]

Each applied modification. one_based_residue indexes the peptide's own residues and is None for a terminal modification, which carries terminus instead. (mzLib's internal dictionary reserves slot 1 for the N-terminus, so its keys are one past the residue they modify; that is corrected here rather than passed on.)

fragments list[Fragment]

The fragment ions for the requested dissociation type.

Source code in pkg/python/src/pymzlib/peptidoform.py
@dataclass(frozen=True)
class Peptide:
    """One digested peptide, with its modifications and fragment ions.

    Attributes:
        base_sequence: The bare amino-acid sequence.
        full_sequence: The sequence with modifications written inline, as mzLib renders them.
        monoisotopic_mass: The neutral monoisotopic mass, modifications included.
        one_based_start / one_based_end: Position within the parent protein.
        missed_cleavages: How many cleavage sites the peptide spans.
        fixed_charges: Charges the peptide carries before any protonation, from modifications
            that leave a permanently charged residue. :meth:`mz` accounts for these.
        modifications: Each applied modification. ``one_based_residue`` indexes the peptide's own
            residues and is ``None`` for a terminal modification, which carries ``terminus``
            instead. (mzLib's internal dictionary reserves slot 1 for the N-terminus, so its keys
            are one past the residue they modify; that is corrected here rather than passed on.)
        fragments: The fragment ions for the requested dissociation type.
    """

    base_sequence: str
    full_sequence: str
    monoisotopic_mass: float
    length: int
    one_based_start: int
    one_based_end: int
    missed_cleavages: int
    fixed_charges: int = 0
    modifications: list[dict[str, Any]] = field(default_factory=list)
    fragments: list[Fragment] = field(default_factory=list)

    @property
    def is_modified(self) -> bool:
        """Whether this peptide carries at least one modification."""
        return bool(self.modifications)

    def mz(self, charge: int) -> float:
        """Return the m/z of the intact peptide at a given total charge.

        Two conventions are handled explicitly here, because getting either wrong is invisible in
        the answer.

        **The proton mass (1.007276), not the hydrogen atom (1.007825).** The difference is
        0.55 mDa — 1.1 ppm at m/z 500, which on an Orbitrap is a match versus a miss. Libraries
        differ on this and rarely say which they used.

        **Fixed charges are not double-counted.** Some modifications leave the residue permanently
        charged: trimethylation of a lysine ε-amine gives a quaternary ammonium, and UniProt
        records the delta as 43.054227 — C₃H₇ *minus an electron* — rather than the neutral
        43.054775. So :attr:`monoisotopic_mass` already carries that charge, and only
        ``charge - fixed_charges`` protons are added. Adding a full complement would put a 2+
        trimethylated peptide half a Thomson high, on the most important histone modification
        there is.

        A peptide with a fixed charge is therefore observable at that charge with no protonation
        at all, which is why ``charge`` may not be below :attr:`fixed_charges`.

        Args:
            charge: The total charge state, at least :attr:`fixed_charges` and at least 1.
        """
        if not isinstance(charge, int) or isinstance(charge, bool) or charge < 1:
            raise _bridge.UsageError(f"charge must be a positive whole number; got {charge!r}.")
        if charge < self.fixed_charges:
            raise _bridge.UsageError(
                f"This peptide already carries {self.fixed_charges} fixed charge(s) from its "
                f"modifications, so it cannot be observed at charge {charge}."
            )
        return (self.monoisotopic_mass + (charge - self.fixed_charges) * PROTON_MASS) / charge

    @classmethod
    def _from_wire(cls, payload: dict[str, Any]) -> "Peptide":
        return cls(
            base_sequence=payload.get("base_sequence", ""),
            full_sequence=payload.get("full_sequence", ""),
            monoisotopic_mass=float(payload.get("monoisotopic_mass", 0.0)),
            length=int(payload.get("length", 0)),
            one_based_start=int(payload.get("one_based_start", 0)),
            one_based_end=int(payload.get("one_based_end", 0)),
            missed_cleavages=int(payload.get("missed_cleavages", 0)),
            fixed_charges=int(payload.get("fixed_charges", 0)),
            modifications=list(payload.get("modifications") or []),
            fragments=[
                Fragment(
                    product_type=f.get("product_type", ""),
                    fragment_number=int(f.get("fragment_number", 0)),
                    neutral_mass=float(f.get("neutral_mass", 0.0)),
                    neutral_loss=float(f.get("neutral_loss", 0.0)),
                    residue_position=int(f.get("residue_position", 0)),
                )
                for f in (payload.get("fragments") or [])
            ],
        )
is_modified property
is_modified: bool

Whether this peptide carries at least one modification.

mz
mz(charge: int) -> float

Return the m/z of the intact peptide at a given total charge.

Two conventions are handled explicitly here, because getting either wrong is invisible in the answer.

The proton mass (1.007276), not the hydrogen atom (1.007825). The difference is 0.55 mDa — 1.1 ppm at m/z 500, which on an Orbitrap is a match versus a miss. Libraries differ on this and rarely say which they used.

Fixed charges are not double-counted. Some modifications leave the residue permanently charged: trimethylation of a lysine ε-amine gives a quaternary ammonium, and UniProt records the delta as 43.054227 — C₃H₇ minus an electron — rather than the neutral 43.054775. So :attr:monoisotopic_mass already carries that charge, and only charge - fixed_charges protons are added. Adding a full complement would put a 2+ trimethylated peptide half a Thomson high, on the most important histone modification there is.

A peptide with a fixed charge is therefore observable at that charge with no protonation at all, which is why charge may not be below :attr:fixed_charges.

Parameters:

Name Type Description Default
charge int

The total charge state, at least :attr:fixed_charges and at least 1.

required
Source code in pkg/python/src/pymzlib/peptidoform.py
def mz(self, charge: int) -> float:
    """Return the m/z of the intact peptide at a given total charge.

    Two conventions are handled explicitly here, because getting either wrong is invisible in
    the answer.

    **The proton mass (1.007276), not the hydrogen atom (1.007825).** The difference is
    0.55 mDa — 1.1 ppm at m/z 500, which on an Orbitrap is a match versus a miss. Libraries
    differ on this and rarely say which they used.

    **Fixed charges are not double-counted.** Some modifications leave the residue permanently
    charged: trimethylation of a lysine ε-amine gives a quaternary ammonium, and UniProt
    records the delta as 43.054227 — C₃H₇ *minus an electron* — rather than the neutral
    43.054775. So :attr:`monoisotopic_mass` already carries that charge, and only
    ``charge - fixed_charges`` protons are added. Adding a full complement would put a 2+
    trimethylated peptide half a Thomson high, on the most important histone modification
    there is.

    A peptide with a fixed charge is therefore observable at that charge with no protonation
    at all, which is why ``charge`` may not be below :attr:`fixed_charges`.

    Args:
        charge: The total charge state, at least :attr:`fixed_charges` and at least 1.
    """
    if not isinstance(charge, int) or isinstance(charge, bool) or charge < 1:
        raise _bridge.UsageError(f"charge must be a positive whole number; got {charge!r}.")
    if charge < self.fixed_charges:
        raise _bridge.UsageError(
            f"This peptide already carries {self.fixed_charges} fixed charge(s) from its "
            f"modifications, so it cannot be observed at charge {charge}."
        )
    return (self.monoisotopic_mass + (charge - self.fixed_charges) * PROTON_MASS) / charge

ModificationCensus dataclass

What UniProt annotates, and what could actually be used.

mzLib loads only modified residue and lipid moiety-binding region annotations; every other feature type is dropped on feature type alone, before any mass lookup. So the census sees the world at feature-type granularity — one entry per type in :attr:by_type, never per modification name. On serum albumin the 24 excluded features all sit under the single type glycosylation site; at UniProt's finer name level 22 of those 24 are specifically N-linked (Glc) (glycation) lysine, which does have a defined mass — but the census never surfaces that name, so "22" is a fact you confirm by reading the UniProt entry, not a number this class reports. Read the exclusion as "wrong feature type", not "no defined mass".

The exclusion is still correct: glycation and glycosylation are labile, heterogeneous adducts, so assigning one an exact mass and a clean fragment ladder would describe a species you cannot observe. What this class exists for is that you should not have to guess it happened: for serum albumin, 14 sites are applied out of 38 annotated, and without this the 14 arrives with no indication that a rule was ever applied. See smith-chem-wisc/mzLib#1112.

Attributes:

Name Type Description
sites int

Distinct residue positions carrying at least one modification. A histone lists several alternatives at one residue — K9me1, K9me2, K9me3, K9ac are four modifications at one site — so this is always the smaller number and is not a modification count.

applied int

Modifications actually placed on the protein.

annotated int

Modification-like features UniProt lists.

by_type list[dict[str, Any]]

One entry per feature type, with count and whether it was loaded.

unresolved list[str]

Modification names UniProt annotated that could not be resolved to a mass — usually because the name is absent from UniProt's own ptmlist. These vanish silently otherwise: on histone H3.1, seven N6-lactoyllysine sites were dropped while the type summary still reported "modified residue … loaded".

Source code in pkg/python/src/pymzlib/peptidoform.py
@dataclass(frozen=True)
class ModificationCensus:
    """What UniProt annotates, and what could actually be used.

    mzLib loads only ``modified residue`` and ``lipid moiety-binding region`` annotations; every
    other feature type is dropped **on feature type alone**, before any mass lookup. So the census
    sees the world at feature-*type* granularity — one entry per type in :attr:`by_type`, never per
    modification *name*. On serum albumin the 24 excluded features all sit under the single type
    ``glycosylation site``; at UniProt's finer name level 22 of those 24 are specifically
    ``N-linked (Glc) (glycation) lysine``, which *does* have a defined mass — but the census never
    surfaces that name, so "22" is a fact you confirm by reading the UniProt entry, not a number
    this class reports. Read the exclusion as "wrong feature type", **not** "no defined mass".

    The exclusion is still correct: glycation and glycosylation are labile, heterogeneous adducts,
    so assigning one an exact mass and a clean fragment ladder would describe a species you cannot
    observe. What this class exists for is that you should not have to *guess* it happened: for
    serum albumin, 14 sites are applied out of 38 annotated, and without this the 14 arrives with
    no indication that a rule was ever applied. See smith-chem-wisc/mzLib#1112.

    Attributes:
        sites: Distinct residue positions carrying at least one modification. A histone lists
            several alternatives at one residue — K9me1, K9me2, K9me3, K9ac are four
            modifications at one site — so this is always the smaller number and is not a
            modification count.
        applied: Modifications actually placed on the protein.
        annotated: Modification-like features UniProt lists.
        by_type: One entry per feature type, with ``count`` and whether it was ``loaded``.
        unresolved: Modification names UniProt annotated that could not be resolved to a mass —
            usually because the name is absent from UniProt's own ptmlist. These vanish silently
            otherwise: on histone H3.1, seven N6-lactoyllysine sites were dropped while the type
            summary still reported "modified residue … loaded".
    """

    sites: int
    applied: int
    annotated: int
    unresolved: list[str] = field(default_factory=list)
    by_type: list[dict[str, Any]] = field(default_factory=list)

    @property
    def excluded(self) -> int:
        """Annotated features mzLib did not apply, dropped on feature type (not for want of mass)."""
        return max(0, self.annotated - self.applied)

    def explain(self) -> str:
        """A one-paragraph, human-readable account of what was used and what was not.

        It names the excluded feature *types* and their counts — the only granularity the census
        has. It never reports a modification-*name*-level breakdown (e.g. "22 of 24 are glycation"),
        because :attr:`by_type` does not carry names; such a figure comes from reading the UniProt
        entry, not from this census.
        """
        if not self.excluded:
            return (
                f"All {self.annotated} annotated modifications were applied, across "
                f"{self.sites} residue positions."
            )
        sentences = [
            f"{self.applied} of {self.annotated} annotated modifications were applied, across "
            f"{self.sites} residue positions."
        ]
        excluded_types = [t for t in self.by_type if not t.get("loaded")]
        if excluded_types:
            named = ", ".join(f"{t['count']} × {t['type']}" for t in excluded_types)
            sentences.append(
                f"Excluded by type: {named} — mzLib loads only 'modified residue' "
                "and 'lipid moiety-binding region' annotations, so these were dropped on "
                "feature type alone. The exclusion is usually right: a glycation or "
                "glycosylation annotation describes a labile, heterogeneous adduct, so "
                "assigning it one exact mass and a clean fragment ladder would invent a "
                "species you cannot observe. But the reason is not reported, and the "
                "qualifier is not read: some annotations are marked 'in vitro' and some "
                "exist only in disease variants, which are different "
                "grounds for exclusion needing different judgements from you. Read the "
                "annotations on the UniProt entry before concluding anything about a "
                "specific site; this census can only tell you the count "
                "(smith-chem-wisc/mzLib#1112)."
            )
        if self.unresolved:
            sentences.append(
                f"Could not be resolved to a mass: {', '.join(self.unresolved)} — annotated by "
                "UniProt but absent from its own modification list, so they were dropped."
            )
        return " ".join(sentences)
excluded property
excluded: int

Annotated features mzLib did not apply, dropped on feature type (not for want of mass).

explain
explain() -> str

A one-paragraph, human-readable account of what was used and what was not.

It names the excluded feature types and their counts — the only granularity the census has. It never reports a modification-name-level breakdown (e.g. "22 of 24 are glycation"), because :attr:by_type does not carry names; such a figure comes from reading the UniProt entry, not from this census.

Source code in pkg/python/src/pymzlib/peptidoform.py
def explain(self) -> str:
    """A one-paragraph, human-readable account of what was used and what was not.

    It names the excluded feature *types* and their counts — the only granularity the census
    has. It never reports a modification-*name*-level breakdown (e.g. "22 of 24 are glycation"),
    because :attr:`by_type` does not carry names; such a figure comes from reading the UniProt
    entry, not from this census.
    """
    if not self.excluded:
        return (
            f"All {self.annotated} annotated modifications were applied, across "
            f"{self.sites} residue positions."
        )
    sentences = [
        f"{self.applied} of {self.annotated} annotated modifications were applied, across "
        f"{self.sites} residue positions."
    ]
    excluded_types = [t for t in self.by_type if not t.get("loaded")]
    if excluded_types:
        named = ", ".join(f"{t['count']} × {t['type']}" for t in excluded_types)
        sentences.append(
            f"Excluded by type: {named} — mzLib loads only 'modified residue' "
            "and 'lipid moiety-binding region' annotations, so these were dropped on "
            "feature type alone. The exclusion is usually right: a glycation or "
            "glycosylation annotation describes a labile, heterogeneous adduct, so "
            "assigning it one exact mass and a clean fragment ladder would invent a "
            "species you cannot observe. But the reason is not reported, and the "
            "qualifier is not read: some annotations are marked 'in vitro' and some "
            "exist only in disease variants, which are different "
            "grounds for exclusion needing different judgements from you. Read the "
            "annotations on the UniProt entry before concluding anything about a "
            "specific site; this census can only tell you the count "
            "(smith-chem-wisc/mzLib#1112)."
        )
    if self.unresolved:
        sentences.append(
            f"Could not be resolved to a mass: {', '.join(self.unresolved)} — annotated by "
            "UniProt but absent from its own modification list, so they were dropped."
        )
    return " ".join(sentences)

Digest dataclass

The result of digesting a protein and fragmenting its peptides.

Source code in pkg/python/src/pymzlib/peptidoform.py
@dataclass(frozen=True)
class Digest:
    """The result of digesting a protein and fragmenting its peptides."""

    accession: str
    name: str
    full_name: str
    organism: str
    sequence_length: int
    protease: str
    dissociation: str
    terminus: str
    modifications_applied: bool
    max_modifications: int
    max_isoforms: int
    peptides_at_cap: int
    modification_census: ModificationCensus
    peptides: list[Peptide] = field(default_factory=list)

    @property
    def truncated(self) -> bool:
        """Whether any peptide hit the isoform cap, meaning the result is incomplete.

        A short answer and a truncated answer look identical from the outside. Check this before
        treating a Peptidoform list as exhaustive.
        """
        return self.peptides_at_cap > 0

    @property
    def modified_peptides(self) -> list[Peptide]:
        """Only the peptides carrying at least one modification."""
        return [p for p in self.peptides if p.is_modified]

    @property
    def fragment_count(self) -> int:
        """Total fragment ions across every peptide."""
        return sum(len(p.fragments) for p in self.peptides)
truncated property
truncated: bool

Whether any peptide hit the isoform cap, meaning the result is incomplete.

A short answer and a truncated answer look identical from the outside. Check this before treating a Peptidoform list as exhaustive.

modified_peptides property
modified_peptides: list[Peptide]

Only the peptides carrying at least one modification.

fragment_count property
fragment_count: int

Total fragment ions across every peptide.

fragments

fragments(accession: str, protease: str = 'trypsin|P', dissociation: str = 'ETD', modifications: bool = True, missed_cleavages: int = 2, min_length: int = 7, max_length: int | None = None, max_modifications: int = 2, max_isoforms: int = 1024, terminus: str = 'Both', timeout: float | None = 300) -> Digest

Fetch a UniProt entry, digest it, and fragment every peptide.

Parameters:

Name Type Description Default
accession str

A UniProtKB accession, e.g. "P02768".

required
protease str

Read this if you are coming from MaxQuant or Mascot. mzLib's "trypsin|P" applies the classic Keil rule — cleave after K/R except before proline — and is the default here because it is what a mass spectrometrist usually means. mzLib's plain "trypsin" cleaves before proline too. That is the reverse of the MaxQuant and Mascot convention, where Trypsin/P denotes ignoring the proline rule. On serum albumin the two differ by 7 peptides out of about 200 (195 vs 202; tryptic, 2 missed cleavages, min length 7) - a small count hiding a large semantic difference, since which peptides you get changes wherever a K/R precedes a proline.

'trypsin|P'
dissociation str

"HCD"/"CID" (b and y ions), "ETD", and the rest of mzLib's dissociation types.

"ETD" returns the c and zDot series (radical N-Ca cleavage yields c/z, not the b/y of vibrational activation). Note that z ions are suppressed N-terminal to proline while the complementary c ions are not, leaving about 4% of the c series unobservable (smith-chem-wisc/mzLib#1110).

'ETD'
modifications bool

Apply UniProt's annotated modifications.

Pass False for the bare sequences — a clean control, since the digest's distinct backbones are unchanged and only the modified variants of them go away. The peptidoform count drops a long way; on albumin from 303 to 195.

This once carried a caveat saying False was not a clean control, because it discarded UniProt's whole feature table and with it the signal-peptide and propeptide boundaries mzLib digests at — costing albumin two peptides and changing the peptide list, not just the modifications on it (issue #8). That was true and is no longer: verified against the published bridge, both peptides are present either way and albumin gives 195 distinct base sequences with modifications on or off.

True
missed_cleavages int

Maximum missed cleavage sites per peptide.

2
min_length int

Shortest peptide to keep. The default of 7 silently discards shorter peptides — roughly a third of a histone digest — so pass min_length=1 when you mean every peptide.

7
max_length int | None

Longest peptide to keep. None means unbounded.

None
max_modifications int

Maximum modifications considered per peptide. Modification isoforms are enumerated combinatorially: histone H3.1 yields 49 bare tryptic peptides, 2,563 at two modifications and 7,040 at three.

2
max_isoforms int

Maximum modification isoforms per peptide position. mzLib's default of 1024 truncates silently when it binds — on H3.1 at four modifications it discards about 30% of the Peptidoforms (13,700 down to 9,536). :attr:Digest.peptides_at_cap reports how many peptides hit it, so a truncated answer is visible rather than merely short.

1024
terminus str

"Both", "N" or "C".

'Both'
timeout float | None

Seconds to allow. Large proteins with many modification isoforms take longer.

300

Returns:

Name Type Description
A Digest

class:Digest. Check :attr:Digest.modification_census before trusting a

Digest

modification count — it reports what was annotated as well as what was applied.

Raises:

Type Description
UsageError

the accession, protease, dissociation type or terminus is not recognised.

ServiceUnavailableError

UniProt was unreachable.

Example

d = fragments("P02768") # doctest: +SKIP print(d.modification_census.explain()) # doctest: +SKIP 14 of 38 annotated modification sites were applied. Excluded: 24 × glycosylation site …

Source code in pkg/python/src/pymzlib/peptidoform.py
def fragments(
    accession: str,
    protease: str = "trypsin|P",
    dissociation: str = "ETD",
    modifications: bool = True,
    missed_cleavages: int = 2,
    min_length: int = 7,
    max_length: int | None = None,
    max_modifications: int = 2,
    max_isoforms: int = 1024,
    terminus: str = "Both",
    timeout: float | None = 300,
) -> Digest:
    """Fetch a UniProt entry, digest it, and fragment every peptide.

    Args:
        accession: A UniProtKB accession, e.g. ``"P02768"``.
        protease: **Read this if you are coming from MaxQuant or Mascot.** mzLib's ``"trypsin|P"``
            applies the classic Keil rule — cleave after K/R *except* before proline — and is the
            default here because it is what a mass spectrometrist usually means. mzLib's plain
            ``"trypsin"`` cleaves before proline too. That is the **reverse** of the MaxQuant and
            Mascot convention, where ``Trypsin/P`` denotes ignoring the proline rule. On serum
            albumin the two differ by 7 peptides out of about 200 (195 vs 202; tryptic, 2
            missed cleavages, min length 7) - a small count hiding a large semantic
            difference, since which peptides you get changes wherever a K/R precedes a
            proline.
        dissociation: ``"HCD"``/``"CID"`` (b and y ions), ``"ETD"``, and the rest of mzLib's
            dissociation types.

            ``"ETD"`` returns the c and zDot series (radical N-Ca cleavage yields c/z*, not the
            b/y of vibrational activation). Note that z* ions are suppressed N-terminal to
            proline while the complementary c ions are not, leaving about 4% of the c series
            unobservable (smith-chem-wisc/mzLib#1110).
        modifications: Apply UniProt's annotated modifications.

            Pass ``False`` for the bare sequences — a clean control, since the digest's
            distinct backbones are unchanged and only the modified variants of them go
            away. The peptidoform count drops a long way; on albumin from 303 to 195.

            This once carried a caveat saying ``False`` was *not* a clean control, because
            it discarded UniProt's whole feature table and with it the signal-peptide and
            propeptide boundaries mzLib digests at — costing albumin two peptides and
            changing the peptide list, not just the modifications on it (issue #8). That
            was true and is no longer: verified against the published bridge, both peptides
            are present either way and albumin gives 195 distinct base sequences with
            modifications on or off.
        missed_cleavages: Maximum missed cleavage sites per peptide.
        min_length: Shortest peptide to keep. The default of 7 silently discards shorter
            peptides — roughly a third of a histone digest — so pass ``min_length=1`` when you
            mean *every* peptide.
        max_length: Longest peptide to keep. ``None`` means unbounded.
        max_modifications: Maximum modifications considered per peptide. Modification isoforms
            are enumerated combinatorially: histone H3.1 yields 49 bare tryptic peptides, 2,563
            at two modifications and 7,040 at three.
        max_isoforms: Maximum modification isoforms per peptide position. mzLib's default of 1024
            **truncates silently** when it binds — on H3.1 at four modifications it discards
            about 30% of the Peptidoforms (13,700 down to 9,536). :attr:`Digest.peptides_at_cap`
            reports how many peptides hit it, so a truncated answer is visible rather than
            merely short.
        terminus: ``"Both"``, ``"N"`` or ``"C"``.
        timeout: Seconds to allow. Large proteins with many modification isoforms take longer.

    Returns:
        A :class:`Digest`. Check :attr:`Digest.modification_census` before trusting a
        modification count — it reports what was annotated as well as what was applied.

    Raises:
        UsageError: the accession, protease, dissociation type or terminus is not recognised.
        ServiceUnavailableError: UniProt was unreachable.

    Example:
        >>> d = fragments("P02768")                                    # doctest: +SKIP
        >>> print(d.modification_census.explain())                     # doctest: +SKIP
        14 of 38 annotated modification sites were applied. Excluded: 24 × glycosylation site …
    """
    if not isinstance(accession, str) or not accession.strip():
        raise _bridge.UsageError("A UniProt accession is required, e.g. 'P02768'.")
    canonical = accession.strip().upper()
    if not _ACCESSION.match(canonical):
        raise _bridge.UsageError(
            f"'{accession}' is not a valid UniProtKB accession. They look like 'P02768' or "
            "'A0A0B4J2D5' — see https://www.uniprot.org/help/accession_numbers."
        )
    # UniProt's accessions are upper-case and its API is case-sensitive, so the validated
    # canonical form is what gets sent, not the caller's original casing.
    if max_length is not None and (isinstance(max_length, bool) or not isinstance(max_length, int)):
        raise _bridge.UsageError(f"max_length must be a whole number or None; got {max_length!r}.")
    for name, value in (("missed_cleavages", missed_cleavages), ("min_length", min_length),
                        ("max_modifications", max_modifications), ("max_isoforms", max_isoforms)):
        if isinstance(value, bool) or not isinstance(value, int) or value < 0:
            raise _bridge.UsageError(f"{name} must be a non-negative whole number; got {value!r}.")
    if max_isoforms < 1:
        raise _bridge.UsageError(f"max_isoforms must be at least 1; got {max_isoforms}.")

    args = [
        "peptidoform", "fragments",
        "--accession", canonical,
        "--protease", protease,
        "--dissociation", dissociation,
        "--terminus", terminus,
        "--missed-cleavages", str(missed_cleavages),
        "--min-length", str(min_length),
        "--max-length", str(max_length if max_length else 0),
        "--max-mods", str(max_modifications),
        "--max-isoforms", str(max_isoforms),
    ]
    if not modifications:
        args.append("--no-modifications")

    data = _bridge.invoke(*args, timeout=timeout)

    census = ModificationCensus(
        sites=int(data.get("annotated_modification_sites", 0)),
        applied=int(data.get("annotated_modifications_loaded", 0)),
        annotated=int(data.get("uniprot_annotated_features", 0)),
        by_type=list(data.get("uniprot_features_by_type") or []),
        unresolved=list(data.get("unresolved_modifications") or []),
    )

    return Digest(
        accession=data.get("accession", ""),
        name=data.get("name", "") or "",
        full_name=data.get("full_name", "") or "",
        organism=data.get("organism", "") or "",
        sequence_length=int(data.get("sequence_length", 0)),
        protease=data.get("protease", ""),
        dissociation=data.get("dissociation", ""),
        terminus=data.get("terminus", ""),
        modifications_applied=bool(data.get("modifications_applied", False)),
        max_modifications=int(data.get("max_modifications", 0)),
        max_isoforms=int(data.get("max_modification_isoforms", 0)),
        peptides_at_cap=int(data.get("peptides_at_isoform_cap", 0)),
        modification_census=census,
        peptides=[Peptide._from_wire(p) for p in (data.get("peptides") or [])],
    )

pymzlib.flashlfq

flashlfq

Label-free quantification with FlashLFQ: quantify a search's peptides across mzML runs.

The question this answers is the one a quant workflow actually asks — given these identifications and these runs, how much of each peptide and protein is in each run? — in one call:

>>> import pymzlib
>>> result = pymzlib.flashlfq.quantify(                          # doctest: +SKIP
...     psms="AllPSMs.psmtsv",
...     spectra=["run_3.mzML", "run_4.mzML"],
...     match_between_runs=True,
... )
>>> result.peptide_count, result.protein_count                   # doctest: +SKIP
(354, 943)

The whole pipeline is mzLib's: the result file is read by mzLib's Readers, turned into FlashLFQ identifications by mzLib's own converter, and quantified by FlashLfqEngine. MetaMorpheus is not involved — mzLib does it alone.

Names follow mzLib and FlashLFQ deliberately, so a value here means the same thing it does in the FlashLFQ source, the MetaMorpheus output columns, and the FlashLFQ paper: match_between_runs, ppm_tolerance, mbr_ppm_tolerance, sequence, base_sequence, protein_groups, detection_types, ProteinGroup, FlashLfqResults.

.. note::

An MSFragger psm.tsv can now be quantified here. mzLib PR #1116 converts MSFragger's retention time to minutes at the reader, so the value FlashLFQ consumes is in the unit it expects; the former "MSFragger writes seconds, do not quantify it" warning no longer applies.

Three more limits worth knowing before you trust a number, in the "surface it, don't hide it" spirit of the rest of pyMzLib:

  • mzML only, for now. Convert .raw/.d to mzML first; a non-mzML path is rejected up front.
  • A protein intensity can be None. FlashLFQ's median-polish protein quant marks a protein NaN when its peptide matrix is degenerate (too few peptides per run, or identical intensities across runs — a real artifact documented in mzLib's own tests). NaN is not valid JSON, so it arrives here as None — "could not be quantified" — rather than a silently wrong number. A peptide intensity, by contrast, is 0.0 when missing, never None.
  • For match-between-runs, read the peaks, not the peptides. The peptide roll-up (:attr:FlashLfqResults.peptides, mirroring QuantifiedPeptides.tsv) reports far fewer MBR transfers than actually happened — a whole run's transfers can be absent. :attr:FlashLfqResults.peaks (and :meth:FlashLfqResults.mbr_peaks) is the complete surface; :attr:FlashLfqResults.mbr_peak_count is the number to trust.

SpectraFileInfo dataclass

One quantified run, mirroring mzLib's MassSpectrometry.SpectraFileInfo.

Attributes:

Name Type Description
file_name str

The run's base name (no directory, no extension) — the key used everywhere else here to look up this run's intensity.

full_path str

The mzML path as provided.

condition str

The sample-group label, or "" if none was given.

biological_replicate / technical_replicate / fraction

The experimental-design coordinates.

peak_count int

Chromatographic peaks quantified in this run.

mbr_peak_count int

Of those, how many were transferred by match-between-runs — peaks quantified in this run for a peptide that was never identified in it. Zero unless match_between_runs was set.

Source code in pkg/python/src/pymzlib/flashlfq.py
@dataclass(frozen=True)
class SpectraFileInfo:
    """One quantified run, mirroring mzLib's ``MassSpectrometry.SpectraFileInfo``.

    Attributes:
        file_name: The run's base name (no directory, no extension) — the key used everywhere else
            here to look up this run's intensity.
        full_path: The mzML path as provided.
        condition: The sample-group label, or ``""`` if none was given.
        biological_replicate / technical_replicate / fraction: The experimental-design coordinates.
        peak_count: Chromatographic peaks quantified in this run.
        mbr_peak_count: Of those, how many were transferred by match-between-runs — peaks quantified
            in this run for a peptide that was never identified *in* it. Zero unless
            ``match_between_runs`` was set.
    """

    file_name: str
    full_path: str
    condition: str
    biological_replicate: int
    technical_replicate: int
    fraction: int
    peak_count: int
    mbr_peak_count: int

    @classmethod
    def _from_wire(cls, payload: dict[str, Any]) -> "SpectraFileInfo":
        return cls(
            file_name=payload.get("file_name", ""),
            full_path=payload.get("full_path", ""),
            condition=payload.get("condition", ""),
            biological_replicate=int(payload.get("biological_replicate", 0)),
            technical_replicate=int(payload.get("technical_replicate", 0)),
            fraction=int(payload.get("fraction", 0)),
            peak_count=int(payload.get("peak_count", 0)),
            mbr_peak_count=int(payload.get("mbr_peak_count", 0)),
        )

Peptide dataclass

A quantified peptide, mirroring FlashLFQ's Peptide.

Attributes:

Name Type Description
sequence str

The full (modified) sequence, as FlashLFQ renders it — the identity FlashLFQ quantifies. Two different modification states of one base sequence are two peptides.

base_sequence str

The bare amino-acid sequence.

protein_groups str

The protein group(s) this peptide belongs to, ;-joined.

intensities dict[str, Any]

Run base name → intensity in that run. Missing is 0.0, never None — a null on the wire is resolved to 0.0 when parsed, so the invariant holds by construction rather than by hope (issue #7). Note too that where a peptide has several peaks in one run this roll-up reports one of them rather than their sum, so pivoting :attr:FlashLfqResults.peaks yourself will not reproduce these intensities exactly. Counting presence is unaffected; summing intensity is not. (unlike proteins). This roll-up mirrors FlashLFQ's QuantifiedPeptides.tsv and does not fully reflect match-between-runs — many transferred peptides read 0.0 / "NotDetected" here. For MBR-inclusive quantities use :attr:FlashLfqResults.peaks.

detection_types dict[str, str]

Run base name → how it was quantified there. Values FlashLFQ emits: "MSMS" (identified and quantified in this run), "MBR" (transferred by match-between-runs), "MSMSIdentifiedButNotQuantified" (an ID here but no usable peak), "MSMSAmbiguousPeakfinding" (more than one peptide fits the peak), and "NotDetected".

Source code in pkg/python/src/pymzlib/flashlfq.py
@dataclass(frozen=True)
class Peptide:
    """A quantified peptide, mirroring FlashLFQ's ``Peptide``.

    Attributes:
        sequence: The full (modified) sequence, as FlashLFQ renders it — the identity FlashLFQ
            quantifies. Two different modification states of one base sequence are two peptides.
        base_sequence: The bare amino-acid sequence.
        protein_groups: The protein group(s) this peptide belongs to, ``;``-joined.
        intensities: Run base name → intensity in that run. Missing is ``0.0``, **never ``None``** — a ``null``
            on the wire is resolved to ``0.0`` when parsed, so the invariant holds by
            construction rather than by hope (issue #7). Note too that where a peptide has
            several peaks in one run this roll-up reports **one** of them rather than their
            sum, so pivoting :attr:`FlashLfqResults.peaks` yourself will not reproduce these
            intensities exactly. Counting presence is unaffected; summing intensity is not.
            (unlike proteins). This roll-up mirrors FlashLFQ's ``QuantifiedPeptides.tsv`` and does
            **not** fully reflect match-between-runs — many transferred peptides read ``0.0`` /
            ``"NotDetected"`` here. For MBR-inclusive quantities use :attr:`FlashLfqResults.peaks`.
        detection_types: Run base name → how it was quantified there. Values FlashLFQ emits:
            ``"MSMS"`` (identified and quantified in this run), ``"MBR"`` (transferred by
            match-between-runs), ``"MSMSIdentifiedButNotQuantified"`` (an ID here but no usable
            peak), ``"MSMSAmbiguousPeakfinding"`` (more than one peptide fits the peak), and
            ``"NotDetected"``.
    """

    sequence: str
    base_sequence: str
    protein_groups: str
    intensities: dict[str, Any]
    detection_types: dict[str, str]

    def intensity(self, file_name: str) -> Any:
        """This peptide's intensity in the named run.

        **``0.0`` — not ``None`` — means "not quantified here."** (Only *protein* intensities are
        ever ``None``.) Treat ``0.0`` as missing, not as a measured absence: log-transforming it
        will mislead. And note a peptide that FlashLFQ *transferred* into this run by
        match-between-runs may still read ``0.0`` here — see :attr:`FlashLfqResults.peaks`. Returns
        ``0.0`` for a run that was never provided.
        """
        return self.intensities.get(file_name, 0.0)

    def detection_type(self, file_name: str) -> str:
        """How this peptide was quantified in the named run (``"NotDetected"`` if it was not)."""
        return self.detection_types.get(file_name, "NotDetected")

    @classmethod
    def _from_wire(cls, payload: dict[str, Any]) -> "Peptide":
        return cls(
            sequence=payload.get("sequence", ""),
            base_sequence=payload.get("base_sequence", ""),
            protein_groups=payload.get("protein_groups", ""),
            # A null crossing the wire becomes 0.0 here, so the documented invariant --
            # "missing is 0.0, never None" -- is true by construction rather than by hope.
            # The bridge routes every double through a finite check, so a NaN peptide
            # intensity arrives as null; leaving it None would make intensity() return None
            # for a peptide, which is supposed to be a protein-only condition and is exactly
            # what callers branch on to find unresolvable proteins. See issue #7.
            intensities={
                run: (0.0 if value is None else value)
                for run, value in (payload.get("intensities") or {}).items()
            },
            detection_types=dict(payload.get("detection_types") or {}),
        )
intensity
intensity(file_name: str) -> Any

This peptide's intensity in the named run.

0.0 — not None — means "not quantified here." (Only protein intensities are ever None.) Treat 0.0 as missing, not as a measured absence: log-transforming it will mislead. And note a peptide that FlashLFQ transferred into this run by match-between-runs may still read 0.0 here — see :attr:FlashLfqResults.peaks. Returns 0.0 for a run that was never provided.

Source code in pkg/python/src/pymzlib/flashlfq.py
def intensity(self, file_name: str) -> Any:
    """This peptide's intensity in the named run.

    **``0.0`` — not ``None`` — means "not quantified here."** (Only *protein* intensities are
    ever ``None``.) Treat ``0.0`` as missing, not as a measured absence: log-transforming it
    will mislead. And note a peptide that FlashLFQ *transferred* into this run by
    match-between-runs may still read ``0.0`` here — see :attr:`FlashLfqResults.peaks`. Returns
    ``0.0`` for a run that was never provided.
    """
    return self.intensities.get(file_name, 0.0)
detection_type
detection_type(file_name: str) -> str

How this peptide was quantified in the named run ("NotDetected" if it was not).

Source code in pkg/python/src/pymzlib/flashlfq.py
def detection_type(self, file_name: str) -> str:
    """How this peptide was quantified in the named run (``"NotDetected"`` if it was not)."""
    return self.detection_types.get(file_name, "NotDetected")

ProteinGroup dataclass

A quantified protein group, mirroring FlashLFQ's ProteinGroup.

Attributes:

Name Type Description
protein_group str

The protein group name (accession, or ;-joined accessions).

gene_name str

The gene name, when the result file carried one.

organism str

The organism, when the result file carried one.

intensities dict[str, Any]

Run base name → protein intensity in that run. May be None: FlashLFQ's median-polish protein quant emits NaN (returned here as None) when the peptide matrix for the protein is degenerate — too few peptides per run to resolve, or several runs reporting the same intensity — protecting you from a fabricated-looking number. Missing, as opposed to un-resolvable, is 0.0.

Source code in pkg/python/src/pymzlib/flashlfq.py
@dataclass(frozen=True)
class ProteinGroup:
    """A quantified protein group, mirroring FlashLFQ's ``ProteinGroup``.

    Attributes:
        protein_group: The protein group name (accession, or ``;``-joined accessions).
        gene_name: The gene name, when the result file carried one.
        organism: The organism, when the result file carried one.
        intensities: Run base name → protein intensity in that run. **May be ``None``**: FlashLFQ's
            median-polish protein quant emits NaN (returned here as ``None``) when the peptide matrix
            for the protein is degenerate — too few peptides per run to resolve, or several runs
            reporting the same intensity — protecting you from a fabricated-looking number. Missing,
            as opposed to un-resolvable, is ``0.0``.
    """

    protein_group: str
    gene_name: str
    organism: str
    intensities: dict[str, Any]

    def intensity(self, file_name: str) -> Any:
        """This protein's intensity in the named run.

        ``None`` means FlashLFQ could not resolve a number (a degenerate peptide matrix); ``0.0``
        means simply not measured in this run.
        """
        return self.intensities.get(file_name, 0.0)

    @classmethod
    def _from_wire(cls, payload: dict[str, Any]) -> "ProteinGroup":
        return cls(
            protein_group=payload.get("protein_group", ""),
            gene_name=payload.get("gene_name", ""),
            organism=payload.get("organism", ""),
            intensities=dict(payload.get("intensities") or {}),
        )
intensity
intensity(file_name: str) -> Any

This protein's intensity in the named run.

None means FlashLFQ could not resolve a number (a degenerate peptide matrix); 0.0 means simply not measured in this run.

Source code in pkg/python/src/pymzlib/flashlfq.py
def intensity(self, file_name: str) -> Any:
    """This protein's intensity in the named run.

    ``None`` means FlashLFQ could not resolve a number (a degenerate peptide matrix); ``0.0``
    means simply not measured in this run.
    """
    return self.intensities.get(file_name, 0.0)

Peak dataclass

One quantified chromatographic peak, mirroring FlashLFQ's ChromatographicPeak.

This is the surface to use for match-between-runs. Unlike the peptide roll-up (:attr:Peptide.intensities, which mirrors QuantifiedPeptides.tsv and drops most MBR transfers), the peaks fully represent every quantified peak, transferred or not. To build an MBR-inclusive peptide × run matrix, pivot these on (sequence, file_name).

Attributes:

Name Type Description
file_name str

The run this peak was measured in (base name).

sequence str

The full (modified) sequence of the peptide the peak was assigned to.

base_sequence str

The bare amino-acid sequence.

intensity Any

The peak's intensity (None only if FlashLFQ could not integrate it).

detection_type str

"MSMS", "MBR", "MSMSIdentifiedButNotQuantified", "MSMSAmbiguousPeakfinding" — the same vocabulary as :attr:Peptide.detection_types. Filter detection_type == "MBR" to see exactly the transferred peaks.

retention_time Any

Apex retention time in minutes, or None if the peak has no apex.

num_identifications int

How many peptides could explain this peak. > 1 means it is ambiguous and its intensity should be treated with care.

protein_groups str

The protein group(s) the assigned identification(s) belong to, ;-joined.

Source code in pkg/python/src/pymzlib/flashlfq.py
@dataclass(frozen=True)
class Peak:
    """One quantified chromatographic peak, mirroring FlashLFQ's ``ChromatographicPeak``.

    This is the surface to use for **match-between-runs**. Unlike the peptide roll-up
    (:attr:`Peptide.intensities`, which mirrors ``QuantifiedPeptides.tsv`` and drops most MBR
    transfers), the peaks fully represent every quantified peak, transferred or not. To build an
    MBR-inclusive peptide × run matrix, pivot these on ``(sequence, file_name)``.

    Attributes:
        file_name: The run this peak was measured in (base name).
        sequence: The full (modified) sequence of the peptide the peak was assigned to.
        base_sequence: The bare amino-acid sequence.
        intensity: The peak's intensity (``None`` only if FlashLFQ could not integrate it).
        detection_type: ``"MSMS"``, ``"MBR"``, ``"MSMSIdentifiedButNotQuantified"``,
            ``"MSMSAmbiguousPeakfinding"`` — the same vocabulary as :attr:`Peptide.detection_types`.
            Filter ``detection_type == "MBR"`` to see exactly the transferred peaks.
        retention_time: Apex retention time in minutes, or ``None`` if the peak has no apex.
        num_identifications: How many peptides could explain this peak. ``> 1`` means it is
            ambiguous and its intensity should be treated with care.
        protein_groups: The protein group(s) the assigned identification(s) belong to, ``;``-joined.
    """

    file_name: str
    sequence: str
    base_sequence: str
    intensity: Any
    detection_type: str
    retention_time: Any
    num_identifications: int
    protein_groups: str

    @property
    def is_mbr(self) -> bool:
        """Whether this peak was transferred by match-between-runs."""
        return self.detection_type == "MBR"

    @classmethod
    def _from_wire(cls, payload: dict[str, Any]) -> "Peak":
        return cls(
            file_name=payload.get("file_name", ""),
            sequence=payload.get("sequence", ""),
            base_sequence=payload.get("base_sequence", ""),
            intensity=payload.get("intensity"),
            detection_type=payload.get("detection_type", ""),
            retention_time=payload.get("retention_time"),
            num_identifications=int(payload.get("num_identifications", 0)),
            protein_groups=payload.get("protein_groups", ""),
        )
is_mbr property
is_mbr: bool

Whether this peak was transferred by match-between-runs.

FlashLfqResults dataclass

The result of a quantification run, mirroring mzLib's FlashLfqResults.

Attributes:

Name Type Description
psm_file str

The absolute path of the PSM result file that was quantified.

identification_count int

How many identifications were read from it.

parameters dict[str, Any]

The FlashLFQ parameters actually used, echoed back with their mzLib names.

spectra_files list[SpectraFileInfo]

One :class:SpectraFileInfo per run.

peptides list[Peptide]

One :class:Peptide per quantified modified sequence (mirrors QuantifiedPeptides.tsv; does not fully carry MBR — use :attr:peaks for that).

proteins list[ProteinGroup]

One :class:ProteinGroup per quantified protein group.

peaks list[Peak]

Every quantified :class:Peak across all runs (mirrors QuantifiedPeaks.tsv) — the complete surface, and the one to use for match-between-runs.

output_directory Any

Where the FlashLFQ TSVs were written, or None if none were.

Source code in pkg/python/src/pymzlib/flashlfq.py
@dataclass(frozen=True)
class FlashLfqResults:
    """The result of a quantification run, mirroring mzLib's ``FlashLfqResults``.

    Attributes:
        psm_file: The absolute path of the PSM result file that was quantified.
        identification_count: How many identifications were read from it.
        parameters: The FlashLFQ parameters actually used, echoed back with their mzLib names.
        spectra_files: One :class:`SpectraFileInfo` per run.
        peptides: One :class:`Peptide` per quantified modified sequence (mirrors
            ``QuantifiedPeptides.tsv``; does not fully carry MBR — use :attr:`peaks` for that).
        proteins: One :class:`ProteinGroup` per quantified protein group.
        peaks: Every quantified :class:`Peak` across all runs (mirrors ``QuantifiedPeaks.tsv``) —
            the complete surface, and the one to use for match-between-runs.
        output_directory: Where the FlashLFQ TSVs were written, or ``None`` if none were.
    """

    psm_file: str
    identification_count: int
    parameters: dict[str, Any]
    spectra_files: list[SpectraFileInfo]
    peptides: list[Peptide]
    proteins: list[ProteinGroup]
    peaks: list[Peak]
    output_directory: Any

    @property
    def peptide_count(self) -> int:
        """Number of quantified peptides."""
        return len(self.peptides)

    @property
    def protein_count(self) -> int:
        """Number of quantified protein groups."""
        return len(self.proteins)

    @property
    def mbr_peak_count(self) -> int:
        """Total match-between-runs peaks (transfers) across every run.

        This counts transferred **peaks**, not distinct peptides: one peptide rescued in two runs is
        two peaks here. For "how many peptides did MBR rescue," use :attr:`mbr_rescued_peptide_count`.
        Either way, do not count MBR from the peptide roll-up (:attr:`Peptide.detection_types`) — it
        under-counts. Zero unless ``match_between_runs`` was on.
        """
        return sum(f.mbr_peak_count for f in self.spectra_files)

    @property
    def mbr_peaks(self) -> list[Peak]:
        """Exactly the peaks transferred by match-between-runs (``detection_type == "MBR"``)."""
        return [p for p in self.peaks if p.is_mbr]

    @property
    def mbr_rescued_peptide_count(self) -> int:
        """Distinct peptides quantified in at least one run only by match-between-runs.

        Exactly: **the number of distinct ``sequence`` values among peaks whose
        ``detection_type`` is ``"MBR"``.** Stated in code terms because the prose version,
        "peptides quantified in at least one run *only* by match-between-runs", is subtly
        different and on real data the two diverge: peptides having **both** an MBR peak and
        a zero-intensity ``MSMS`` peak in the same run were identified there, so they are not
        rescues under the strict reading. On the K562 pair this returns 140 where the strict
        count is 135. Do not read ``mbr_rescued_peptide_count == mbr_peak_count`` as
        reassurance that nothing was double-counted; on that data both are 140, and they
        coincide only because every MBR peak happened to carry a distinct sequence.

        Distinct modified sequences among
        :attr:`mbr_peaks`. This equals :attr:`mbr_peak_count` only when no peptide was rescued in
        more than one run.
        """
        return len({p.sequence for p in self.mbr_peaks})

    @classmethod
    def _from_wire(cls, data: dict[str, Any]) -> "FlashLfqResults":
        return cls(
            psm_file=data.get("psm_file", ""),
            identification_count=int(data.get("identification_count", 0)),
            parameters=dict(data.get("parameters") or {}),
            spectra_files=[SpectraFileInfo._from_wire(f) for f in (data.get("spectra_files") or [])],
            peptides=[Peptide._from_wire(p) for p in (data.get("peptides") or [])],
            proteins=[ProteinGroup._from_wire(g) for g in (data.get("proteins") or [])],
            peaks=[Peak._from_wire(pk) for pk in (data.get("peaks") or [])],
            output_directory=data.get("output_directory"),
        )
peptide_count property
peptide_count: int

Number of quantified peptides.

protein_count property
protein_count: int

Number of quantified protein groups.

mbr_peak_count property
mbr_peak_count: int

Total match-between-runs peaks (transfers) across every run.

This counts transferred peaks, not distinct peptides: one peptide rescued in two runs is two peaks here. For "how many peptides did MBR rescue," use :attr:mbr_rescued_peptide_count. Either way, do not count MBR from the peptide roll-up (:attr:Peptide.detection_types) — it under-counts. Zero unless match_between_runs was on.

mbr_peaks property
mbr_peaks: list[Peak]

Exactly the peaks transferred by match-between-runs (detection_type == "MBR").

mbr_rescued_peptide_count property
mbr_rescued_peptide_count: int

Distinct peptides quantified in at least one run only by match-between-runs.

Exactly: the number of distinct sequence values among peaks whose detection_type is "MBR". Stated in code terms because the prose version, "peptides quantified in at least one run only by match-between-runs", is subtly different and on real data the two diverge: peptides having both an MBR peak and a zero-intensity MSMS peak in the same run were identified there, so they are not rescues under the strict reading. On the K562 pair this returns 140 where the strict count is 135. Do not read mbr_rescued_peptide_count == mbr_peak_count as reassurance that nothing was double-counted; on that data both are 140, and they coincide only because every MBR peak happened to carry a distinct sequence.

Distinct modified sequences among :attr:mbr_peaks. This equals :attr:mbr_peak_count only when no peptide was rescued in more than one run.

quantify

quantify(psms: str | PathLike[str], spectra: Sequence[SpectraInput], *, normalize: bool = False, ppm_tolerance: float = 10.0, isotope_ppm_tolerance: float = 5.0, integrate: bool = False, match_between_runs: bool = False, mbr_ppm_tolerance: float = 10.0, mbr_q_value_threshold: float = 0.05, use_shared_peptides_for_protein_quant: bool = False, bayesian_protein_quant: bool = False, use_pep_q_value: bool = False, max_threads: int = -1, output_directory: str | PathLike[str] | None = None, timeout: float | None = None) -> FlashLfqResults

Quantify a search's peptides across mzML runs with FlashLFQ.

Parameters:

Name Type Description Default
psms str | PathLike[str]

Path to a PSM result file — a MetaMorpheus .psmtsv/.osmtsv (full field set) or an MSFragger psm.tsv (retention time is converted to minutes upstream since mzLib PR #1116, so it quantifies correctly). Every run named in the file must have a matching mzML in spectra; FlashLFQ matches identifications to runs by base file name.

required
spectra Sequence[SpectraInput]

The mzML runs. Each entry is either a path ("run_1.mzML") or a mapping carrying the experimental design ({"path": "run_1.mzML", "condition": "treated", "biological_replicate": 1}). Base file names must be unique.

required
normalize bool

Normalize intensities across runs (FlashLFQ Normalize).

False
ppm_tolerance float

Mass tolerance for peak-finding, in ppm (PpmTolerance).

10.0
isotope_ppm_tolerance float

Mass tolerance for isotope-envelope matching, in ppm.

5.0
integrate bool

Integrate peak intensities rather than taking the apex. FlashLFQ recommends leaving this off.

False
match_between_runs bool

Quantify a peptide in a run where it was not identified, by transferring the identification from a run where it was (MatchBetweenRuns). The reason to reach for FlashLFQ; off by default because it makes assumptions worth opting into. Requires a complete, balanced design: every condition and biological replicate must carry the same set of fractions — a missing replicate or fraction breaks the complementarity MBR relies on. Read the transferred peaks from :attr:FlashLfqResults.peaks, not the peptide table.

False
mbr_ppm_tolerance float

Mass tolerance for MBR transfers, in ppm.

10.0
mbr_q_value_threshold float

The q-value cutoff below which an MBR transfer is accepted.

0.05
use_shared_peptides_for_protein_quant bool

Let peptides shared between protein groups contribute to protein quant (UseSharedPeptidesForProteinQuant).

False
bayesian_protein_quant bool

Run FlashLFQ's Bayesian protein-fold-change engine.

False
use_pep_q_value bool

Filter identifications on PEP q-value rather than q-value.

False
max_threads int

Worker threads; -1 lets FlashLFQ choose.

This is not only a performance knob - it changes results. With -1, FlashLFQ's peptide roll-up nondeterministically drops some MBR intensities, so peptide and protein numbers vary between runs on byte-identical inputs. On the K562 pair, 6 peptides flip between 0.0 and a real intensity, which flips a borderline protein group between None and a number: unquantifiable in 5 of 6 runs, quantified in the 6th. The peaks are stable throughout - only the roll-up wobbles. Set max_threads=1 for anything you intend to publish or reproduce. See smith-chem-wisc/mzLib#1111.

-1
output_directory str | PathLike[str] | None

If given, FlashLFQ also writes QuantifiedPeaks.tsv, QuantifiedPeptides.tsv and QuantifiedProteins.tsv there.

None
timeout float | None

Seconds to allow. Large experiments legitimately take a while; None waits indefinitely.

None

Returns:

Name Type Description
A FlashLfqResults

class:FlashLfqResults.

Raises:

Type Description
UsageError

an argument is malformed, a run is not mzML, an mzML is missing, or the PSM file names a run with no mzML provided.

BridgeError

FlashLFQ itself failed.

Source code in pkg/python/src/pymzlib/flashlfq.py
def quantify(
    psms: str | os.PathLike[str],
    spectra: Sequence[SpectraInput],
    *,
    normalize: bool = False,
    ppm_tolerance: float = 10.0,
    isotope_ppm_tolerance: float = 5.0,
    integrate: bool = False,
    match_between_runs: bool = False,
    mbr_ppm_tolerance: float = 10.0,
    mbr_q_value_threshold: float = 0.05,
    use_shared_peptides_for_protein_quant: bool = False,
    bayesian_protein_quant: bool = False,
    use_pep_q_value: bool = False,
    max_threads: int = -1,
    output_directory: str | os.PathLike[str] | None = None,
    timeout: float | None = None,
) -> FlashLfqResults:
    """Quantify a search's peptides across mzML runs with FlashLFQ.

    Args:
        psms: Path to a PSM result file — a MetaMorpheus ``.psmtsv``/``.osmtsv`` (full field set) or
            an MSFragger ``psm.tsv`` (retention time is converted to minutes upstream since mzLib
            PR #1116, so it quantifies correctly). Every run named in the file must have a matching
            mzML in ``spectra``; FlashLFQ matches identifications to runs by base file name.
        spectra: The mzML runs. Each entry is either a path (``"run_1.mzML"``) or a mapping
            carrying the experimental design (``{"path": "run_1.mzML", "condition": "treated",
            "biological_replicate": 1}``). Base file names must be unique.
        normalize: Normalize intensities across runs (FlashLFQ ``Normalize``).
        ppm_tolerance: Mass tolerance for peak-finding, in ppm (``PpmTolerance``).
        isotope_ppm_tolerance: Mass tolerance for isotope-envelope matching, in ppm.
        integrate: Integrate peak intensities rather than taking the apex. FlashLFQ recommends
            leaving this off.
        match_between_runs: Quantify a peptide in a run where it was not identified, by transferring
            the identification from a run where it was (``MatchBetweenRuns``). The reason to reach
            for FlashLFQ; off by default because it makes assumptions worth opting into. **Requires a
            complete, balanced design**: every condition and biological replicate must carry the same
            set of fractions — a missing replicate or fraction breaks the complementarity MBR relies
            on. Read the transferred peaks from :attr:`FlashLfqResults.peaks`, not the peptide table.
        mbr_ppm_tolerance: Mass tolerance for MBR transfers, in ppm.
        mbr_q_value_threshold: The q-value cutoff below which an MBR transfer is accepted.
        use_shared_peptides_for_protein_quant: Let peptides shared between protein groups contribute
            to protein quant (``UseSharedPeptidesForProteinQuant``).
        bayesian_protein_quant: Run FlashLFQ's Bayesian protein-fold-change engine.
        use_pep_q_value: Filter identifications on PEP q-value rather than q-value.
        max_threads: Worker threads; ``-1`` lets FlashLFQ choose.

            **This is not only a performance knob - it changes results.** With ``-1``,
            FlashLFQ's peptide roll-up nondeterministically drops some MBR intensities, so
            peptide and protein numbers vary between runs on byte-identical inputs. On the
            K562 pair, 6 peptides flip between ``0.0`` and a real intensity, which flips a
            borderline protein group between ``None`` and a number: unquantifiable in 5 of 6
            runs, quantified in the 6th. The peaks are stable throughout - only the roll-up
            wobbles. **Set ``max_threads=1`` for anything you intend to publish or
            reproduce.** See smith-chem-wisc/mzLib#1111.
        output_directory: If given, FlashLFQ also writes ``QuantifiedPeaks.tsv``,
            ``QuantifiedPeptides.tsv`` and ``QuantifiedProteins.tsv`` there.
        timeout: Seconds to allow. Large experiments legitimately take a while; ``None`` waits
            indefinitely.

    Returns:
        A :class:`FlashLfqResults`.

    Raises:
        UsageError: an argument is malformed, a run is not mzML, an mzML is missing, or the PSM
            file names a run with no mzML provided.
        BridgeError: FlashLFQ itself failed.
    """
    psms = _bridge.path_text(psms)
    if not psms:
        raise _bridge.UsageError("A PSM result file path is required, e.g. 'AllPSMs.psmtsv'.")

    stdin = _spectra_stdin(spectra)

    args: list[str] = ["quant", "flashlfq", "--psms", psms]
    if normalize:
        args.append("--normalize")
    args += ["--ppm", _number(ppm_tolerance, "ppm_tolerance")]
    args += ["--isotope-ppm", _number(isotope_ppm_tolerance, "isotope_ppm_tolerance")]
    if integrate:
        args.append("--integrate")
    if match_between_runs:
        args.append("--mbr")
    args += ["--mbr-ppm", _number(mbr_ppm_tolerance, "mbr_ppm_tolerance")]
    args += ["--mbr-q", _number(mbr_q_value_threshold, "mbr_q_value_threshold")]
    if use_shared_peptides_for_protein_quant:
        args.append("--shared-peptides")
    if bayesian_protein_quant:
        args.append("--bayesian")
    if use_pep_q_value:
        args.append("--use-pep-q")
    if isinstance(max_threads, bool) or not isinstance(max_threads, int):
        raise _bridge.UsageError(f"max_threads must be a whole number; got {max_threads!r}.")
    args += ["--threads", str(max_threads)]
    if output_directory is not None:
        output_directory = _bridge.path_text(output_directory)
        if not output_directory:
            raise _bridge.UsageError("output_directory must be a non-empty path or None.")
        args += ["--out", output_directory]

    data = _bridge.invoke(*args, stdin=stdin, timeout=timeout)
    return FlashLfqResults._from_wire(data)

median_polish

median_polish(peptides: str | PathLike[str], *, design: Sequence[DesignInput] | None = None, use_shared_peptides: bool = False, output_directory: str | PathLike[str] | None = None, timeout: float | None = None) -> list[ProteinGroup]

Roll a QuantifiedPeptides.tsv up to protein intensities with FlashLFQ's median polish.

This is the second half of :func:quantify on its own: given a peptide table FlashLFQ already wrote — its Intensity_<run> and Detection Type_<run> columns — it rebuilds the FlashLFQ peptide/protein object graph and runs the exact same median-polish protein quant (CalculateProteinResultsMedianPolish), without re-reading any mzML. Reach for it to re-quantify proteins under a different experimental design, or with shared peptides toggled, without paying for peak-finding again::

>>> import pymzlib
>>> proteins = pymzlib.flashlfq.median_polish(                    # doctest: +SKIP
...     "QuantifiedPeptides.tsv",
...     design=[
...         {"file_name": "run_3", "condition": "control", "biological_replicate": 0},
...         {"file_name": "run_4", "condition": "treated", "biological_replicate": 0},
...     ],
... )
>>> proteins[0].intensity("control_1")                           # doctest: +SKIP
3005.6

The returned objects are ordinary :class:ProteinGroup\ s, so their intensity semantics are the ones documented there and on :func:quantify: an intensity is None where median polish could not resolve a number (a degenerate peptide matrix), 0.0 where the protein was simply not measured in that sample.

Parameters:

Name Type Description Default
peptides str | PathLike[str]

Path to a FlashLFQ QuantifiedPeptides.tsv (the file :func:quantify writes as QuantifiedPeptides.tsv when given an output_directory). It must carry the Sequence, Base Sequence, Protein Groups and Intensity_<run> columns.

required
design Sequence[DesignInput] | None

The experimental design, one mapping per run. Each needs a file_name matching an Intensity_<file_name> column, plus any of condition, biological_replicate, technical_replicate, fraction (FlashLFQ's SpectraFileInfo names). Median polish groups measurements by condition and biological replicate, so this is how you tell it which runs are replicates of which sample — the whole reason to re-run it. If a design is given it must name every run in the table and only runs in the table. When None, each Intensity_ column becomes its own biological replicate with a blank condition — exactly what FlashLFQ assumes when it writes the file with no design.

None
use_shared_peptides bool

Let peptides shared between protein groups contribute to protein quant (FlashLFQ's UseSharedPeptidesForProteinQuant). Off by default; when off, a group with only shared peptides quantifies to 0.0.

False
output_directory str | PathLike[str] | None

If given, also write a FlashLFQ QuantifiedProteins.tsv there. For unfractionated data its column headers do not match the keys of the returned objects' :attr:~ProteinGroup.intensities: FlashLFQ labels a sample by file name exactly when a design is given, and writes Intensity__1 when one is not (an inverted condition, smith-chem-wisc/mzLib#1128, fixed by mzLib#1129 — the columns will agree once pyMzLib re-pins). The values agree either way. The returned list is the primary result and this file is a convenience.

None
timeout float | None

Seconds to allow; None waits indefinitely.

None

Returns:

Type Description
list[ProteinGroup]

A list of :class:ProteinGroup, ordered by protein group name. Each carries its per-sample

list[ProteinGroup]

attr:~ProteinGroup.intensities, keyed by the sample label (a run base name when no design

list[ProteinGroup]

is given, "condition_biorep" once a design groups runs).

Raises:

Type Description
UsageError

peptides is blank, the file is missing a required column or has no Intensity_ columns, or the design and the table's runs do not match.

BridgeError

the reconstruction or quantification itself failed.

Source code in pkg/python/src/pymzlib/flashlfq.py
def median_polish(
    peptides: str | os.PathLike[str],
    *,
    design: Sequence[DesignInput] | None = None,
    use_shared_peptides: bool = False,
    output_directory: str | os.PathLike[str] | None = None,
    timeout: float | None = None,
) -> list[ProteinGroup]:
    """Roll a ``QuantifiedPeptides.tsv`` up to protein intensities with FlashLFQ's median polish.

    This is the second half of :func:`quantify` on its own: given a peptide table FlashLFQ already
    wrote — its ``Intensity_<run>`` and ``Detection Type_<run>`` columns — it rebuilds the FlashLFQ
    peptide/protein object graph and runs the exact same median-polish protein quant
    (``CalculateProteinResultsMedianPolish``), without re-reading any mzML. Reach for it to
    re-quantify proteins under a different experimental design, or with shared peptides toggled,
    without paying for peak-finding again::

        >>> import pymzlib
        >>> proteins = pymzlib.flashlfq.median_polish(                    # doctest: +SKIP
        ...     "QuantifiedPeptides.tsv",
        ...     design=[
        ...         {"file_name": "run_3", "condition": "control", "biological_replicate": 0},
        ...         {"file_name": "run_4", "condition": "treated", "biological_replicate": 0},
        ...     ],
        ... )
        >>> proteins[0].intensity("control_1")                           # doctest: +SKIP
        3005.6

    The returned objects are ordinary :class:`ProteinGroup`\\ s, so their intensity semantics are the
    ones documented there and on :func:`quantify`: an intensity is ``None`` where median polish could
    not resolve a number (a degenerate peptide matrix), ``0.0`` where the protein was simply not
    measured in that sample.

    Args:
        peptides: Path to a FlashLFQ ``QuantifiedPeptides.tsv`` (the file :func:`quantify` writes as
            ``QuantifiedPeptides.tsv`` when given an ``output_directory``). It must carry the
            ``Sequence``, ``Base Sequence``, ``Protein Groups`` and ``Intensity_<run>`` columns.
        design: The experimental design, one mapping per run. Each needs a ``file_name`` matching an
            ``Intensity_<file_name>`` column, plus any of ``condition``, ``biological_replicate``,
            ``technical_replicate``, ``fraction`` (FlashLFQ's ``SpectraFileInfo`` names). Median
            polish groups measurements by condition and biological replicate, so **this is how you
            tell it which runs are replicates of which sample** — the whole reason to re-run it. If a
            design is given it must name every run in the table and only runs in the table. When
            ``None``, each ``Intensity_`` column becomes its own biological replicate with a blank
            condition — exactly what FlashLFQ assumes when it writes the file with no design.
        use_shared_peptides: Let peptides shared between protein groups contribute to protein quant
            (FlashLFQ's ``UseSharedPeptidesForProteinQuant``). Off by default; when off, a group with
            only shared peptides quantifies to ``0.0``.
        output_directory: If given, also write a FlashLFQ ``QuantifiedProteins.tsv`` there. For
            **unfractionated** data its column headers do not match the keys of the returned objects'
            :attr:`~ProteinGroup.intensities`: FlashLFQ labels a sample by file name exactly when a
            design *is* given, and writes ``Intensity__1`` when one is not (an inverted condition,
            smith-chem-wisc/mzLib#1128, fixed by mzLib#1129 — the columns will agree once pyMzLib
            re-pins). The values agree either way. The returned list is the primary result and this
            file is a convenience.
        timeout: Seconds to allow; ``None`` waits indefinitely.

    Returns:
        A list of :class:`ProteinGroup`, ordered by protein group name. Each carries its per-sample
        :attr:`~ProteinGroup.intensities`, keyed by the sample label (a run base name when no design
        is given, ``"condition_biorep"`` once a design groups runs).

    Raises:
        UsageError: ``peptides`` is blank, the file is missing a required column or has no
            ``Intensity_`` columns, or the design and the table's runs do not match.
        BridgeError: the reconstruction or quantification itself failed.
    """
    peptides = _bridge.path_text(peptides)
    if not peptides:
        raise _bridge.UsageError("A quantified peptides file path is required, e.g. 'QuantifiedPeptides.tsv'.")

    stdin = _design_stdin(design) if design is not None else None

    args: list[str] = ["quant", "median-polish", "--peptides", peptides]
    if use_shared_peptides:
        args.append("--shared-peptides")
    if output_directory is not None:
        output_directory = _bridge.path_text(output_directory)
        if not output_directory:
            raise _bridge.UsageError("output_directory must be a non-empty path or None.")
        args += ["--out", output_directory]

    data = _bridge.invoke(*args, stdin=stdin, timeout=timeout)
    return [ProteinGroup._from_wire(g) for g in (data.get("proteins") or [])]

pymzlib.readers

readers

Read mass-spectrometry data files and proteomics search results: what a file is, what you can do with it, and its records.

Spectra files are read here too, not just search output. :func:read_spectra reads mzML, Thermo .raw, Bruker .d, timsTOF .d, MGF and msalign - scan headers always, peaks on request::

>>> scans = pymzlib.readers.read_spectra("run.mzML", peaks=True)   # doctest: +SKIP
>>> scans.scan_count, scans.columns["retention_time"][:2]          # doctest: +SKIP
(455, [0.0011, 0.0285])

mzLib recognises 32 file types in all - the instrument and deconvolution formats above, plus the output of a dozen search tools: MetaMorpheus, MSFragger, TopPIC, TopFD, MsPathFinderT, Crux, Casanovo, FlashDeconv, Dinosaur, DIA-NN, FlashLFQ - and dispatches each to a parser it maintains. This module asks it what a path is::

>>> import pymzlib
>>> info = pymzlib.readers.identify("psm.tsv")     # doctest: +SKIP
>>> info.file_type, info.views                     # doctest: +SKIP
('MsFraggerPsm', ['quantifiable'])

...and reads it, whatever it turns out to be::

>>> table = pymzlib.readers.read_records("toppic_prsm.tsv")   # doctest: +SKIP
>>> table.record_type, len(table.column_names)                # doctest: +SKIP
('ToppicPrsm', 36)

Every one of the 32 formats is readable - :func:read_records reads any of them. What differs between formats is not whether you can read them but what the columns mean, and that is what :attr:FileInfo.views tells you. It is tempting to describe mzLib as reading 32 formats into one uniform shape; it does not. They fall into disjoint families, and several belong to no family at all:

+---------------------+-------+------------------------+---------------------------------------+ | view | types | function | columns | +=====================+=======+========================+=======================================+ | "quantifiable" | 4 | :func:read_results | uniform: sequence, RT, charge, mass, | | | | | protein groups. What | | | | | :func:pymzlib.flashlfq.quantify | | | | | consumes. | +---------------------+-------+------------------------+---------------------------------------+ | "ms1_features" | 2 | :func:read_features | uniform: m/z, charge, RT range, | | | | | intensity, isotope count. | +---------------------+-------+------------------------+---------------------------------------+ | "spectral_match"| 4 | :func:read_matches | uniform: scan, sequences, accession, | | | | | decoy flag, modifications. | +---------------------+-------+------------------------+---------------------------------------+ | "spectra" | 7 | :func:read_spectra | uniform: scan headers, and peaks on | | | | | request. | +---------------------+-------+------------------------+---------------------------------------+ | (any) | 32 | :func:read_records | this format's own fields, under | | | | | mzLib's names. Not uniform. | +---------------------+-------+------------------------+---------------------------------------+

views == [] is a real and common answer - fifteen types have it. TopPIC, Crux, MSFragger's peptide and protein tables and the FlashDeconv formats each parse into their own record type with nothing in common. mzLib reads them and so does :func:read_records; there is simply no uniform view to project them onto, and inventing one here would mean publishing a schema mzLib does not have.

So: use a typed view when you need numbers that mean the same thing across files, and :func:read_records when you need everything a format has. A .psmtsv read through :func:read_results gives 10 comparable columns; the same file through :func:read_records gives 73, including the q-values and scores the uniform view does not carry.

Call :func:formats for the whole table. It is enumerated from mzLib rather than transcribed, so it cannot drift from what mzLib actually dispatches.

Three things this module deliberately does not tell you, in the "surface it, don't hide it" spirit of the rest of pyMzLib:

  • Which tool wrote the file. mzLib has a Software property that looks like the answer and is not: readers carry their software constant on a constructor that mzLib's own file factory does not use, so the value is unset for everything the factory returns - and it is not reliably set on the other constructor either. Rather than reconstruct a plausible answer, there is no software field. :attr:FileInfo.file_type already names the tool.
  • Whether the numbers inside mean the same thing across formats. They do not, and this is the trap most likely to produce a wrong result. mzLib's result-file readers pass through whatever the tool wrote: MetaMorpheus retention times are in minutes and MSFragger's are too (mzLib PR #1116 converts them at the reader), but TopPIC's are still in seconds, and TopFD changed from seconds to minutes between v1.6.2 and v1.7.0 within the same file type. Likewise is_decoy is hardcoded False for MSFragger, which means "mzLib cannot tell" rather than "target" - MSFragger's psm.tsv carries no target/decoy column at all - so is_decoy arrives as None for that format rather than a fabricated False. monoisotopic_mass is the theoretical peptide mass in both formats, never the observed precursor mass. Identifying a file is safe; comparing raw fields across formats is not.

  • Anything about confidence. There is no q-value, PEP or score in this view, because IQuantifiableRecord carries only what FlashLFQ needs. Nothing you get back is FDR-filtered, even though every one of these files records confidence somewhere. Filter before you report.

.. note::

That units mismatch was not hypothetical, and the fix shows where such things belong. Passing an MSFragger psm.tsv to :func:pymzlib.flashlfq.quantify used to return near-zero intensities, because FlashLFQ read the seconds as minutes and searched for each peptide about sixty times too late in the gradient. It was fixed upstream in mzLib (#1116 <https://github.com/smith-chem-wisc/mzLib/pull/1116>_, converting at the reader) rather than papered over here, so every mzLib consumer benefits and this library's caveat and retention_time_unit changed with it. That is the standing rule: a value whose meaning or availability is wrong is repaired in the core contract, and a binding discloses rather than repairs.

Format dataclass

One file type mzLib can recognise.

Attributes:

Name Type Description
file_type str

mzLib's SupportedFileType name, e.g. "MsFraggerPsm", "psmtsv".

extension Any

The extension or filename suffix mzLib dispatches on, e.g. "psm.tsv", "_ms1.feature". Not unique across file types - BrukerD and BrukerTimsTof are both .d (told apart by the directory's contents), and several formats share .tsv.

reader Any

The name of the mzLib class that parses it, for cross-referencing the mzLib source.

views list[str]

The uniform views this format supports - see the module docstring. Often empty.

Source code in pkg/python/src/pymzlib/readers.py
@dataclass(frozen=True)
class Format:
    """One file type mzLib can recognise.

    Attributes:
        file_type: mzLib's ``SupportedFileType`` name, e.g. ``"MsFraggerPsm"``, ``"psmtsv"``.
        extension: The extension or filename suffix mzLib dispatches on, e.g. ``"psm.tsv"``,
            ``"_ms1.feature"``. **Not unique across file types** - ``BrukerD`` and
            ``BrukerTimsTof`` are both ``.d`` (told apart by the directory's contents), and
            several formats share ``.tsv``.
        reader: The name of the mzLib class that parses it, for cross-referencing the mzLib source.
        views: The uniform views this format supports - see the module docstring. Often empty.
    """

    file_type: str
    extension: Any
    reader: Any
    views: list[str] = field(default_factory=list)

    @property
    def is_quantifiable(self) -> bool:
        """Whether this format offers the cross-format record view (and so feeds FlashLFQ)."""
        return QUANTIFIABLE in self.views

    @classmethod
    def _from_wire(cls, payload: dict[str, Any]) -> "Format":
        return cls(
            file_type=payload.get("file_type", ""),
            extension=payload.get("extension"),
            reader=payload.get("reader"),
            views=list(payload.get("views") or []),
        )
is_quantifiable property
is_quantifiable: bool

Whether this format offers the cross-format record view (and so feeds FlashLFQ).

FileInfo dataclass

What a particular file is, and what can be done with it.

Attributes:

Name Type Description
path str

The absolute path that was identified.

file_type str

mzLib's SupportedFileType name.

extension Any

The extension mzLib dispatched on.

reader Any

The mzLib class that would parse it.

views list[str]

The uniform views this file supports - see the module docstring. Often empty, which means mzLib can read the file but offers no cross-format projection of it.

Source code in pkg/python/src/pymzlib/readers.py
@dataclass(frozen=True)
class FileInfo:
    """What a particular file is, and what can be done with it.

    Attributes:
        path: The absolute path that was identified.
        file_type: mzLib's ``SupportedFileType`` name.
        extension: The extension mzLib dispatched on.
        reader: The mzLib class that would parse it.
        views: The uniform views this file supports - see the module docstring. Often empty, which
            means mzLib can read the file but offers no cross-format projection of it.
    """

    path: str
    file_type: str
    extension: Any
    reader: Any
    views: list[str] = field(default_factory=list)

    @property
    def is_quantifiable(self) -> bool:
        """Whether this file offers the cross-format record view.

        When ``True``, the path can be passed straight to :func:`pymzlib.flashlfq.quantify` as
        ``psms``. When ``False``, mzLib can still read the file - it simply has no uniform view,
        so quantification would fail on it.
        """
        return QUANTIFIABLE in self.views

    @classmethod
    def _from_wire(cls, data: dict[str, Any]) -> "FileInfo":
        return cls(
            path=data.get("path", ""),
            file_type=data.get("file_type", ""),
            extension=data.get("extension"),
            reader=data.get("reader"),
            views=list(data.get("views") or []),
        )
is_quantifiable property
is_quantifiable: bool

Whether this file offers the cross-format record view.

When True, the path can be passed straight to :func:pymzlib.flashlfq.quantify as psms. When False, mzLib can still read the file - it simply has no uniform view, so quantification would fail on it.

WrittenTable dataclass

Where :func:read_results wrote a table, when asked to write one instead of returning it.

Attributes:

Name Type Description
path str

The absolute path written.

format str

Always "tsv". Tab-separated, not comma-separated, because these fields contain commas - MSFragger's mapped proteins are a comma-separated list inside a single field, and joined accessions look the same. It is also what every mzLib reader and writer uses. Read it with csv.reader(f, delimiter="\t") or pandas.read_csv(path, sep="\t").

row_count int

Rows written, excluding the header.

Source code in pkg/python/src/pymzlib/readers.py
@dataclass(frozen=True)
class WrittenTable:
    """Where :func:`read_results` wrote a table, when asked to write one instead of returning it.

    Attributes:
        path: The absolute path written.
        format: Always ``"tsv"``. **Tab-separated, not comma-separated**, because these fields
            contain commas - MSFragger's mapped proteins are a comma-separated list inside a single
            field, and joined accessions look the same. It is also what every mzLib reader and
            writer uses. Read it with ``csv.reader(f, delimiter="\\t")`` or
            ``pandas.read_csv(path, sep="\\t")``.
        row_count: Rows written, excluding the header.
    """

    path: str
    format: str
    row_count: int

    @classmethod
    def _from_wire(cls, payload: dict[str, Any]) -> "WrittenTable":
        return cls(
            path=payload.get("path", ""),
            format=payload.get("format", ""),
            row_count=int(payload.get("row_count", 0)),
        )

ResultRecords dataclass

Bases: _Table

The uniform record view of a result file.

Attributes:

Name Type Description
path str

The absolute path that was read.

file_type str

mzLib's SupportedFileType name.

record_count int

Records in the whole file, regardless of limit or offset.

returned_count int

Records actually carried back in :attr:columns. Zero when out was given, since the table went to disk instead.

offset int

The offset that was applied.

truncated bool

Whether records were left behind, by either limit or offset. A short answer and a complete one must never look alike, so check this rather than assuming.

retention_time_unit str

The unit :attr:columns' retention_time carries for this format - "minutes", "seconds", or "unknown". mzLib does not normalise it, so this differs per format and you must convert before comparing two files. Provided as a value so you can convert programmatically instead of hard-coding a table.

rows_not_read Any

Data rows in the file that did not become records - mzLib drops a malformed row silently, so a non-zero value here means the file is partly unreadable and the table is incomplete. None when the count could not be established meaningfully.

caveats list[str]

What the uniform view cannot be trusted to mean for this format. Empty for some formats, not for others; each entry cites the mzLib source it came from. Worth printing before comparing anything across formats - this is where you learn that, e.g., TopPIC retention times are seconds while MetaMorpheus's and MSFragger's are minutes.

column_names list[str]

The field names, in order.

columns Any

Field name -> list of values, one entry per record - the shape pandas.DataFrame and polars.DataFrame both accept directly. None when out was given.

output Any

Where the table was written, or None if it was returned inline.

Source code in pkg/python/src/pymzlib/readers.py
@dataclass(frozen=True)
class ResultRecords(_Table):
    """The uniform record view of a result file.

    Attributes:
        path: The absolute path that was read.
        file_type: mzLib's ``SupportedFileType`` name.
        record_count: Records in the **whole file**, regardless of ``limit`` or ``offset``.
        returned_count: Records actually carried back in :attr:`columns`. Zero when ``out`` was
            given, since the table went to disk instead.
        offset: The offset that was applied.
        truncated: **Whether records were left behind**, by either ``limit`` or ``offset``. A short
            answer and a complete one must never look alike, so check this rather than assuming.
        retention_time_unit: The unit :attr:`columns`' ``retention_time`` carries for this format -
            ``"minutes"``, ``"seconds"``, or ``"unknown"``. mzLib does not normalise it, so this
            differs per format and you must convert before comparing two files. Provided as a value
            so you can convert programmatically instead of hard-coding a table.
        rows_not_read: Data rows in the file that did not become records - mzLib drops a malformed
            row silently, so a non-zero value here means the file is partly unreadable and the
            table is incomplete. ``None`` when the count could not be established meaningfully.
        caveats: **What the uniform view cannot be trusted to mean for this format.** Empty for
            some formats, not for others; each entry cites the mzLib source it came from. Worth
            printing before comparing anything across formats - this is where you learn that, e.g.,
            TopPIC retention times are seconds while MetaMorpheus's and MSFragger's are minutes.
        column_names: The field names, in order.
        columns: Field name -> list of values, one entry per record - the shape ``pandas.DataFrame``
            and ``polars.DataFrame`` both accept directly. ``None`` when ``out`` was given.
        output: Where the table was written, or ``None`` if it was returned inline.
    """

    path: str
    file_type: str
    record_count: int
    returned_count: int
    offset: int
    truncated: bool
    retention_time_unit: str
    rows_not_read: Any
    caveats: list[str] = field(default_factory=list)
    column_names: list[str] = field(default_factory=list)
    columns: Any = None
    output: Any = None

    @property
    def retention_time_in_minutes(self) -> list[Any]:
        """``retention_time`` converted to minutes, whatever unit the format wrote.

        The conversion you would otherwise write by hand, using
        :attr:`retention_time_unit`. Raises if the unit is ``"unknown"`` rather than guessing -
        a silently unconverted axis is the specific mistake this module exists to prevent.
        """
        self._require_columns("read_results")
        if "retention_time" not in self.columns:
            raise _bridge.UsageError(
                "This result has no retention_time column, so it cannot be converted."
            )

        values = self.columns["retention_time"]
        if self.retention_time_unit == "minutes":
            return list(values)
        if self.retention_time_unit == "seconds":
            return [None if v is None else v / 60.0 for v in values]
        raise _bridge.UsageError(
            f"Cannot convert retention time for '{self.file_type}': mzLib gives no basis to say "
            "what unit it is in. Inspect the values against scan numbers before comparing them."
        )

    @classmethod
    def _from_wire(cls, data: dict[str, Any]) -> "ResultRecords":
        written = data.get("output")
        return cls(
            path=data.get("path", ""),
            file_type=data.get("file_type", ""),
            record_count=int(data.get("record_count", 0)),
            returned_count=int(data.get("returned_count", 0)),
            offset=int(data.get("offset", 0)),
            truncated=bool(data.get("truncated", False)),
            retention_time_unit=data.get("retention_time_unit") or "unknown",
            rows_not_read=data.get("rows_not_read"),
            caveats=list(data.get("caveats") or []),
            column_names=list(data.get("column_names") or []),
            columns=data.get("columns"),
            output=WrittenTable._from_wire(written) if written else None,
        )
retention_time_in_minutes property
retention_time_in_minutes: list[Any]

retention_time converted to minutes, whatever unit the format wrote.

The conversion you would otherwise write by hand, using :attr:retention_time_unit. Raises if the unit is "unknown" rather than guessing - a silently unconverted axis is the specific mistake this module exists to prevent.

NativeRecords dataclass

Bases: _Table

A result file read into its own fields, whatever format it is.

What :func:read_records returns. Unlike every other result type in this module, the columns here are not uniform: they are the fields of this format's own mzLib record type, under mzLib's own names in snake_case. A TopPIC file gives you TopPIC's thirty-six columns; a Crux file gives you Crux's twenty-three. Always read :attr:column_names rather than assuming.

Attributes:

Name Type Description
path str

The absolute path that was read.

file_type str

mzLib's SupportedFileType name.

reader Any

The mzLib class that parsed it.

record_type str

The mzLib record class the columns came from, e.g. "ToppicPrsm". Cross- reference it against the mzLib source to find out what a column means.

views list[str]

The uniform views this file also supports, if any - see the module docstring.

record_count int

Records in the whole file, regardless of limit or offset.

returned_count int

Records carried back in :attr:columns. Zero when out was given.

offset int

The offset that was applied.

truncated bool

Whether records were left behind, by either limit or offset.

excluded_fields list[dict[str, Any]]

Fields of the record type that could not become columns, each with the reason. Nested objects and dictionaries have no faithful column shape, and inventing one would mean publishing a schema mzLib does not have. Listed rather than dropped, so an absent column is never mistaken for an absent field.

failed_fields list[str]

Fields that raised while being read, with the exception type. Several mzLib properties are computed and assume a UniProt-style FASTA header - Crux's and MsPathFinderT's accession are protein_id.split("|")[1] - so on other databases they throw. Those cells arrive None instead of taking the whole read down, but a failure must not look like missing data, so it is named here.

column_names list[str]

The field names, in order - base-class fields first, then declared ones.

columns Any

Field name -> list of values, one entry per record. None when out was given.

output Any

Where the table was written, or None if it was returned inline.

Source code in pkg/python/src/pymzlib/readers.py
@dataclass(frozen=True)
class NativeRecords(_Table):
    """A result file read into **its own** fields, whatever format it is.

    What :func:`read_records` returns. Unlike every other result type in this module, the columns
    here are **not uniform**: they are the fields of this format's own mzLib record type, under
    mzLib's own names in snake_case. A TopPIC file gives you TopPIC's thirty-six columns; a Crux
    file gives you Crux's twenty-three. Always read :attr:`column_names` rather than assuming.

    Attributes:
        path: The absolute path that was read.
        file_type: mzLib's ``SupportedFileType`` name.
        reader: The mzLib class that parsed it.
        record_type: The mzLib record class the columns came from, e.g. ``"ToppicPrsm"``. Cross-
            reference it against the mzLib source to find out what a column means.
        views: The uniform views this file *also* supports, if any - see the module docstring.
        record_count: Records in the **whole file**, regardless of ``limit`` or ``offset``.
        returned_count: Records carried back in :attr:`columns`. Zero when ``out`` was given.
        offset: The offset that was applied.
        truncated: Whether records were left behind, by either ``limit`` or ``offset``.
        excluded_fields: **Fields of the record type that could not become columns**, each with the
            reason. Nested objects and dictionaries have no faithful column shape, and inventing
            one would mean publishing a schema mzLib does not have. Listed rather than dropped, so
            an absent column is never mistaken for an absent field.
        failed_fields: Fields that **raised** while being read, with the exception type. Several
            mzLib properties are computed and assume a UniProt-style FASTA header - Crux's and
            MsPathFinderT's ``accession`` are ``protein_id.split("|")[1]`` - so on other databases
            they throw. Those cells arrive ``None`` instead of taking the whole read down, but a
            failure must not look like missing data, so it is named here.
        column_names: The field names, in order - base-class fields first, then declared ones.
        columns: Field name -> list of values, one entry per record. ``None`` when ``out`` was given.
        output: Where the table was written, or ``None`` if it was returned inline.
    """

    path: str
    file_type: str
    reader: Any
    record_type: str
    record_count: int
    returned_count: int
    offset: int
    truncated: bool
    views: list[str] = field(default_factory=list)
    excluded_fields: list[dict[str, Any]] = field(default_factory=list)
    failed_fields: list[str] = field(default_factory=list)
    column_names: list[str] = field(default_factory=list)
    columns: Any = None
    output: Any = None

    @classmethod
    def _from_wire(cls, data: dict[str, Any]) -> "NativeRecords":
        written = data.get("output")
        return cls(
            path=data.get("path", ""),
            file_type=data.get("file_type", ""),
            reader=data.get("reader"),
            record_type=data.get("record_type", ""),
            record_count=int(data.get("record_count", 0)),
            returned_count=int(data.get("returned_count", 0)),
            offset=int(data.get("offset", 0)),
            truncated=bool(data.get("truncated", False)),
            views=list(data.get("views") or []),
            excluded_fields=list(data.get("excluded_fields") or []),
            failed_fields=list(data.get("failed_fields") or []),
            column_names=list(data.get("column_names") or []),
            columns=data.get("columns"),
            output=WrittenTable._from_wire(written) if written else None,
        )

FeatureRecords dataclass

Bases: _Table

Deconvolved MS1 features, in the cross-format ms1_features view.

What :func:read_features returns. Columns are mz, charge, retention_time_start, retention_time_end, intensity and number_of_isotopes - the same for every format that offers the view, so they are comparable across files, subject to :attr:retention_time_unit.

Attributes:

Name Type Description
path str

The absolute path that was read.

file_type str

mzLib's SupportedFileType name.

record_count int

Features in the whole file. For _ms1.feature this exceeds the file's line count, because mzLib expands each deconvolved feature into one row per charge state. See :attr:caveats.

returned_count int

Features carried back in :attr:columns. Zero when out was given.

offset int

The offset that was applied.

truncated bool

Whether features were left behind, by either limit or offset.

retention_time_unit str

"minutes", "seconds", or "unknown". It is genuinely "unknown" for _ms1.feature: TopFD wrote seconds through v1.6.2 and minutes from v1.7.0 without changing the file type. That is not a gap in this library; it is the honest state of the format.

caveats list[str]

What this view cannot be trusted to mean for this format, each citing the mzLib source it came from.

column_names list[str]

The field names, in order.

columns Any

Field name -> list of values. None when out was given.

output Any

Where the table was written, or None.

Source code in pkg/python/src/pymzlib/readers.py
@dataclass(frozen=True)
class FeatureRecords(_Table):
    """Deconvolved MS1 features, in the cross-format ``ms1_features`` view.

    What :func:`read_features` returns. Columns are ``mz``, ``charge``, ``retention_time_start``,
    ``retention_time_end``, ``intensity`` and ``number_of_isotopes`` - the same for every format
    that offers the view, so they *are* comparable across files, subject to
    :attr:`retention_time_unit`.

    Attributes:
        path: The absolute path that was read.
        file_type: mzLib's ``SupportedFileType`` name.
        record_count: Features in the whole file. **For ``_ms1.feature`` this exceeds the file's
            line count**, because mzLib expands each deconvolved feature into one row per charge
            state. See :attr:`caveats`.
        returned_count: Features carried back in :attr:`columns`. Zero when ``out`` was given.
        offset: The offset that was applied.
        truncated: Whether features were left behind, by either ``limit`` or ``offset``.
        retention_time_unit: ``"minutes"``, ``"seconds"``, or ``"unknown"``. **It is genuinely
            ``"unknown"`` for ``_ms1.feature``**: TopFD wrote seconds through v1.6.2 and minutes
            from v1.7.0 without changing the file type. That is not a gap in this library; it is
            the honest state of the format.
        caveats: What this view cannot be trusted to mean for this format, each citing the mzLib
            source it came from.
        column_names: The field names, in order.
        columns: Field name -> list of values. ``None`` when ``out`` was given.
        output: Where the table was written, or ``None``.
    """

    path: str
    file_type: str
    record_count: int
    returned_count: int
    offset: int
    truncated: bool
    retention_time_unit: str
    caveats: list[str] = field(default_factory=list)
    column_names: list[str] = field(default_factory=list)
    columns: Any = None
    output: Any = None

    @property
    def retention_time_start_in_minutes(self) -> list[Any]:
        """``retention_time_start`` in minutes, or a raised error if the unit is unknown.

        Raises rather than guessing. For ``_ms1.feature`` the unit genuinely is unknown, and a
        silently unconverted time axis is the specific mistake this module exists to prevent -
        mzLib's own deconvolution code guesses here, and this will not.
        """
        return self._converted("retention_time_start")

    @property
    def retention_time_end_in_minutes(self) -> list[Any]:
        """``retention_time_end`` in minutes, or a raised error if the unit is unknown."""
        return self._converted("retention_time_end")

    def _converted(self, name: str) -> list[Any]:
        columns = self._require_columns("read_features")
        if name not in columns:
            raise _bridge.UsageError(f"This result has no {name} column, so it cannot be converted.")
        values = columns[name]
        if self.retention_time_unit == "minutes":
            return list(values)
        if self.retention_time_unit == "seconds":
            return [None if v is None else v / 60.0 for v in values]
        raise _bridge.UsageError(
            f"Cannot convert retention time for '{self.file_type}': mzLib gives no basis to say what "
            "unit it is in. TopFD changed from seconds to minutes at v1.7.0 without changing the "
            "file type, so check the values against your gradient length before comparing them."
        )

    @classmethod
    def _from_wire(cls, data: dict[str, Any]) -> "FeatureRecords":
        written = data.get("output")
        return cls(
            path=data.get("path", ""),
            file_type=data.get("file_type", ""),
            record_count=int(data.get("record_count", 0)),
            returned_count=int(data.get("returned_count", 0)),
            offset=int(data.get("offset", 0)),
            truncated=bool(data.get("truncated", False)),
            retention_time_unit=data.get("retention_time_unit") or "unknown",
            caveats=list(data.get("caveats") or []),
            column_names=list(data.get("column_names") or []),
            columns=data.get("columns"),
            output=WrittenTable._from_wire(written) if written else None,
        )
retention_time_start_in_minutes property
retention_time_start_in_minutes: list[Any]

retention_time_start in minutes, or a raised error if the unit is unknown.

Raises rather than guessing. For _ms1.feature the unit genuinely is unknown, and a silently unconverted time axis is the specific mistake this module exists to prevent - mzLib's own deconvolution code guesses here, and this will not.

retention_time_end_in_minutes property
retention_time_end_in_minutes: list[Any]

retention_time_end in minutes, or a raised error if the unit is unknown.

MatchRecords dataclass

Bases: _Table

Identifications, in the cross-format spectral_match view.

What :func:read_matches returns. Columns are file_name_without_extension, one_based_scan_number, base_sequence, full_sequence, accession, is_decoy, modifications and modification_count.

Nothing here is FDR-filtered, and unlike the quantifiable view there is not even a hint of confidence to filter on - mzLib's ISpectralMatch carries identity fields only. Every format that offers this view records an E-value or q-value in columns :func:read_records will give you. Filter before you report.

Attributes:

Name Type Description
path str

The absolute path that was read.

file_type str

mzLib's SupportedFileType name.

record_count int

Matches in the whole file.

returned_count int

Matches carried back in :attr:columns. Zero when out was given.

offset int

The offset that was applied.

truncated bool

Whether matches were left behind, by either limit or offset.

caveats list[str]

What this view cannot be trusted to mean for this format - that MsPathFinderT infers decoys from an XXX name prefix, that Casanovo's scan numbers are mzTab indices, and that Casanovo's is_decoy is None because de novo sequencing has no target/decoy label at all.

column_names list[str]

The field names, in order.

columns Any

Field name -> list of values. None when out was given.

output Any

Where the table was written, or None.

Source code in pkg/python/src/pymzlib/readers.py
@dataclass(frozen=True)
class MatchRecords(_Table):
    """Identifications, in the cross-format ``spectral_match`` view.

    What :func:`read_matches` returns. Columns are ``file_name_without_extension``,
    ``one_based_scan_number``, ``base_sequence``, ``full_sequence``, ``accession``, ``is_decoy``,
    ``modifications`` and ``modification_count``.

    **Nothing here is FDR-filtered**, and unlike the quantifiable view there is not even a hint of
    confidence to filter on - mzLib's ``ISpectralMatch`` carries identity fields only. Every format
    that offers this view records an E-value or q-value in columns :func:`read_records` will give
    you. Filter before you report.

    Attributes:
        path: The absolute path that was read.
        file_type: mzLib's ``SupportedFileType`` name.
        record_count: Matches in the whole file.
        returned_count: Matches carried back in :attr:`columns`. Zero when ``out`` was given.
        offset: The offset that was applied.
        truncated: Whether matches were left behind, by either ``limit`` or ``offset``.
        caveats: What this view cannot be trusted to mean for this format - that MsPathFinderT
            infers decoys from an ``XXX`` name prefix, that Casanovo's scan numbers are mzTab
            indices, and that Casanovo's ``is_decoy`` is ``None`` because de novo sequencing has no
            target/decoy label at all.
        column_names: The field names, in order.
        columns: Field name -> list of values. ``None`` when ``out`` was given.
        output: Where the table was written, or ``None``.
    """

    path: str
    file_type: str
    record_count: int
    returned_count: int
    offset: int
    truncated: bool
    caveats: list[str] = field(default_factory=list)
    column_names: list[str] = field(default_factory=list)
    columns: Any = None
    output: Any = None

    @classmethod
    def _from_wire(cls, data: dict[str, Any]) -> "MatchRecords":
        written = data.get("output")
        return cls(
            path=data.get("path", ""),
            file_type=data.get("file_type", ""),
            record_count=int(data.get("record_count", 0)),
            returned_count=int(data.get("returned_count", 0)),
            offset=int(data.get("offset", 0)),
            truncated=bool(data.get("truncated", False)),
            caveats=list(data.get("caveats") or []),
            column_names=list(data.get("column_names") or []),
            columns=data.get("columns"),
            output=WrittenTable._from_wire(written) if written else None,
        )

ScanRecords dataclass

Bases: _Table

Scan headers - and optionally peaks - from a spectra file.

What :func:read_spectra returns. Retention times here are in minutes for every format: mzLib's spectra readers convert at the boundary, unlike its result-file readers, which pass the tool's own unit through untouched.

Attributes:

Name Type Description
path str

The absolute path that was read.

file_type str

mzLib's SupportedFileType name.

reader Any

The mzLib class that parsed it, e.g. "Mzml", "ThermoRawFileReader".

scan_count int

Scans in the whole file, before any ms_order filter. Reported alongside :attr:record_count so a filter that matched nothing can never look like an empty file.

ms_order Any

The MS level filtered to, or None if unfiltered.

record_count int

Scans that passed the ms_order filter.

returned_count int

Scans carried back in :attr:columns. Zero when out was given.

offset int

The offset that was applied.

truncated bool

Whether scans were left behind, by either limit or offset.

peaks_included bool

Whether mz and intensity are present. When False, peak_count still tells you how many peaks each scan has.

retention_time_unit str

Always "minutes" for this view.

caveats list[str]

What this view cannot be trusted to mean for this format - that msalign holds deconvolved neutral masses rather than m/z, that MGF scan numbers come from a title line, that Bruker needs Windows-x64 native libraries.

column_names list[str]

The field names, in order.

columns Any

Field name -> list of values. When :attr:peaks_included, mz and intensity are each a list of lists - one array per scan. None when out was given.

output Any

Where the table was written, or None.

Source code in pkg/python/src/pymzlib/readers.py
@dataclass(frozen=True)
class ScanRecords(_Table):
    """Scan headers - and optionally peaks - from a spectra file.

    What :func:`read_spectra` returns. Retention times here **are** in minutes for every format:
    mzLib's spectra readers convert at the boundary, unlike its result-file readers, which pass the
    tool's own unit through untouched.

    Attributes:
        path: The absolute path that was read.
        file_type: mzLib's ``SupportedFileType`` name.
        reader: The mzLib class that parsed it, e.g. ``"Mzml"``, ``"ThermoRawFileReader"``.
        scan_count: Scans in the **whole file**, before any ``ms_order`` filter. Reported alongside
            :attr:`record_count` so a filter that matched nothing can never look like an empty file.
        ms_order: The MS level filtered to, or ``None`` if unfiltered.
        record_count: Scans that passed the ``ms_order`` filter.
        returned_count: Scans carried back in :attr:`columns`. Zero when ``out`` was given.
        offset: The offset that was applied.
        truncated: Whether scans were left behind, by either ``limit`` or ``offset``.
        peaks_included: Whether ``mz`` and ``intensity`` are present. When ``False``, ``peak_count``
            still tells you how many peaks each scan has.
        retention_time_unit: Always ``"minutes"`` for this view.
        caveats: What this view cannot be trusted to mean for this format - that msalign holds
            deconvolved neutral masses rather than m/z, that MGF scan numbers come from a title
            line, that Bruker needs Windows-x64 native libraries.
        column_names: The field names, in order.
        columns: Field name -> list of values. When :attr:`peaks_included`, ``mz`` and ``intensity``
            are each a **list of lists** - one array per scan. ``None`` when ``out`` was given.
        output: Where the table was written, or ``None``.
    """

    path: str
    file_type: str
    reader: Any
    scan_count: int
    record_count: int
    returned_count: int
    offset: int
    truncated: bool
    peaks_included: bool
    retention_time_unit: str
    ms_order: Any = None
    caveats: list[str] = field(default_factory=list)
    column_names: list[str] = field(default_factory=list)
    columns: Any = None
    output: Any = None

    @property
    def total_ion_current(self) -> list[Any]:
        """The ``total_ion_current`` column, for the commonest plot there is."""
        columns = self._require_columns("read_spectra")
        return list(columns.get("total_ion_current") or [])

    @classmethod
    def _from_wire(cls, data: dict[str, Any]) -> "ScanRecords":
        written = data.get("output")
        return cls(
            path=data.get("path", ""),
            file_type=data.get("file_type", ""),
            reader=data.get("reader"),
            scan_count=int(data.get("scan_count", 0)),
            record_count=int(data.get("record_count", 0)),
            returned_count=int(data.get("returned_count", 0)),
            offset=int(data.get("offset", 0)),
            truncated=bool(data.get("truncated", False)),
            peaks_included=bool(data.get("peaks_included", False)),
            retention_time_unit=data.get("retention_time_unit") or "minutes",
            ms_order=data.get("ms_order"),
            caveats=list(data.get("caveats") or []),
            column_names=list(data.get("column_names") or []),
            columns=data.get("columns"),
            output=WrittenTable._from_wire(written) if written else None,
        )
total_ion_current property
total_ion_current: list[Any]

The total_ion_current column, for the commonest plot there is.

formats

formats(timeout: float | None = 60) -> list[Format]

Every file type mzLib can recognise.

Enumerated from mzLib itself rather than from a list maintained here, so it reflects the installed version and cannot go stale.

Parameters:

Name Type Description Default
timeout float | None

Seconds to allow.

60

Returns:

Name Type Description
One list[Format]

class:Format per supported file type.

Example

quantifiable = [f.file_type for f in formats() if f.is_quantifiable] # doctest: +SKIP quantifiable # doctest: +SKIP ['psmtsv', 'osmtsv', 'MsFraggerPsm', 'DiaNnReport']

Source code in pkg/python/src/pymzlib/readers.py
def formats(timeout: float | None = 60) -> list[Format]:
    """Every file type mzLib can recognise.

    Enumerated from mzLib itself rather than from a list maintained here, so it reflects the
    installed version and cannot go stale.

    Args:
        timeout: Seconds to allow.

    Returns:
        One :class:`Format` per supported file type.

    Example:
        >>> quantifiable = [f.file_type for f in formats() if f.is_quantifiable]  # doctest: +SKIP
        >>> quantifiable                                                          # doctest: +SKIP
        ['psmtsv', 'osmtsv', 'MsFraggerPsm', 'DiaNnReport']
    """
    data = _bridge.invoke("readers", "formats", timeout=timeout)
    return [Format._from_wire(item) for item in (data.get("formats") or [])]

identify

identify(path: str | PathLike[str], timeout: float | None = 60) -> FileInfo

Identify a result file without parsing its contents.

Cheap by design: mzLib resolves the type and stops, so identifying a million-row file costs no more than identifying an empty one. It is not, however, pure - mzLib disambiguates a bare .tsv by reading its first line, a .mztab by its first five, and a Bruker .d by which analysis file the directory holds. An unreadable file will therefore raise.

Parameters:

Name Type Description Default
path str | PathLike[str]

Path to a result or spectra file. A Bruker .d directory is also accepted.

required
timeout float | None

Seconds to allow.

60

Returns:

Name Type Description
A FileInfo

class:FileInfo naming the format and the views it supports.

Raises:

Type Description
UsageError

the path is blank, does not exist, or is not a file type mzLib recognises. mzLib has no "unknown" result - a file is dispatchable or it is an error - so use :func:formats to see what is supported, or catch this to test a file.

Example

info = identify("AllPSMs.psmtsv") # doctest: +SKIP info.file_type, info.is_quantifiable # doctest: +SKIP ('psmtsv', True)

Source code in pkg/python/src/pymzlib/readers.py
def identify(path: str | os.PathLike[str], timeout: float | None = 60) -> FileInfo:
    """Identify a result file without parsing its contents.

    Cheap by design: mzLib resolves the type and stops, so identifying a million-row file costs no
    more than identifying an empty one. It is not, however, *pure* - mzLib disambiguates a bare
    ``.tsv`` by reading its first line, a ``.mztab`` by its first five, and a Bruker ``.d`` by which
    analysis file the directory holds. An unreadable file will therefore raise.

    Args:
        path: Path to a result or spectra file. A Bruker ``.d`` directory is also accepted.
        timeout: Seconds to allow.

    Returns:
        A :class:`FileInfo` naming the format and the views it supports.

    Raises:
        UsageError: the path is blank, does not exist, or is not a file type mzLib recognises.
            mzLib has no "unknown" result - a file is dispatchable or it is an error - so use
            :func:`formats` to see what is supported, or catch this to test a file.

    Example:
        >>> info = identify("AllPSMs.psmtsv")                      # doctest: +SKIP
        >>> info.file_type, info.is_quantifiable                   # doctest: +SKIP
        ('psmtsv', True)
    """
    path = _bridge.path_text(path)
    if not path:
        raise _bridge.UsageError("A file path is required, e.g. 'AllPSMs.psmtsv'.")

    data = _bridge.invoke("readers", "identify", "--path", path, timeout=timeout)
    return FileInfo._from_wire(data)

read_results

read_results(path: str | PathLike[str], *, limit: int | None = None, offset: int = 0, out: str | PathLike[str] | None = None, timeout: float | None = None) -> ResultRecords

Read a result file into the uniform record view.

Only the three file types offering the "quantifiable" view can be read this way - check :func:identify first, or catch the error. A file without the view is rejected with a message naming the views it does have.

There is no default row limit. A result file can carry a million rows, and truncating by default would mean the ordinary call returns a table that looks complete and is not. For a large file use out rather than paging: see the note on offset below.

Parameters:

Name Type Description Default
path str | PathLike[str]

Path to a MetaMorpheus .psmtsv / .osmtsv or an MSFragger psm.tsv.

required
limit int | None

Maximum records to return. None (the default) returns all of them. :attr:ResultRecords.truncated reports whether anything was left behind.

None
offset int

Records to skip. This is a window, not a cursor. mzLib materializes the whole file on every call - its readers look lazy and are not - so paging re-reads and re-parses the file once per page. For a large file, one call with out is right and a paging loop is quadratic.

0
out str | PathLike[str] | None

Write the records to this path as a tab-separated table and return only a summary, instead of carrying them back in the envelope. The intended path for large files, not an escape hatch. Tab-separated because these fields contain commas.

None
timeout float | None

Seconds to allow. A large file legitimately takes a while; None waits indefinitely.

None

Returns:

Name Type Description
A ResultRecords

class:ResultRecords. Read :attr:ResultRecords.caveats before trusting a field across

ResultRecords

formats.

Raises:

Type Description
UsageError

the path is blank, missing, not a recognised format, or has no quantifiable view.

Example

r = read_results("AllPSMs.psmtsv") # doctest: +SKIP r.record_count, r.truncated # doctest: +SKIP (8, False) import pandas as pd # doctest: +SKIP pd.DataFrame(r.columns) # doctest: +SKIP

Source code in pkg/python/src/pymzlib/readers.py
def read_results(
    path: str | os.PathLike[str],
    *,
    limit: int | None = None,
    offset: int = 0,
    out: str | os.PathLike[str] | None = None,
    timeout: float | None = None,
) -> ResultRecords:
    """Read a result file into the uniform record view.

    Only the three file types offering the ``"quantifiable"`` view can be read this way - check
    :func:`identify` first, or catch the error. A file without the view is rejected with a message
    naming the views it does have.

    **There is no default row limit.** A result file can carry a million rows, and truncating by
    default would mean the ordinary call returns a table that looks complete and is not. For a large
    file use ``out`` rather than paging: see the note on ``offset`` below.

    Args:
        path: Path to a MetaMorpheus ``.psmtsv`` / ``.osmtsv`` or an MSFragger ``psm.tsv``.
        limit: Maximum records to return. ``None`` (the default) returns all of them.
            :attr:`ResultRecords.truncated` reports whether anything was left behind.
        offset: Records to skip. **This is a window, not a cursor.** mzLib materializes the whole
            file on every call - its readers look lazy and are not - so paging re-reads and
            re-parses the file once per page. For a large file, one call with ``out`` is right and
            a paging loop is quadratic.
        out: Write the records to this path as a **tab-separated** table and return only a summary,
            instead of carrying them back in the envelope. The intended path for large files, not
            an escape hatch. Tab-separated because these fields contain commas.
        timeout: Seconds to allow. A large file legitimately takes a while; ``None`` waits
            indefinitely.

    Returns:
        A :class:`ResultRecords`. Read :attr:`ResultRecords.caveats` before trusting a field across
        formats.

    Raises:
        UsageError: the path is blank, missing, not a recognised format, or has no quantifiable view.

    Example:
        >>> r = read_results("AllPSMs.psmtsv")                       # doctest: +SKIP
        >>> r.record_count, r.truncated                              # doctest: +SKIP
        (8, False)
        >>> import pandas as pd                                      # doctest: +SKIP
        >>> pd.DataFrame(r.columns)                                  # doctest: +SKIP
    """
    args = _window("read-results", path, limit=limit, offset=offset, out=out)
    data = _bridge.invoke(*args, timeout=timeout)
    return ResultRecords._from_wire(data)

read_records

read_records(path: str | PathLike[str], *, limit: int | None = None, offset: int = 0, out: str | PathLike[str] | None = None, timeout: float | None = None) -> NativeRecords

Read any file mzLib recognises, into that format's own fields.

This is the exhaustive verb: if :func:identify succeeds on a path, this reads it. All thirty-two file types, including the fifteen that belong to no cross-format view at all - TopPIC, Crux, MSFragger's peptide and protein tables, the FlashDeconv formats - which no other function here can touch.

The columns are not uniform, by design. They are this format's own mzLib record fields, under mzLib's own names in snake_case: a TopPIC file gives thirty-six columns, a Crux file twenty-three, an experiment annotation five. Read :attr:NativeRecords.column_names, and use :func:read_results, :func:read_features or :func:read_matches when you need columns that mean the same thing across formats.

Nothing is silently dropped. A field that could not become a column is named in :attr:NativeRecords.excluded_fields, and one that raised while being read is named in :attr:NativeRecords.failed_fields - so a missing column never has to be guessed at.

Parameters:

Name Type Description Default
path str | PathLike[str]

Path to any file mzLib recognises. A Bruker .d directory is also accepted.

required
limit int | None

Maximum records to return. None (the default) returns all of them.

None
offset int

Records to skip. A window, not a cursor - see :func:read_results.

0
out str | PathLike[str] | None

Write a tab-separated table here and return only a summary.

None
timeout float | None

Seconds to allow. None waits indefinitely.

None

Returns:

Name Type Description
A NativeRecords

class:NativeRecords.

Raises:

Type Description
UsageError

the path is blank, missing, or not a file type mzLib recognises.

Example

r = read_records("toppic_prsm.tsv") # doctest: +SKIP r.record_type, len(r.column_names) # doctest: +SKIP ('ToppicPrsm', 36) import pandas as pd # doctest: +SKIP pd.DataFrame(r.columns)[["e_value", "q_value_spectrum_level"]] # doctest: +SKIP

Source code in pkg/python/src/pymzlib/readers.py
def read_records(
    path: str | os.PathLike[str],
    *,
    limit: int | None = None,
    offset: int = 0,
    out: str | os.PathLike[str] | None = None,
    timeout: float | None = None,
) -> NativeRecords:
    """Read **any** file mzLib recognises, into that format's own fields.

    This is the exhaustive verb: if :func:`identify` succeeds on a path, this reads it. All
    thirty-two file types, including the fifteen that belong to no cross-format view at all -
    TopPIC, Crux, MSFragger's peptide and protein tables, the FlashDeconv formats - which no other
    function here can touch.

    **The columns are not uniform, by design.** They are this format's own mzLib record fields,
    under mzLib's own names in snake_case: a TopPIC file gives thirty-six columns, a Crux file
    twenty-three, an experiment annotation five. Read :attr:`NativeRecords.column_names`, and use
    :func:`read_results`, :func:`read_features` or :func:`read_matches` when you need columns that
    mean the same thing across formats.

    Nothing is silently dropped. A field that could not become a column is named in
    :attr:`NativeRecords.excluded_fields`, and one that raised while being read is named in
    :attr:`NativeRecords.failed_fields` - so a missing column never has to be guessed at.

    Args:
        path: Path to any file mzLib recognises. A Bruker ``.d`` directory is also accepted.
        limit: Maximum records to return. ``None`` (the default) returns all of them.
        offset: Records to skip. A window, not a cursor - see :func:`read_results`.
        out: Write a **tab-separated** table here and return only a summary.
        timeout: Seconds to allow. ``None`` waits indefinitely.

    Returns:
        A :class:`NativeRecords`.

    Raises:
        UsageError: the path is blank, missing, or not a file type mzLib recognises.

    Example:
        >>> r = read_records("toppic_prsm.tsv")                    # doctest: +SKIP
        >>> r.record_type, len(r.column_names)                     # doctest: +SKIP
        ('ToppicPrsm', 36)
        >>> import pandas as pd                                    # doctest: +SKIP
        >>> pd.DataFrame(r.columns)[["e_value", "q_value_spectrum_level"]]   # doctest: +SKIP
    """
    args = _window("read-records", path, limit=limit, offset=offset, out=out)
    data = _bridge.invoke(*args, timeout=timeout)
    return NativeRecords._from_wire(data)

read_features

read_features(path: str | PathLike[str], *, limit: int | None = None, offset: int = 0, out: str | PathLike[str] | None = None, timeout: float | None = None) -> FeatureRecords

Read deconvolved MS1 features, in the cross-format ms1_features view.

Two file types offer it: TopFD/FLASHDeconv _ms1.feature and Dinosaur .feature.tsv. A file without the view is rejected with a message naming the views it does have.

One row is not one line of the file for _ms1.feature. mzLib expands each deconvolved feature into one single-charge feature per charge in its recorded range, so a hundred-feature file can read as a thousand rows. Dinosaur is one-for-one. Both facts are in :attr:FeatureRecords.caveats, and :func:read_records gives the file's own rows either way.

Parameters:

Name Type Description Default
path str | PathLike[str]

Path to a _ms1.feature or Dinosaur .feature.tsv.

required
limit int | None

Maximum features to return. None returns all of them.

None
offset int

Features to skip.

0
out str | PathLike[str] | None

Write a tab-separated table here and return only a summary.

None
timeout float | None

Seconds to allow.

None

Returns:

Name Type Description
A FeatureRecords

class:FeatureRecords. Check :attr:FeatureRecords.retention_time_unit before

FeatureRecords

comparing times - it is "unknown" for _ms1.feature and that is the honest answer.

Raises:

Type Description
UsageError

the path is blank, missing, unrecognised, or has no ms1_features view.

Example

f = read_features("sample_ms1.feature") # doctest: +SKIP f.record_count, f.retention_time_unit # doctest: +SKIP (25, 'unknown')

Source code in pkg/python/src/pymzlib/readers.py
def read_features(
    path: str | os.PathLike[str],
    *,
    limit: int | None = None,
    offset: int = 0,
    out: str | os.PathLike[str] | None = None,
    timeout: float | None = None,
) -> FeatureRecords:
    """Read deconvolved MS1 features, in the cross-format ``ms1_features`` view.

    Two file types offer it: TopFD/FLASHDeconv ``_ms1.feature`` and Dinosaur ``.feature.tsv``. A
    file without the view is rejected with a message naming the views it does have.

    **One row is not one line of the file for ``_ms1.feature``.** mzLib expands each deconvolved
    feature into one single-charge feature per charge in its recorded range, so a hundred-feature
    file can read as a thousand rows. Dinosaur is one-for-one. Both facts are in
    :attr:`FeatureRecords.caveats`, and :func:`read_records` gives the file's own rows either way.

    Args:
        path: Path to a ``_ms1.feature`` or Dinosaur ``.feature.tsv``.
        limit: Maximum features to return. ``None`` returns all of them.
        offset: Features to skip.
        out: Write a tab-separated table here and return only a summary.
        timeout: Seconds to allow.

    Returns:
        A :class:`FeatureRecords`. Check :attr:`FeatureRecords.retention_time_unit` before
        comparing times - it is ``"unknown"`` for ``_ms1.feature`` and that is the honest answer.

    Raises:
        UsageError: the path is blank, missing, unrecognised, or has no ``ms1_features`` view.

    Example:
        >>> f = read_features("sample_ms1.feature")                # doctest: +SKIP
        >>> f.record_count, f.retention_time_unit                  # doctest: +SKIP
        (25, 'unknown')
    """
    args = _window("read-features", path, limit=limit, offset=offset, out=out)
    data = _bridge.invoke(*args, timeout=timeout)
    return FeatureRecords._from_wire(data)

read_matches

read_matches(path: str | PathLike[str], *, limit: int | None = None, offset: int = 0, out: str | PathLike[str] | None = None, timeout: float | None = None) -> MatchRecords

Read identifications, in the cross-format spectral_match view.

Four file types offer it: MsPathFinderT's targets, decoys and combined results, and Casanovo's .mztab. These are the identification formats that share no file-level interface, so :func:read_results cannot reach them.

Nothing here is FDR-filtered, and there is no confidence column to filter on - mzLib's ISpectralMatch carries identity fields only. Every one of these formats records an E-value or q-value that :func:read_records will give you. Filter before you report.

Parameters:

Name Type Description Default
path str | PathLike[str]

Path to an MsPathFinderT _IcTarget.tsv / _IcDecoy.tsv / _IcTDA.tsv, or a Casanovo .mztab.

required
limit int | None

Maximum matches to return. None returns all of them.

None
offset int

Matches to skip.

0
out str | PathLike[str] | None

Write a tab-separated table here and return only a summary.

None
timeout float | None

Seconds to allow.

None

Returns:

Name Type Description
A MatchRecords

class:MatchRecords. Read :attr:MatchRecords.caveats before trusting is_decoy -

MatchRecords

it is inferred from a name prefix for MsPathFinderT and is None for Casanovo.

Example

m = read_matches("results_IcTda.tsv") # doctest: +SKIP m.record_count, m.columns["modifications"] # doctest: +SKIP (6, ['', '12:Oxidation on M', '', '', '4:Acetylation on K', ''])

Source code in pkg/python/src/pymzlib/readers.py
def read_matches(
    path: str | os.PathLike[str],
    *,
    limit: int | None = None,
    offset: int = 0,
    out: str | os.PathLike[str] | None = None,
    timeout: float | None = None,
) -> MatchRecords:
    """Read identifications, in the cross-format ``spectral_match`` view.

    Four file types offer it: MsPathFinderT's targets, decoys and combined results, and Casanovo's
    ``.mztab``. These are the identification formats that share no *file*-level interface, so
    :func:`read_results` cannot reach them.

    **Nothing here is FDR-filtered, and there is no confidence column to filter on** - mzLib's
    ``ISpectralMatch`` carries identity fields only. Every one of these formats records an E-value
    or q-value that :func:`read_records` will give you. Filter before you report.

    Args:
        path: Path to an MsPathFinderT ``_IcTarget.tsv`` / ``_IcDecoy.tsv`` / ``_IcTDA.tsv``, or a
            Casanovo ``.mztab``.
        limit: Maximum matches to return. ``None`` returns all of them.
        offset: Matches to skip.
        out: Write a tab-separated table here and return only a summary.
        timeout: Seconds to allow.

    Returns:
        A :class:`MatchRecords`. Read :attr:`MatchRecords.caveats` before trusting ``is_decoy`` -
        it is inferred from a name prefix for MsPathFinderT and is ``None`` for Casanovo.

    Example:
        >>> m = read_matches("results_IcTda.tsv")                  # doctest: +SKIP
        >>> m.record_count, m.columns["modifications"]             # doctest: +SKIP
        (6, ['', '12:Oxidation on M', '', '', '4:Acetylation on K', ''])
    """
    args = _window("read-matches", path, limit=limit, offset=offset, out=out)
    data = _bridge.invoke(*args, timeout=timeout)
    return MatchRecords._from_wire(data)

read_spectra

read_spectra(path: str | PathLike[str], *, limit: int | None = None, offset: int = 0, ms_order: int | None = None, peaks: bool = False, out: str | PathLike[str] | None = None, timeout: float | None = None) -> ScanRecords

Read the scans of a spectra file: headers always, peaks on request.

Seven file types offer the spectra view: .mzML, .mgf, _ms1.msalign, _ms2.msalign, Thermo .raw, Bruker .d and timsTOF .d.

Peaks are opt-in and should stay that way unless you need them. A scan header is tens of bytes; its peak list is thousands, and a mid-size mzML holds tens of thousands of scans. With peaks=True the mz and intensity columns each become a list of arrays, one per scan - so pair it with limit, ms_order or out.

Parameters:

Name Type Description Default
path str | PathLike[str]

Path to a spectra file. A Bruker .d directory is also accepted.

required
limit int | None

Maximum scans to return. None returns all of them.

None
offset int

Scans to skip, applied after ms_order.

0
ms_order int | None

Keep only scans at this MS level - 1 for survey scans, 2 for fragment scans. Applied before offset and limit, so ms_order=2, limit=10 means the first ten MS2 scans rather than the MS2 scans among the first ten.

None
peaks bool

Include the mz and intensity arrays. Off by default.

False
out str | PathLike[str] | None

Write a tab-separated table here and return only a summary. With peaks=True each cell holds a ;-joined list.

None
timeout float | None

Seconds to allow. Reading a large .raw legitimately takes a while.

None

Returns:

Name Type Description
A ScanRecords

class:ScanRecords. Retention times are in minutes for every format.

Raises:

Type Description
UsageError

the path is blank, missing, unrecognised, has no spectra view, or ms_order is less than 1.

Example

s = read_spectra("run.mzML", ms_order=2, limit=5) # doctest: +SKIP s.scan_count, s.record_count # doctest: +SKIP (14238, 11902) s.columns["selected_ion_mz"] # doctest: +SKIP [447.7391, 551.2903, 638.8215, 712.3344, 805.9012]

Source code in pkg/python/src/pymzlib/readers.py
def read_spectra(
    path: str | os.PathLike[str],
    *,
    limit: int | None = None,
    offset: int = 0,
    ms_order: int | None = None,
    peaks: bool = False,
    out: str | os.PathLike[str] | None = None,
    timeout: float | None = None,
) -> ScanRecords:
    """Read the scans of a spectra file: headers always, peaks on request.

    Seven file types offer the ``spectra`` view: ``.mzML``, ``.mgf``, ``_ms1.msalign``,
    ``_ms2.msalign``, Thermo ``.raw``, Bruker ``.d`` and timsTOF ``.d``.

    **Peaks are opt-in and should stay that way unless you need them.** A scan header is tens of
    bytes; its peak list is thousands, and a mid-size mzML holds tens of thousands of scans. With
    ``peaks=True`` the ``mz`` and ``intensity`` columns each become a list of arrays, one per scan -
    so pair it with ``limit``, ``ms_order`` or ``out``.

    Args:
        path: Path to a spectra file. A Bruker ``.d`` directory is also accepted.
        limit: Maximum scans to return. ``None`` returns all of them.
        offset: Scans to skip, applied **after** ``ms_order``.
        ms_order: Keep only scans at this MS level - ``1`` for survey scans, ``2`` for fragment
            scans. Applied before ``offset`` and ``limit``, so ``ms_order=2, limit=10`` means the
            first ten MS2 scans rather than the MS2 scans among the first ten.
        peaks: Include the ``mz`` and ``intensity`` arrays. Off by default.
        out: Write a tab-separated table here and return only a summary. With ``peaks=True`` each
            cell holds a ``;``-joined list.
        timeout: Seconds to allow. Reading a large ``.raw`` legitimately takes a while.

    Returns:
        A :class:`ScanRecords`. Retention times are in minutes for every format.

    Raises:
        UsageError: the path is blank, missing, unrecognised, has no ``spectra`` view, or
            ``ms_order`` is less than 1.

    Example:
        >>> s = read_spectra("run.mzML", ms_order=2, limit=5)      # doctest: +SKIP
        >>> s.scan_count, s.record_count                           # doctest: +SKIP
        (14238, 11902)
        >>> s.columns["selected_ion_mz"]                           # doctest: +SKIP
        [447.7391, 551.2903, 638.8215, 712.3344, 805.9012]
    """
    args = _window("read-spectra", path, limit=limit, offset=offset, out=out)

    if ms_order is not None:
        if isinstance(ms_order, bool) or not isinstance(ms_order, int) or ms_order < 1:
            raise _bridge.UsageError(
                f"ms_order must be a whole number of 1 or more, or None; got {ms_order!r}."
            )
        args += ["--ms-order", str(ms_order)]

    if peaks:
        args += ["--peaks"]

    data = _bridge.invoke(*args, timeout=timeout)
    return ScanRecords._from_wire(data)

pymzlib.sdrf

sdrf

Read SDRF-Proteomics experimental-design files, and pool several into one table.

Every other reader in pyMzLib answers what did the search find. SDRF answers what was searched - which sample, which organism part, which replicate, which instrument settings - and that is the half you need to group results across experiments::

>>> import pymzlib
>>> doc = pymzlib.sdrf.read("PXD000070.sdrf.tsv")        # doctest: +SKIP
>>> doc.row_count, len(doc.columns)                      # doctest: +SKIP
(6, 31)
>>> doc.value("characteristics[organism]")               # doctest: +SKIP
['plasmodium falciparum', 'plasmodium falciparum', ...]

Pool several experiments into one analysis table, giving each a name you choose::

>>> pooled = pymzlib.sdrf.pool({                         # doctest: +SKIP
...     "PXD000070.sdrf.tsv": "malaria",
...     "PXD026824.sdrf.tsv": "colon",
... })
>>> pooled.document_count, pooled.row_count              # doctest: +SKIP
(2, 24)

This module is row-major, and every other reader here is columnar. That is not a style choice. :func:pymzlib.readers.read_records and friends hand back columns, a name-to-values dict, because their column names are a schema. SDRF's are data, and they repeat: 649 files in the curated corpus carry comment[modification parameters] more than once, up to eight times in one file, and one file repeats an empty name 23 times. A dict keyed by name would silently keep one occurrence and drop the rest. So :attr:SdrfDocument.columns is a list that may contain duplicates, :attr:SdrfDocument.rows is a list of cell lists, and position is what links them. Use :meth:SdrfDocument.value for the first cell under a name and :meth:SdrfDocument.all for every one.

Three things worth knowing before you index anything:

Rows are ragged. len(row) may be less than len(columns). Real files are like this - PXD059974 in mzLib's own fixtures has a 46-column header with 17 of its 23 rows carrying 42 cells - and mzLib preserves it rather than padding, so the file round-trips byte for byte. :meth:SdrfDocument.value returns None for a position a row does not reach.

Cells are raw strings, never interpreted. The SDRF key=value grammar ("NT=Oxidation;AC=UNIMOD:35") arrives exactly as written. It is not decoded, because it cannot be told apart from a cell that merely contains = and ; - comment[file uri] routinely carries pre-signed download URLs whose query strings contain Signature= and Expires=.

A reserved word is a real value. "not available" and "not applicable" mean the experiment stated an absence, which is not the same as a column the document does not have. None means the latter. Do not collapse the two.

What this module does not do yet is validate. mzLib models SDRF's structural rules in SdrfValidator and its vocabulary-drift rules in SdrfDriftLint. Both have been public since mzLib #1207, which the pinned mzLib (1.0.589) includes, so the bridge can call them. They are not exposed yet. When they are, the rules will be projected once, in the bridge, for all three bindings - a second implementation here is exactly the per-binding repair pyMzLib exists to avoid. Until then, this module reads, pools and reports honestly, and makes no claim about whether a document is correct.

WrittenSdrf dataclass

Where :func:pool wrote the merged document, when asked to write one.

Attributes:

Name Type Description
path str

The path written.

row_count int

Rows written - the whole merged document, not the windowed slice. limit and offset shape what comes back over the wire; they never shorten the file.

Source code in pkg/python/src/pymzlib/sdrf.py
@dataclass(frozen=True)
class WrittenSdrf:
    """Where :func:`pool` wrote the merged document, when asked to write one.

    Attributes:
        path: The path written.
        row_count: Rows written - the **whole** merged document, not the windowed slice. ``limit``
            and ``offset`` shape what comes back over the wire; they never shorten the file.
    """

    path: str
    row_count: int

    @classmethod
    def _from_wire(cls, payload: Mapping[str, Any]) -> "WrittenSdrf":
        return cls(path=payload.get("path", ""), row_count=int(payload.get("row_count", 0)))

SdrfDocument dataclass

One SDRF-Proteomics document: an ordered header, and rows of raw cells.

Attributes:

Name Type Description
path str

The path that was read.

columns list[str]

The column names, verbatim and in document order. Names may repeat, and the order is part of the document, so this is a list rather than a set. Names are never case-normalised: the corpus contains "comment[MS min charge]" and "Material Type", and rewriting them would silently alter someone's file.

rows list[list[str]]

One list of cells per row. Ragged: a row may be shorter than columns.

row_count int

Rows in the whole document, regardless of limit or offset.

returned_count int

Rows actually carried back in :attr:rows.

offset int

The offset that was applied.

truncated bool

Whether rows were left behind, by either limit or offset. A short answer and a complete one must never look alike, so check this rather than assuming.

caveats list[str]

What this document's data cannot tell you about itself - raggedness, repeated names, reserved words. Worth printing the first time you read an unfamiliar file.

Source code in pkg/python/src/pymzlib/sdrf.py
@dataclass(frozen=True)
class SdrfDocument:
    """One SDRF-Proteomics document: an ordered header, and rows of raw cells.

    Attributes:
        path: The path that was read.
        columns: The column names, **verbatim and in document order**. Names may repeat, and the
            order is part of the document, so this is a list rather than a set. Names are never
            case-normalised: the corpus contains ``"comment[MS min charge]"`` and
            ``"Material Type"``, and rewriting them would silently alter someone's file.
        rows: One list of cells per row. **Ragged**: a row may be shorter than ``columns``.
        row_count: Rows in the **whole document**, regardless of ``limit`` or ``offset``.
        returned_count: Rows actually carried back in :attr:`rows`.
        offset: The offset that was applied.
        truncated: Whether rows were left behind, by either ``limit`` or ``offset``. A short answer
            and a complete one must never look alike, so check this rather than assuming.
        caveats: What this document's data cannot tell you about itself - raggedness, repeated
            names, reserved words. Worth printing the first time you read an unfamiliar file.
    """

    path: str
    columns: list[str]
    rows: list[list[str]]
    row_count: int
    returned_count: int
    offset: int
    truncated: bool
    caveats: list[str] = field(default_factory=list)

    def index_of(self, column: str) -> int:
        """The position of the first column with this name, or ``-1`` if absent.

        Comparison is exact and case-**sensitive**, matching the SDRF specification and mzLib.
        """
        try:
            return self.columns.index(column)
        except ValueError:
            return -1

    def indexes_of(self, column: str) -> list[int]:
        """Every position carrying this name, in document order. Empty when the column is absent."""
        return [i for i, name in enumerate(self.columns) if name == column]

    def value(self, column: str) -> list[str | None]:
        """The first cell under ``column``, one entry per returned row.

        ``None`` means **the document does not have this column, or the row is too short to reach
        it**. It does not mean "empty": the SDRF reserved words ``"not available"`` and
        ``"not applicable"`` are real values that an experiment chose to write, and they come back
        as themselves.

        Example:
            >>> doc.value("characteristics[disease]")     # doctest: +SKIP
            ['not applicable', 'not applicable', ...]
        """
        i = self.index_of(column)
        if i < 0:
            return [None] * len(self.rows)
        return [row[i] if i < len(row) else None for row in self.rows]

    def all(self, column: str) -> list[list[str]]:
        """Every cell under ``column``, one list per returned row.

        The accessor for a multi-cardinality column such as ``comment[modification parameters]``,
        which legitimately repeats - up to eight times in one corpus file. Positions a row is too
        short to reach are skipped rather than reported as ``None``, so each inner list holds only
        cells that exist.
        """
        indexes = self.indexes_of(column)
        return [[row[i] for i in indexes if i < len(row)] for row in self.rows]

    @property
    def records(self) -> list[dict[str, str]]:
        """The rows as dicts, for the common case of a document with no repeated column names.

        **Lossy when a name repeats** - later positions overwrite earlier ones - which is exactly
        why it is not the primary shape. :attr:`has_repeated_columns` says whether that applies to
        this document. For a repeating document use :meth:`all` instead.
        """
        return [
            {name: row[i] for i, name in enumerate(self.columns) if i < len(row)}
            for row in self.rows
        ]

    @property
    def has_repeated_columns(self) -> bool:
        """Whether any column name appears more than once - see :attr:`records`."""
        return len(self.columns) != len(set(self.columns))

    @property
    def ragged_row_count(self) -> int:
        """How many returned rows carry fewer cells than there are columns."""
        return sum(1 for row in self.rows if len(row) < len(self.columns))

    @classmethod
    def _from_wire(cls, data: Mapping[str, Any]) -> "SdrfDocument":
        return cls(
            path=data.get("path", ""),
            columns=list(data.get("column_names") or []),
            rows=[list(r) for r in (data.get("rows") or [])],
            row_count=int(data.get("row_count", 0)),
            returned_count=int(data.get("returned_count", 0)),
            offset=int(data.get("offset", 0)),
            truncated=bool(data.get("truncated", False)),
            caveats=list(data.get("caveats") or []),
        )
records property
records: list[dict[str, str]]

The rows as dicts, for the common case of a document with no repeated column names.

Lossy when a name repeats - later positions overwrite earlier ones - which is exactly why it is not the primary shape. :attr:has_repeated_columns says whether that applies to this document. For a repeating document use :meth:all instead.

has_repeated_columns property
has_repeated_columns: bool

Whether any column name appears more than once - see :attr:records.

ragged_row_count property
ragged_row_count: int

How many returned rows carry fewer cells than there are columns.

index_of
index_of(column: str) -> int

The position of the first column with this name, or -1 if absent.

Comparison is exact and case-sensitive, matching the SDRF specification and mzLib.

Source code in pkg/python/src/pymzlib/sdrf.py
def index_of(self, column: str) -> int:
    """The position of the first column with this name, or ``-1`` if absent.

    Comparison is exact and case-**sensitive**, matching the SDRF specification and mzLib.
    """
    try:
        return self.columns.index(column)
    except ValueError:
        return -1
indexes_of
indexes_of(column: str) -> list[int]

Every position carrying this name, in document order. Empty when the column is absent.

Source code in pkg/python/src/pymzlib/sdrf.py
def indexes_of(self, column: str) -> list[int]:
    """Every position carrying this name, in document order. Empty when the column is absent."""
    return [i for i, name in enumerate(self.columns) if name == column]
value
value(column: str) -> list[str | None]

The first cell under column, one entry per returned row.

None means the document does not have this column, or the row is too short to reach it. It does not mean "empty": the SDRF reserved words "not available" and "not applicable" are real values that an experiment chose to write, and they come back as themselves.

Example

doc.value("characteristics[disease]") # doctest: +SKIP ['not applicable', 'not applicable', ...]

Source code in pkg/python/src/pymzlib/sdrf.py
def value(self, column: str) -> list[str | None]:
    """The first cell under ``column``, one entry per returned row.

    ``None`` means **the document does not have this column, or the row is too short to reach
    it**. It does not mean "empty": the SDRF reserved words ``"not available"`` and
    ``"not applicable"`` are real values that an experiment chose to write, and they come back
    as themselves.

    Example:
        >>> doc.value("characteristics[disease]")     # doctest: +SKIP
        ['not applicable', 'not applicable', ...]
    """
    i = self.index_of(column)
    if i < 0:
        return [None] * len(self.rows)
    return [row[i] if i < len(row) else None for row in self.rows]
all
all(column: str) -> list[list[str]]

Every cell under column, one list per returned row.

The accessor for a multi-cardinality column such as comment[modification parameters], which legitimately repeats - up to eight times in one corpus file. Positions a row is too short to reach are skipped rather than reported as None, so each inner list holds only cells that exist.

Source code in pkg/python/src/pymzlib/sdrf.py
def all(self, column: str) -> list[list[str]]:
    """Every cell under ``column``, one list per returned row.

    The accessor for a multi-cardinality column such as ``comment[modification parameters]``,
    which legitimately repeats - up to eight times in one corpus file. Positions a row is too
    short to reach are skipped rather than reported as ``None``, so each inner list holds only
    cells that exist.
    """
    indexes = self.indexes_of(column)
    return [[row[i] for i in indexes if i < len(row)] for row in self.rows]

PooledSdrf dataclass

Bases: SdrfDocument

Several SDRF documents merged into one table.

Everything :class:SdrfDocument offers, plus where the rows came from. path is empty: a pooled table is not a file that was read.

Attributes:

Name Type Description
document_count int

How many documents were pooled.

paths list[str]

The paths pooled, in the order given.

labels list[str]

The provenance label used for each, in the same order - either what you supplied or mzLib's containing-folder/file-stem default.

written WrittenSdrf | None

Where the merged document was written, when out was given. None otherwise.

Source code in pkg/python/src/pymzlib/sdrf.py
@dataclass(frozen=True)
class PooledSdrf(SdrfDocument):
    """Several SDRF documents merged into one table.

    Everything :class:`SdrfDocument` offers, plus where the rows came from. ``path`` is empty:
    a pooled table is not a file that was read.

    Attributes:
        document_count: How many documents were pooled.
        paths: The paths pooled, in the order given.
        labels: The provenance label used for each, in the same order - either what you supplied
            or mzLib's ``containing-folder/file-stem`` default.
        written: Where the merged document was written, when ``out`` was given. ``None`` otherwise.
    """

    document_count: int = 0
    paths: list[str] = field(default_factory=list)
    labels: list[str] = field(default_factory=list)
    written: WrittenSdrf | None = None

    #: The column :meth:`pool` adds to record which document each row came from.
    SOURCE_DOCUMENT_COLUMN = "comment[source document]"

    def source_documents(self) -> list[str | None]:
        """The provenance label of each returned row - which document it came from."""
        return self.value(self.SOURCE_DOCUMENT_COLUMN)

    @classmethod
    def _from_wire(cls, data: Mapping[str, Any]) -> "PooledSdrf":
        written = data.get("written")
        return cls(
            path="",
            columns=list(data.get("column_names") or []),
            rows=[list(r) for r in (data.get("rows") or [])],
            row_count=int(data.get("row_count", 0)),
            returned_count=int(data.get("returned_count", 0)),
            offset=int(data.get("offset", 0)),
            truncated=bool(data.get("truncated", False)),
            caveats=list(data.get("caveats") or []),
            document_count=int(data.get("document_count", 0)),
            paths=list(data.get("paths") or []),
            labels=list(data.get("labels") or []),
            written=WrittenSdrf._from_wire(written) if written else None,
        )
source_documents
source_documents() -> list[str | None]

The provenance label of each returned row - which document it came from.

Source code in pkg/python/src/pymzlib/sdrf.py
def source_documents(self) -> list[str | None]:
    """The provenance label of each returned row - which document it came from."""
    return self.value(self.SOURCE_DOCUMENT_COLUMN)

read

read(path: str | PathLike[str], *, limit: int | None = None, offset: int = 0, timeout: float | None = 60) -> SdrfDocument

Read one SDRF-Proteomics file.

Parameters:

Name Type Description Default
path str | PathLike[str]

Path to a .sdrf.tsv file.

required
limit int | None

Maximum rows to return. None (the default) returns all of them.

None
offset int

Rows to skip.

0
timeout float | None

Seconds to allow. None waits indefinitely.

60

Returns:

Name Type Description
An SdrfDocument

class:SdrfDocument.

Raises:

Type Description
UsageError

the path is blank, or the file is missing or unreadable as SDRF.

Example

doc = read("PXD000070.sdrf.tsv") # doctest: +SKIP doc.value("characteristics[organism part]")[0] # doctest: +SKIP 'human erythrocytes' doc.all("comment[modification parameters]")[0] # doctest: +SKIP ['NT=Carbamidomethyl;AC=UNIMOD:4;TA=C;MT=Fixed', 'NT=Oxidation;AC=UNIMOD:35;...']

Source code in pkg/python/src/pymzlib/sdrf.py
def read(
    path: str | os.PathLike[str],
    *,
    limit: int | None = None,
    offset: int = 0,
    timeout: float | None = 60,
) -> SdrfDocument:
    """Read one SDRF-Proteomics file.

    Args:
        path: Path to a ``.sdrf.tsv`` file.
        limit: Maximum rows to return. ``None`` (the default) returns all of them.
        offset: Rows to skip.
        timeout: Seconds to allow. ``None`` waits indefinitely.

    Returns:
        An :class:`SdrfDocument`.

    Raises:
        UsageError: the path is blank, or the file is missing or unreadable as SDRF.

    Example:
        >>> doc = read("PXD000070.sdrf.tsv")                   # doctest: +SKIP
        >>> doc.value("characteristics[organism part]")[0]     # doctest: +SKIP
        'human erythrocytes'
        >>> doc.all("comment[modification parameters]")[0]     # doctest: +SKIP
        ['NT=Carbamidomethyl;AC=UNIMOD:4;TA=C;MT=Fixed', 'NT=Oxidation;AC=UNIMOD:35;...']
    """
    path = _bridge.path_text(path)
    if not path:
        raise _bridge.UsageError("A file path is required, e.g. 'PXD000070.sdrf.tsv'.")

    args = ["sdrf", "read", "--path", path, *_window(limit, offset)]
    data = _bridge.invoke(*args, timeout=timeout)
    return SdrfDocument._from_wire(data)

pool

pool(documents: Sequence[str] | Mapping[str, str], *, out: str | PathLike[str] | None = None, limit: int | None = None, offset: int = 0, timeout: float | None = 60) -> PooledSdrf

Merge several SDRF documents into one analysis table.

Columns are the union of every document's, ordered by SDRF's own block structure, and a name that repeats is carried at the highest multiplicity any single document used, so nothing is dropped. A cell a document did not have is filled with the reserved word "not available", and a comment[source document] column records which document each row came from.

Give your documents names. Pass a mapping to choose them. With a plain sequence, mzLib falls back to containing-folder/file-stem, which depends on where the files happen to sit - so the same two documents pooled from a different directory produce a different table. The returned :attr:PooledSdrf.caveats says so when that fallback was used.

The result is an analysis table, not something to deposit. source name + assay name + comment[label] is unique within one document, but two experiments may both have a "Sample 1", so a pooled table will usually violate SDRF's uniqueness rule. Use the source-document column as part of any key.

Parameters:

Name Type Description Default
documents Sequence[str] | Mapping[str, str]

The files to pool - a sequence of paths, or a {path: label} mapping.

required
out str | PathLike[str] | None

Write the merged document here as SDRF. The whole document is written regardless of limit and offset.

None
limit int | None

Maximum rows to return over the wire. None (the default) returns all of them.

None
offset int

Rows to skip.

0
timeout float | None

Seconds to allow. None waits indefinitely.

60

Returns:

Name Type Description
A PooledSdrf

class:PooledSdrf.

Raises:

Type Description
UsageError

no documents were given, a path is missing, or a label is blank.

Example

pooled = pool({"a.sdrf.tsv": "malaria", "b.sdrf.tsv": "colon"}) # doctest: +SKIP set(pooled.source_documents()) # doctest: +SKIP

Source code in pkg/python/src/pymzlib/sdrf.py
def pool(
    documents: Sequence[str] | Mapping[str, str],
    *,
    out: str | os.PathLike[str] | None = None,
    limit: int | None = None,
    offset: int = 0,
    timeout: float | None = 60,
) -> PooledSdrf:
    """Merge several SDRF documents into one analysis table.

    Columns are the union of every document's, ordered by SDRF's own block structure, and a name
    that repeats is carried at the highest multiplicity any single document used, so nothing is
    dropped. A cell a document did not have is filled with the reserved word ``"not available"``,
    and a ``comment[source document]`` column records which document each row came from.

    **Give your documents names.** Pass a mapping to choose them. With a plain sequence, mzLib
    falls back to ``containing-folder/file-stem``, which depends on where the files happen to sit -
    so the same two documents pooled from a different directory produce a different table. The
    returned :attr:`PooledSdrf.caveats` says so when that fallback was used.

    **The result is an analysis table, not something to deposit.** ``source name`` + ``assay
    name`` + ``comment[label]`` is unique within one document, but two experiments may both have a
    ``"Sample 1"``, so a pooled table will usually violate SDRF's uniqueness rule. Use the
    source-document column as part of any key.

    Args:
        documents: The files to pool - a sequence of paths, or a ``{path: label}`` mapping.
        out: Write the merged document here as SDRF. The **whole** document is written regardless
            of ``limit`` and ``offset``.
        limit: Maximum rows to return over the wire. ``None`` (the default) returns all of them.
        offset: Rows to skip.
        timeout: Seconds to allow. ``None`` waits indefinitely.

    Returns:
        A :class:`PooledSdrf`.

    Raises:
        UsageError: no documents were given, a path is missing, or a label is blank.

    Example:
        >>> pooled = pool({"a.sdrf.tsv": "malaria", "b.sdrf.tsv": "colon"})   # doctest: +SKIP
        >>> set(pooled.source_documents())                                   # doctest: +SKIP
        {'malaria', 'colon'}
    """
    if isinstance(documents, Mapping):
        pairs = [(str(p), str(label)) for p, label in documents.items()]
    elif isinstance(documents, str):
        # A bare string is a sequence of characters, so this would otherwise pool one document per
        # letter and fail with a baffling "file not found: P".
        raise _bridge.UsageError(
            "pool() takes several documents; pass a list or a {path: label} mapping. "
            f"For one document use read({documents!r})."
        )
    elif isinstance(documents, Sequence):
        pairs = [(str(p), "") for p in documents]
    else:
        raise _bridge.UsageError(
            f"documents must be a sequence of paths or a {{path: label}} mapping; got {documents!r}."
        )

    if not pairs:
        raise _bridge.UsageError("At least one SDRF document is required.")

    lines = []
    for path, label in pairs:
        if not path.strip():
            raise _bridge.UsageError("A document path may not be blank.")
        if isinstance(documents, Mapping) and not label.strip():
            raise _bridge.UsageError(
                f"The label for '{path}' is blank. Give every document a label, or pass a plain "
                "list to accept mzLib's path-derived default for all of them."
            )
        if "\t" in path or "\t" in label or "\n" in path or "\n" in label:
            raise _bridge.UsageError(
                f"A path or label contains a tab or newline, which the bridge uses to separate "
                f"them: {path!r} / {label!r}."
            )
        lines.append(f"{path.strip()}\t{label.strip()}" if label.strip() else path.strip())

    args = ["sdrf", "pool", *_window(limit, offset)]
    if out is not None:
        out = _bridge.path_text(out)
        if not out:
            raise _bridge.UsageError("out must be a non-empty path or None.")
        args += ["--out", out]

    data = _bridge.invoke(*args, stdin="\n".join(lines), timeout=timeout)
    return PooledSdrf._from_wire(data)

Errors and diagnostics

Everything pyMzLib raises inherits from PyMzLibError, so a single except catches all of it.

_bridge

Locating and invoking the bundled mzLib bridge executable.

This module is the only place in pyMzLib that knows the bridge exists. Everything above it sees ordinary Python functions and objects. That boundary is deliberate: the transport (today, a self-contained .NET executable invoked per call) can be replaced by an in-process binding or a long-lived local server without any public API changing.

Nothing here imports a third-party package. pyMzLib declares no runtime dependencies, so it cannot participate in a dependency conflict inside anyone's environment.

PyMzLibError

Bases: Exception

Base class for every error pyMzLib raises.

Source code in pkg/python/src/pymzlib/_bridge.py
class PyMzLibError(Exception):
    """Base class for every error pyMzLib raises."""

UsageError

Bases: PyMzLibError, ValueError

A call was malformed — a missing or invalid argument. Raised before any work happens.

Source code in pkg/python/src/pymzlib/_bridge.py
class UsageError(PyMzLibError, ValueError):
    """A call was malformed — a missing or invalid argument. Raised before any work happens."""

BridgeError

Bases: PyMzLibError

mzLib reported a failure.

Attributes:

Name Type Description
error_type

The .NET exception type name, e.g. HttpRequestException. Useful for distinguishing a network failure from a bad accession without parsing prose.

Source code in pkg/python/src/pymzlib/_bridge.py
class BridgeError(PyMzLibError):
    """mzLib reported a failure.

    Attributes:
        error_type: The .NET exception type name, e.g. ``HttpRequestException``. Useful for
            distinguishing a network failure from a bad accession without parsing prose.
    """

    def __init__(self, error_type: str, message: str) -> None:
        super().__init__(message)
        self.error_type = error_type

ServiceUnavailableError

Bases: BridgeError

An external service is unavailable — down, rate-limited, timing out, or unreachable.

This is deliberately a distinct type, because the difference between "the repository is having a bad morning" and "something is broken" is the difference between retrying later and filing a bug. Catch it to back off and retry::

try:
    files = pymzlib.pride.list_files("PXD000001")
except pymzlib.ServiceUnavailableError:
    ...   # EBI's problem; try again later
except pymzlib.BridgeError:
    ...   # ours

The classification happens in the bridge rather than here, so every consumer of the wire format gets it and not only Python. HTTP 408, 429, and 5xx count as unavailable; 404 and 400 do not, because a wrong URL or a malformed request is our problem and excusing it as an outage would hide a real bug.

Source code in pkg/python/src/pymzlib/_bridge.py
class ServiceUnavailableError(BridgeError):
    """An external service is unavailable — down, rate-limited, timing out, or unreachable.

    This is deliberately a distinct type, because the difference between "the repository is
    having a bad morning" and "something is broken" is the difference between retrying later and
    filing a bug. Catch it to back off and retry::

        try:
            files = pymzlib.pride.list_files("PXD000001")
        except pymzlib.ServiceUnavailableError:
            ...   # EBI's problem; try again later
        except pymzlib.BridgeError:
            ...   # ours

    The classification happens in the bridge rather than here, so every consumer of the wire
    format gets it and not only Python. HTTP 408, 429, and 5xx count as unavailable; 404 and 400
    do not, because a wrong URL or a malformed request is our problem and excusing it as an
    outage would hide a real bug.
    """

BridgeTimeoutError

Bases: PyMzLibError

The bridge process did not finish within the timeout.

Deliberately not a :class:ServiceUnavailableError, and the distinction is the whole point. A subprocess timeout has several possible causes and only one of them is a slow service: the bridge may be wedged, the executable may be corrupt, antivirus may be holding it, or the caller may simply have passed a timeout that was too short. Reporting all of that as "the repository is down" is how a real bug gets skipped by every test suite and never seen again.

If the caller wants a slow network to be treated as an outage, they can catch this explicitly — but the library will not guess on their behalf.

Source code in pkg/python/src/pymzlib/_bridge.py
class BridgeTimeoutError(PyMzLibError):
    """The bridge process did not finish within the timeout.

    Deliberately **not** a :class:`ServiceUnavailableError`, and the distinction is the whole
    point. A subprocess timeout has several possible causes and only one of them is a slow
    service: the bridge may be wedged, the executable may be corrupt, antivirus may be holding
    it, or the caller may simply have passed a timeout that was too short. Reporting all of that
    as "the repository is down" is how a real bug gets skipped by every test suite and never
    seen again.

    If the caller wants a slow network to be treated as an outage, they can catch this
    explicitly — but the library will not guess on their behalf.
    """

BridgeNotFoundError

Bases: PyMzLibError

The bridge executable could not be located.

In a released wheel this should be impossible: the executable ships inside the package. It normally means pyMzLib is being run from a source checkout where the bridge has not been built yet (see pkg/build/publish-bridge.ps1).

Source code in pkg/python/src/pymzlib/_bridge.py
class BridgeNotFoundError(PyMzLibError):
    """The bridge executable could not be located.

    In a released wheel this should be impossible: the executable ships inside the package.
    It normally means pyMzLib is being run from a source checkout where the bridge has not
    been built yet (see ``pkg/build/publish-bridge.ps1``).
    """

bridge_path

bridge_path() -> Path

Return the path of the bridge executable that will be used.

Resolution order: the PYMZLIB_BRIDGE environment variable, then the copy staged inside this package for the current platform.

Raises:

Type Description
BridgeNotFoundError

if neither exists.

Source code in pkg/python/src/pymzlib/_bridge.py
def bridge_path() -> Path:
    """Return the path of the bridge executable that will be used.

    Resolution order: the ``PYMZLIB_BRIDGE`` environment variable, then the copy staged
    inside this package for the current platform.

    Raises:
        BridgeNotFoundError: if neither exists.
    """
    override = os.environ.get(BRIDGE_ENV_VAR)
    if override:
        candidate = Path(override)
        if not candidate.is_file():
            raise BridgeNotFoundError(f"{BRIDGE_ENV_VAR} points at '{override}', which is not a file.")
        return candidate

    executable = "mzlib-bridge.exe" if sys.platform == "win32" else "mzlib-bridge"
    candidate = Path(__file__).parent / "_dotnet" / _platform_tag() / executable
    if not candidate.is_file():
        raise BridgeNotFoundError(
            f"No mzLib bridge for this platform at '{candidate}'. "
            f"In a source checkout, build one and set {BRIDGE_ENV_VAR} to its path."
        )
    return candidate

bridge_version

bridge_version() -> dict[str, Any]

Return the bridge's own version information, and check protocol compatibility.

The payload carries:

bridge The bridge assembly's own version. protocol The wire-format version. This — not the mzLib version — is the compatibility contract: a binding is compatible with a bridge by protocol. runtime The bundled .NET runtime. mzlib Which mzLib this bridge was built against, as 1.0.0+<commit>, or absent when the build recorded no commit. It answers "which mzLib am I actually running?" for someone holding a wheel who has no access to the repository's pin file. It is deliberately not a version to compare against: use protocol for that.

Because mzlib is absent from bridges built before it was added, read it with .get("mzlib") rather than indexing.

Raises:

Type Description
PyMzLibError

if the bridge speaks a different wire format than this package.

Source code in pkg/python/src/pymzlib/_bridge.py
def bridge_version() -> dict[str, Any]:
    """Return the bridge's own version information, and check protocol compatibility.

    The payload carries:

    ``bridge``
        The bridge assembly's own version.
    ``protocol``
        The wire-format version. This — not the mzLib version — is the compatibility
        contract: a binding is compatible with a *bridge* by protocol.
    ``runtime``
        The bundled .NET runtime.
    ``mzlib``
        Which mzLib this bridge was built against, as ``1.0.0+<commit>``, or absent when
        the build recorded no commit. It answers "which mzLib am I actually running?" for
        someone holding a wheel who has no access to the repository's pin file. It is
        deliberately *not* a version to compare against: use ``protocol`` for that.

    Because ``mzlib`` is absent from bridges built before it was added, read it with
    ``.get("mzlib")`` rather than indexing.

    Raises:
        PyMzLibError: if the bridge speaks a different wire format than this package.
    """
    info = invoke("version", timeout=60)
    reported = info.get("protocol")
    if reported != PROTOCOL_VERSION:
        raise PyMzLibError(
            f"mzLib bridge speaks protocol {reported}, but this pyMzLib expects {PROTOCOL_VERSION}. "
            "The Python package and the bridge were built from different sources."
        )
    return info