Skip to content

seroverse.client

Asynchronous, token-bucket rate-limited HTTP clients and msgspec msgpack cassette utilities for querying NCBI, ENA, DSMZ BacDive, StrainInfo, and Pathogenwatch.


Base Client & Msgpack Cassettes (seroverse.client.base)

base

Unified asynchronous HTTP client and msgspec serialization helpers for SeroVerse.

Classes:

Name Description
Client

High-performance asynchronous HTTP client powered by aiohttp and msgspec.

Client

High-performance asynchronous HTTP client powered by aiohttp and msgspec.

Parameters:

Name Type Description Default
base_url str

Base endpoint URL.

required
api_key_header str | None

Optional HTTP header key for API authorization.

None
api_key str | None

Optional API key string.

None
user_agent str

User-Agent header value.

'seroverse-client/0.1'
timeout float

Total request timeout in seconds.

30.0
max_concurrency int

Maximum concurrent in-flight requests per event loop.

5
polite_delay float

Optional minimum delay (in seconds) after live network requests.

0.0
recorder Any | None

Optional cassetter recorder instance.

None

Methods:

Name Description
encode_msgpack

Serialize an object or dataclass tree into compact MessagePack bytes.

decode_msgpack

Deserialize MessagePack bytes directly into a typed @dataclass(frozen=True, slots=True) structure.

save_msgpack

Atomically persist obj to path as binary MessagePack.

load_msgpack

Load and decode a binary MessagePack file into type_.

polite_sleep

Sleep politely after a live network request, skipping sleep on fast cassette replays.

get_session

Retrieve or instantiate the underlying aiohttp.ClientSession for the active event loop.

close

Close the underlying aiohttp.ClientSession if open.

close_sync

Close the underlying aiohttp.ClientSession synchronously.

request

Send an async HTTP request and return decoded JSON (via msgspec) or text.

get

Perform an async HTTP GET request.

post

Perform an async HTTP POST request.

delete

Perform an async HTTP DELETE request.

stream_bytes

Send an async HTTP request and read raw response bytes.

get_json

Fetch endpoint via GET and decode JSON directly into type_ using msgspec.

post_json

Fetch endpoint via POST and decode JSON directly into type_ using msgspec.

stream_buffer

Send an async HTTP request and return an in-memory BytesIO stream.

stream_chunks

Stream response bytes in chunks as an async generator, emitting progress when Content-Length is known.

run_sync

Execute a coroutine synchronously and close any session bound to the temporary event loop.

Attributes:

Name Type Description
api_key str | None

Return the configured API key string, or None.

Source code in src/seroverse/client/base.py
class Client:
    """High-performance asynchronous HTTP client powered by `aiohttp` and `msgspec`.

    Args:
        base_url: Base endpoint URL.
        api_key_header: Optional HTTP header key for API authorization.
        api_key: Optional API key string.
        user_agent: User-Agent header value.
        timeout: Total request timeout in seconds.
        max_concurrency: Maximum concurrent in-flight requests per event loop.
        polite_delay: Optional minimum delay (in seconds) after live network requests.
        recorder: Optional `cassetter` recorder instance.
    """

    def __init__(
        self,
        base_url: str,
        api_key_header: str | None = None,
        api_key: str | None = None,
        user_agent: str = "seroverse-client/0.1",
        timeout: float = 30.0,
        *,
        max_concurrency: int = 5,
        polite_delay: float = 0.0,
        recorder: Any | None = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.api_key_header = api_key_header
        self._api_key: str | None = api_key
        self.user_agent = user_agent
        self.timeout = aiohttp.ClientTimeout(total=timeout)
        self.max_concurrency = max(1, int(max_concurrency))
        self.polite_delay = float(polite_delay)
        self.recorder = recorder
        self._session: aiohttp.ClientSession | None = None
        self._sem: asyncio.Semaphore | None = None
        self._sem_loop: asyncio.AbstractEventLoop | None = None

    @property
    def api_key(self) -> str | None:
        """Return the configured API key string, or `None`."""
        return self._api_key

    @staticmethod
    def encode_msgpack(obj: Any) -> bytes:
        """Serialize an object or dataclass tree into compact MessagePack bytes."""
        return msgspec.msgpack.encode(obj)

    @staticmethod
    def decode_msgpack[T](data: bytes, type_: type[T]) -> T:
        """Deserialize MessagePack bytes directly into a typed `@dataclass(frozen=True, slots=True)` structure."""
        return msgspec.msgpack.decode(data, type=type_)

    @staticmethod
    def save_msgpack(path: Path, obj: Any) -> None:
        """Atomically persist `obj` to `path` as binary MessagePack."""
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
        tmp.write_bytes(msgspec.msgpack.encode(obj))
        os.replace(tmp, path)

    @staticmethod
    def load_msgpack[T](path: Path, type_: type[T]) -> T:
        """Load and decode a binary MessagePack file into `type_`."""
        return msgspec.msgpack.decode(path.read_bytes(), type=type_)

    def __repr__(self) -> str:
        key_repr = "'**********'" if self._api_key else "None"
        return f"{self.__class__.__name__}(base_url='{self.base_url}', api_key={key_repr})"

    def __cache_fingerprint__(self) -> tuple[str, str, str]:
        """Returns a deterministic, credential-safe fingerprint tuple for content-addressable caches."""
        key_digest = hashlib.sha256((self._api_key or "").encode("utf-8")).hexdigest()[:16] if self._api_key else ""
        return (type(self).__qualname__, self.base_url, key_digest)

    def _headers(self) -> dict[str, str]:
        headers = {"User-Agent": self.user_agent, "Accept": "application/json"}
        if self.api_key_header and self._api_key:
            headers[self.api_key_header] = self._api_key
        return headers

    def _get_semaphore(self) -> asyncio.Semaphore:
        loop = asyncio.get_running_loop()
        if self._sem is None or self._sem_loop is not loop:
            self._sem = asyncio.Semaphore(self.max_concurrency)
            self._sem_loop = loop
        return self._sem

    async def polite_sleep(self, t_req: float, delay: float | None = None) -> None:
        """Sleep politely after a live network request, skipping sleep on fast cassette replays."""
        wait_s = self.polite_delay if delay is None else delay
        if wait_s <= 0.0:
            return
        elapsed = time.perf_counter() - t_req
        if not (self.recorder is not None and elapsed < 0.25) and elapsed > 0.02:
            await asyncio.sleep(wait_s)

    async def get_session(self) -> aiohttp.ClientSession:
        """Retrieve or instantiate the underlying `aiohttp.ClientSession` for the active event loop."""
        current_loop = asyncio.get_running_loop()
        if (
            self._session is None
            or self._session.closed
            or getattr(self._session, "_loop", None) is not current_loop
            or getattr(self._session, "_loop", current_loop).is_closed()
        ):
            self._session = aiohttp.ClientSession(
                headers=self._headers(),
                timeout=self.timeout,
            )
        return self._session

    async def __aenter__(self) -> Self:
        await self.get_session()
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        await self.close()

    def __enter__(self) -> Self:
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        self.close_sync()

    async def close(self) -> None:
        """Close the underlying `aiohttp.ClientSession` if open."""
        if self._session is not None:
            if not self._session.closed:
                loop = getattr(self._session, "_loop", None)
                if loop is None or not loop.is_closed():
                    await self._session.close()
            self._session = None

    def close_sync(self) -> None:
        """Close the underlying `aiohttp.ClientSession` synchronously."""
        if self._session is not None and not self._session.closed:
            loop = getattr(self._session, "_loop", None)
            if loop is not None and not loop.is_closed():
                self.run_sync(self.close())
            else:
                self._session = None

    def _url(self, endpoint: str) -> str:
        if endpoint.startswith(("http://", "https://")):
            return endpoint
        return f"{self.base_url}/{endpoint.lstrip('/')}" if endpoint else self.base_url

    async def request(self, method: str, endpoint: str, **kwargs: Any) -> Any:
        """Send an async HTTP request and return decoded JSON (via `msgspec`) or text."""
        session = await self.get_session()
        url = self._url(endpoint)
        async with self._get_semaphore():
            t_req = time.perf_counter()
            try:
                async with session.request(method, url, **kwargs) as resp:
                    raw = await resp.read()
                    if resp.status >= 400:
                        body = raw.decode("utf-8", errors="replace").strip()
                        reason = resp.reason or "HTTP Error"
                        detail = f"{reason}: {body[:300]}" if body else reason
                        raise aiohttp.ClientResponseError(
                            resp.request_info,
                            resp.history,
                            status=resp.status,
                            message=detail,
                            headers=resp.headers,
                        )
                    ctype = (resp.content_type or "").lower()
                    if "json" in ctype:
                        return msgspec.json.decode(raw)
                    return raw.decode("utf-8", errors="replace")
            finally:
                await self.polite_sleep(t_req)

    async def get(self, endpoint: str, **kwargs: Any) -> Any:
        """Perform an async HTTP GET request."""
        return await self.request("GET", endpoint, **kwargs)

    async def post(self, endpoint: str, **kwargs: Any) -> Any:
        """Perform an async HTTP POST request."""
        return await self.request("POST", endpoint, **kwargs)

    async def delete(self, endpoint: str, **kwargs: Any) -> Any:
        """Perform an async HTTP DELETE request."""
        return await self.request("DELETE", endpoint, **kwargs)

    async def stream_bytes(self, method: str, endpoint: str, **kwargs: Any) -> bytes:
        """Send an async HTTP request and read raw response bytes."""
        session = await self.get_session()
        url = self._url(endpoint)
        async with self._get_semaphore():
            t_req = time.perf_counter()
            try:
                async with session.request(method, url, **kwargs) as resp:
                    resp.raise_for_status()
                    return await resp.read()
            finally:
                await self.polite_sleep(t_req)

    async def get_json[T](self, endpoint: str, *, type_: type[T], **kwargs: Any) -> T:
        """Fetch `endpoint` via GET and decode JSON directly into `type_` using `msgspec`."""
        raw = await self.stream_bytes("GET", endpoint, **kwargs)
        return msgspec.json.decode(raw, type=type_)

    async def post_json[T](self, endpoint: str, *, type_: type[T], **kwargs: Any) -> T:
        """Fetch `endpoint` via POST and decode JSON directly into `type_` using `msgspec`."""
        raw = await self.stream_bytes("POST", endpoint, **kwargs)
        return msgspec.json.decode(raw, type=type_)

    async def stream_buffer(self, method: str, endpoint: str, **kwargs: Any) -> BytesIO:
        """Send an async HTTP request and return an in-memory `BytesIO` stream."""
        b = await self.stream_bytes(method, endpoint, **kwargs)
        return BytesIO(b)

    async def stream_chunks(
        self,
        method: str,
        endpoint: str,
        chunk_size: int = 65536,
        **kwargs: Any,
    ) -> AsyncIterator[bytes]:
        """Stream response bytes in chunks as an async generator, emitting progress when `Content-Length` is known."""
        session = await self.get_session()
        url = self._url(endpoint)
        prog = current_progress()
        async with session.request(method, url, **kwargs) as resp:
            resp.raise_for_status()
            total_bytes = resp.content_length if resp.content_length and resp.content_length > 0 else None
            received = 0
            async for chunk in resp.content.iter_chunked(chunk_size):
                received += len(chunk)
                if total_bytes is not None and prog.is_active:
                    prog.report(
                        min(1.0, received / total_bytes),
                        detail=f"Streaming {endpoint} ({received:,} / {total_bytes:,} B)",
                    )
                yield chunk

    def run_sync[T](self, coro: Coroutine[Any, Any, T]) -> T:
        """Execute a coroutine synchronously and close any session bound to the temporary event loop."""

        async def _runner() -> T:
            try:
                return await coro
            finally:
                await self.close()

        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            loop = None

        if loop and loop.is_running():
            with ThreadPoolExecutor(max_workers=1) as pool:
                future = pool.submit(lambda: asyncio.run(_runner()))
                return future.result()
        return asyncio.run(_runner())

api_key property

api_key: str | None

Return the configured API key string, or None.

encode_msgpack staticmethod

encode_msgpack(obj: Any) -> bytes

Serialize an object or dataclass tree into compact MessagePack bytes.

Source code in src/seroverse/client/base.py
@staticmethod
def encode_msgpack(obj: Any) -> bytes:
    """Serialize an object or dataclass tree into compact MessagePack bytes."""
    return msgspec.msgpack.encode(obj)

decode_msgpack staticmethod

decode_msgpack(data: bytes, type_: type[T]) -> T

Deserialize MessagePack bytes directly into a typed @dataclass(frozen=True, slots=True) structure.

Source code in src/seroverse/client/base.py
@staticmethod
def decode_msgpack[T](data: bytes, type_: type[T]) -> T:
    """Deserialize MessagePack bytes directly into a typed `@dataclass(frozen=True, slots=True)` structure."""
    return msgspec.msgpack.decode(data, type=type_)

save_msgpack staticmethod

save_msgpack(path: Path, obj: Any) -> None

Atomically persist obj to path as binary MessagePack.

Source code in src/seroverse/client/base.py
@staticmethod
def save_msgpack(path: Path, obj: Any) -> None:
    """Atomically persist `obj` to `path` as binary MessagePack."""
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
    tmp.write_bytes(msgspec.msgpack.encode(obj))
    os.replace(tmp, path)

load_msgpack staticmethod

load_msgpack(path: Path, type_: type[T]) -> T

Load and decode a binary MessagePack file into type_.

Source code in src/seroverse/client/base.py
@staticmethod
def load_msgpack[T](path: Path, type_: type[T]) -> T:
    """Load and decode a binary MessagePack file into `type_`."""
    return msgspec.msgpack.decode(path.read_bytes(), type=type_)

polite_sleep async

polite_sleep(
    t_req: float, delay: float | None = None
) -> None

Sleep politely after a live network request, skipping sleep on fast cassette replays.

Source code in src/seroverse/client/base.py
async def polite_sleep(self, t_req: float, delay: float | None = None) -> None:
    """Sleep politely after a live network request, skipping sleep on fast cassette replays."""
    wait_s = self.polite_delay if delay is None else delay
    if wait_s <= 0.0:
        return
    elapsed = time.perf_counter() - t_req
    if not (self.recorder is not None and elapsed < 0.25) and elapsed > 0.02:
        await asyncio.sleep(wait_s)

get_session async

get_session() -> ClientSession

Retrieve or instantiate the underlying aiohttp.ClientSession for the active event loop.

Source code in src/seroverse/client/base.py
async def get_session(self) -> aiohttp.ClientSession:
    """Retrieve or instantiate the underlying `aiohttp.ClientSession` for the active event loop."""
    current_loop = asyncio.get_running_loop()
    if (
        self._session is None
        or self._session.closed
        or getattr(self._session, "_loop", None) is not current_loop
        or getattr(self._session, "_loop", current_loop).is_closed()
    ):
        self._session = aiohttp.ClientSession(
            headers=self._headers(),
            timeout=self.timeout,
        )
    return self._session

close async

close() -> None

Close the underlying aiohttp.ClientSession if open.

Source code in src/seroverse/client/base.py
async def close(self) -> None:
    """Close the underlying `aiohttp.ClientSession` if open."""
    if self._session is not None:
        if not self._session.closed:
            loop = getattr(self._session, "_loop", None)
            if loop is None or not loop.is_closed():
                await self._session.close()
        self._session = None

close_sync

close_sync() -> None

Close the underlying aiohttp.ClientSession synchronously.

Source code in src/seroverse/client/base.py
def close_sync(self) -> None:
    """Close the underlying `aiohttp.ClientSession` synchronously."""
    if self._session is not None and not self._session.closed:
        loop = getattr(self._session, "_loop", None)
        if loop is not None and not loop.is_closed():
            self.run_sync(self.close())
        else:
            self._session = None

request async

request(method: str, endpoint: str, **kwargs: Any) -> Any

Send an async HTTP request and return decoded JSON (via msgspec) or text.

Source code in src/seroverse/client/base.py
async def request(self, method: str, endpoint: str, **kwargs: Any) -> Any:
    """Send an async HTTP request and return decoded JSON (via `msgspec`) or text."""
    session = await self.get_session()
    url = self._url(endpoint)
    async with self._get_semaphore():
        t_req = time.perf_counter()
        try:
            async with session.request(method, url, **kwargs) as resp:
                raw = await resp.read()
                if resp.status >= 400:
                    body = raw.decode("utf-8", errors="replace").strip()
                    reason = resp.reason or "HTTP Error"
                    detail = f"{reason}: {body[:300]}" if body else reason
                    raise aiohttp.ClientResponseError(
                        resp.request_info,
                        resp.history,
                        status=resp.status,
                        message=detail,
                        headers=resp.headers,
                    )
                ctype = (resp.content_type or "").lower()
                if "json" in ctype:
                    return msgspec.json.decode(raw)
                return raw.decode("utf-8", errors="replace")
        finally:
            await self.polite_sleep(t_req)

get async

get(endpoint: str, **kwargs: Any) -> Any

Perform an async HTTP GET request.

Source code in src/seroverse/client/base.py
async def get(self, endpoint: str, **kwargs: Any) -> Any:
    """Perform an async HTTP GET request."""
    return await self.request("GET", endpoint, **kwargs)

post async

post(endpoint: str, **kwargs: Any) -> Any

Perform an async HTTP POST request.

Source code in src/seroverse/client/base.py
async def post(self, endpoint: str, **kwargs: Any) -> Any:
    """Perform an async HTTP POST request."""
    return await self.request("POST", endpoint, **kwargs)

delete async

delete(endpoint: str, **kwargs: Any) -> Any

Perform an async HTTP DELETE request.

Source code in src/seroverse/client/base.py
async def delete(self, endpoint: str, **kwargs: Any) -> Any:
    """Perform an async HTTP DELETE request."""
    return await self.request("DELETE", endpoint, **kwargs)

stream_bytes async

stream_bytes(
    method: str, endpoint: str, **kwargs: Any
) -> bytes

Send an async HTTP request and read raw response bytes.

Source code in src/seroverse/client/base.py
async def stream_bytes(self, method: str, endpoint: str, **kwargs: Any) -> bytes:
    """Send an async HTTP request and read raw response bytes."""
    session = await self.get_session()
    url = self._url(endpoint)
    async with self._get_semaphore():
        t_req = time.perf_counter()
        try:
            async with session.request(method, url, **kwargs) as resp:
                resp.raise_for_status()
                return await resp.read()
        finally:
            await self.polite_sleep(t_req)

get_json async

get_json(
    endpoint: str, *, type_: type[T], **kwargs: Any
) -> T

Fetch endpoint via GET and decode JSON directly into type_ using msgspec.

Source code in src/seroverse/client/base.py
async def get_json[T](self, endpoint: str, *, type_: type[T], **kwargs: Any) -> T:
    """Fetch `endpoint` via GET and decode JSON directly into `type_` using `msgspec`."""
    raw = await self.stream_bytes("GET", endpoint, **kwargs)
    return msgspec.json.decode(raw, type=type_)

post_json async

post_json(
    endpoint: str, *, type_: type[T], **kwargs: Any
) -> T

Fetch endpoint via POST and decode JSON directly into type_ using msgspec.

Source code in src/seroverse/client/base.py
async def post_json[T](self, endpoint: str, *, type_: type[T], **kwargs: Any) -> T:
    """Fetch `endpoint` via POST and decode JSON directly into `type_` using `msgspec`."""
    raw = await self.stream_bytes("POST", endpoint, **kwargs)
    return msgspec.json.decode(raw, type=type_)

stream_buffer async

stream_buffer(
    method: str, endpoint: str, **kwargs: Any
) -> BytesIO

Send an async HTTP request and return an in-memory BytesIO stream.

Source code in src/seroverse/client/base.py
async def stream_buffer(self, method: str, endpoint: str, **kwargs: Any) -> BytesIO:
    """Send an async HTTP request and return an in-memory `BytesIO` stream."""
    b = await self.stream_bytes(method, endpoint, **kwargs)
    return BytesIO(b)

stream_chunks async

stream_chunks(
    method: str,
    endpoint: str,
    chunk_size: int = 65536,
    **kwargs: Any,
) -> AsyncIterator[bytes]

Stream response bytes in chunks as an async generator, emitting progress when Content-Length is known.

Source code in src/seroverse/client/base.py
async def stream_chunks(
    self,
    method: str,
    endpoint: str,
    chunk_size: int = 65536,
    **kwargs: Any,
) -> AsyncIterator[bytes]:
    """Stream response bytes in chunks as an async generator, emitting progress when `Content-Length` is known."""
    session = await self.get_session()
    url = self._url(endpoint)
    prog = current_progress()
    async with session.request(method, url, **kwargs) as resp:
        resp.raise_for_status()
        total_bytes = resp.content_length if resp.content_length and resp.content_length > 0 else None
        received = 0
        async for chunk in resp.content.iter_chunked(chunk_size):
            received += len(chunk)
            if total_bytes is not None and prog.is_active:
                prog.report(
                    min(1.0, received / total_bytes),
                    detail=f"Streaming {endpoint} ({received:,} / {total_bytes:,} B)",
                )
            yield chunk

run_sync

run_sync(coro: Coroutine[Any, Any, T]) -> T

Execute a coroutine synchronously and close any session bound to the temporary event loop.

Source code in src/seroverse/client/base.py
def run_sync[T](self, coro: Coroutine[Any, Any, T]) -> T:
    """Execute a coroutine synchronously and close any session bound to the temporary event loop."""

    async def _runner() -> T:
        try:
            return await coro
        finally:
            await self.close()

    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        loop = None

    if loop and loop.is_running():
        with ThreadPoolExecutor(max_workers=1) as pool:
            future = pool.submit(lambda: asyncio.run(_runner()))
            return future.result()
    return asyncio.run(_runner())

NCBI Entrez & Datasets (seroverse.client.ncbi)

ncbi

NCBI Entrez E-Utilities and NCBI Datasets v2 REST API client and models.

Classes:

Name Description
NuccoreSummary

Immutable NCBI nuccore esummary record.

PubMedArticleId

Article identifier entry inside a PubMed esummary record.

PubMedSummary

Immutable PubMed esummary record.

DatasetAssemblyReport

Immutable NCBI Datasets v2 genome assembly report summary.

NCBIClient

Asynchronous client for NCBI Entrez E-Utilities and NCBI Datasets v2.

NuccoreSummary dataclass

Immutable NCBI nuccore esummary record.

Source code in src/seroverse/client/ncbi.py
@dataclass(frozen=True, slots=True)
class NuccoreSummary:
    """Immutable NCBI `nuccore` `esummary` record."""

    uid: str
    accession_version: str
    title: str = ""
    subname: str = ""
    subtype: str = ""
    slen: int = 0

    @property
    def base_accession(self) -> str:
        return self.accession_version.split(".")[0] if self.accession_version else ""

    @property
    def blob_text(self) -> str:
        return f"{self.title} | {self.subname}"

    @property
    def sub_pairs(self) -> list[tuple[str, str]]:
        return list(zip(self.subtype.split("|"), self.subname.split("|"), strict=False))

PubMedArticleId dataclass

Article identifier entry inside a PubMed esummary record.

Source code in src/seroverse/client/ncbi.py
@dataclass(frozen=True, slots=True)
class PubMedArticleId:
    """Article identifier entry inside a PubMed `esummary` record."""

    idtype: str = ""
    value: str = ""

PubMedSummary dataclass

Immutable PubMed esummary record.

Source code in src/seroverse/client/ncbi.py
@dataclass(frozen=True, slots=True)
class PubMedSummary:
    """Immutable PubMed `esummary` record."""

    uid: str
    title: str = ""
    pubdate: str = ""
    doi: str = ""
    articleids: tuple[PubMedArticleId, ...] = ()

DatasetAssemblyReport dataclass

Immutable NCBI Datasets v2 genome assembly report summary.

Source code in src/seroverse/client/ncbi.py
@dataclass(frozen=True, slots=True)
class DatasetAssemblyReport:
    """Immutable NCBI Datasets v2 genome assembly report summary."""

    accession: str
    organism_name: str = ""
    tax_id: int = 0
    strain: str = ""
    serovar: str = ""
    isolate: str = ""
    biosample_accession: str = ""
    assembly_level: str = ""
    paired_accession: str = ""

NCBIClient

Bases: Client

Asynchronous client for NCBI Entrez E-Utilities and NCBI Datasets v2.

Methods:

Name Description
esearch

Run rate-limited esearch.fcgi and return matching UID strings.

esummary

Run batched esummary.fcgi POST requests and return {uid: info_dict}.

fetch_nuccore_summaries

Fetch NuccoreSummary records for uids, optionally filtering by sequence length slen_range.

fetch_pubmed_summaries

Fetch PubMedSummary records (including DOIs) for pmids.

elink_pubmed_to_nuccore

Map PubMed IDs to linked nuccore UIDs via elink.fcgi (pubmed_nuccore).

efetch_fasta

Download multi-FASTA bytes for uids via efetch.fcgi.

fetch_taxon_assemblies

Query NCBI Datasets v2 genome/taxon/{taxon}/dataset_report and return assembly reports.

download_assembly_fasta

Download genome FASTA bytes for accession via NCBI Datasets v2 archive endpoint.

Source code in src/seroverse/client/ncbi.py
class NCBIClient(Client):
    """Asynchronous client for NCBI Entrez E-Utilities and NCBI Datasets v2."""

    def __init__(
        self,
        api_key: str | None = None,
        *,
        timeout: float = 40.0,
        max_concurrency: int | None = None,
        polite_delay: float = 0.20,
        recorder: Any | None = None,
    ) -> None:
        resolved_key = api_key if api_key is not None else os.environ.get("NCBI_API_KEY", "")
        conc = max_concurrency if max_concurrency is not None else (5 if resolved_key else 3)
        super().__init__(
            base_url=NCBI_EUTILS_URL,
            api_key_header="api-key" if resolved_key else None,
            api_key=resolved_key or None,
            user_agent="seroverse-ncbi-client/0.1",
            timeout=timeout,
            max_concurrency=conc,
            polite_delay=polite_delay,
            recorder=recorder,
        )

    def _eutils_params(self, extra: dict[str, str]) -> dict[str, str]:
        base = {"retmode": "json"}
        if self.api_key:
            base["api_key"] = self.api_key
        base.update(extra)
        return base

    async def esearch(self, db: str, term: str, retmax: int = 100) -> list[str]:
        """Run rate-limited `esearch.fcgi` and return matching UID strings."""
        try:
            params = self._eutils_params({"db": db, "term": term, "retmax": str(retmax)})
            env = await self.get_json("esearch.fcgi", type_=_ESearchEnvelope, params=params)
            if env.esearchresult and env.esearchresult.idlist:
                return [str(u) for u in env.esearchresult.idlist]
        except Exception:
            pass
        return []

    async def esummary(self, db: str, uids: Sequence[str], chunk_size: int = 200) -> dict[str, dict[str, Any]]:
        """Run batched `esummary.fcgi` POST requests and return `{uid: info_dict}`."""
        out: dict[str, dict[str, Any]] = {}
        chunks = [uids[i : i + chunk_size] for i in range(0, len(uids), chunk_size)]
        if not chunks:
            return out
        prog = current_progress()
        completed = 0
        total = len(chunks)

        async def _fetch_chunk(chunk: Sequence[str]) -> None:
            nonlocal completed
            try:
                data = self._eutils_params({"db": db, "id": ",".join(chunk)})
                raw = await self.stream_bytes("POST", "esummary.fcgi", data=data)
                payload = msgspec.json.decode(raw)
                if isinstance(payload, dict) and isinstance(res := payload.get("result"), dict):
                    for u in chunk:
                        if isinstance(info := res.get(u), dict):
                            out[u] = info
            except Exception:
                pass
            finally:
                completed += 1
                if prog.is_active:
                    prog.report(completed / total, detail=f"NCBI esummary ({db}): batch {completed}/{total}")

        await asyncio.gather(*[_fetch_chunk(c) for c in chunks])
        return out

    async def fetch_nuccore_summaries(
        self,
        uids: Sequence[str],
        *,
        slen_range: tuple[int, int] | None = None,
        chunk_size: int = 200,
    ) -> dict[str, NuccoreSummary]:
        """Fetch `NuccoreSummary` records for `uids`, optionally filtering by sequence length `slen_range`."""
        raw_map = await self.esummary("nuccore", uids, chunk_size=chunk_size)
        summaries: dict[str, NuccoreSummary] = {}
        for u, info in raw_map.items():
            slen = int(info.get("slen") or 0)
            if slen_range is not None and not (slen_range[0] <= slen <= slen_range[1]):
                continue
            acc_ver = str(info.get("accessionversion") or info.get("caption") or "").strip()
            if acc_ver:
                summaries[u] = NuccoreSummary(
                    uid=u,
                    accession_version=acc_ver,
                    title=str(info.get("title") or ""),
                    subname=str(info.get("subname") or ""),
                    subtype=str(info.get("subtype") or ""),
                    slen=slen,
                )
        return summaries

    async def fetch_pubmed_summaries(
        self, pmids: Sequence[int | str], chunk_size: int = 150
    ) -> dict[int, PubMedSummary]:
        """Fetch `PubMedSummary` records (including DOIs) for `pmids`."""
        uid_strs = [str(p) for p in pmids]
        raw_map = await self.esummary("pubmed", uid_strs, chunk_size=chunk_size)
        out: dict[int, PubMedSummary] = {}
        for u_s, info in raw_map.items():
            if not u_s.isdigit():
                continue
            aids: list[PubMedArticleId] = []
            doi = ""
            for aid in info.get("articleids") or ():
                if isinstance(aid, dict):
                    idt = str(aid.get("idtype") or "")
                    val = str(aid.get("value") or "").strip()
                    aids.append(PubMedArticleId(idtype=idt, value=val))
                    if idt == "doi" and val and not doi:
                        doi = val
            out[int(u_s)] = PubMedSummary(
                uid=u_s,
                title=str(info.get("title") or ""),
                pubdate=str(info.get("pubdate") or ""),
                doi=doi,
                articleids=tuple(aids),
            )
        return out

    async def elink_pubmed_to_nuccore(self, pmids: Sequence[int], batch_size: int = 40) -> dict[int, list[str]]:
        """Map PubMed IDs to linked `nuccore` UIDs via `elink.fcgi` (`pubmed_nuccore`)."""
        out: dict[int, list[str]] = {}
        batches = [pmids[i : i + batch_size] for i in range(0, len(pmids), batch_size)]
        if not batches:
            return out
        prog = current_progress()
        total = len(batches)
        for idx, batch in enumerate(batches, start=1):
            try:
                params: list[tuple[str, str]] = [
                    ("dbfrom", "pubmed"),
                    ("db", "nuccore"),
                    ("linkname", "pubmed_nuccore"),
                    ("retmode", "json"),
                ]
                if self.api_key:
                    params.append(("api_key", self.api_key))
                for p in batch:
                    params.append(("id", str(p)))
                raw = await self.stream_bytes("GET", "elink.fcgi", params=params)
                payload = msgspec.json.decode(raw)
                if not isinstance(payload, dict):
                    continue
                for lset in payload.get("linksets") or ():
                    if not isinstance(lset, dict):
                        continue
                    ids = lset.get("ids") or ()
                    if not ids:
                        continue
                    pmid = int(ids[0])
                    uids: list[str] = []
                    for ldb in lset.get("linksetdbs") or ():
                        if isinstance(ldb, dict) and ldb.get("linkname") == "pubmed_nuccore":
                            uids.extend(str(x) for x in (ldb.get("links") or ()))
                    if uids:
                        out[pmid] = uids
            except Exception:
                pass
            finally:
                if prog.is_active:
                    prog.report(idx / total, detail=f"NCBI elink: batch {idx}/{total}")
        return out

    async def efetch_fasta(self, uids: Sequence[str], db: str = "nuccore") -> bytes:
        """Download multi-FASTA bytes for `uids` via `efetch.fcgi`."""
        if not uids:
            return b""
        try:
            params = {"db": db, "id": ",".join(uids), "rettype": "fasta", "retmode": "text"}
            if self.api_key:
                params["api_key"] = self.api_key
            return await self.stream_bytes("GET", "efetch.fcgi", params=params)
        except Exception:
            return b""

    async def fetch_taxon_assemblies(self, taxon: str, page_size: int = 1000) -> list[DatasetAssemblyReport]:
        """Query NCBI Datasets v2 `genome/taxon/{taxon}/dataset_report` and return assembly reports."""
        import urllib.parse

        encoded = urllib.parse.quote(taxon)
        url = f"{NCBI_DATASETS_URL}/genome/taxon/{encoded}/dataset_report"
        try:
            raw = await self.stream_bytes("GET", url, params={"page_size": str(page_size)})
            payload = msgspec.json.decode(raw)
        except Exception:
            return []
        if not isinstance(payload, dict):
            return []
        reports: list[DatasetAssemblyReport] = []
        for rep in payload.get("reports") or ():
            if not isinstance(rep, dict):
                continue
            acc = str(rep.get("accession") or "").strip()
            if not acc:
                continue
            org = rep.get("organism") or {}
            infraspec = (org.get("infraspecific_names") or {}) if isinstance(org, dict) else {}
            asm_info = rep.get("assembly_info") or {}
            biosample = (asm_info.get("biosample") or {}) if isinstance(asm_info, dict) else {}
            strain = str(infraspec.get("strain") or biosample.get("strain") or "").strip()
            serovar = str(infraspec.get("serovar") or biosample.get("serovar") or "").strip()
            isolate = str(infraspec.get("isolate") or biosample.get("isolate") or "").strip()
            reports.append(
                DatasetAssemblyReport(
                    accession=acc,
                    organism_name=str(org.get("organism_name") or "").strip() if isinstance(org, dict) else "",
                    tax_id=int(org.get("tax_id") or 0) if isinstance(org, dict) else 0,
                    strain=strain,
                    serovar=serovar,
                    isolate=isolate,
                    biosample_accession=str(biosample.get("accession") or "").strip(),
                    assembly_level=str(asm_info.get("assembly_level") or "").strip()
                    if isinstance(asm_info, dict)
                    else "",
                    paired_accession=str(rep.get("paired_accession") or "").strip(),
                )
            )
        return reports

    async def download_assembly_fasta(self, accession: str) -> bytes:
        """Download genome FASTA bytes for `accession` via NCBI Datasets v2 archive endpoint."""
        url = f"{NCBI_DATASETS_URL}/genome/accession/{accession}/download"
        try:
            zip_bytes = await self.stream_bytes("GET", url, params={"include_annotation_type": "GENOME_FASTA"})
            with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
                fna_chunks = [
                    zf.read(zi.filename) for zi in zf.infolist() if zi.filename.endswith((".fna", ".fasta", ".fa"))
                ]
                return b"\n".join(fna_chunks)
        except Exception:
            return b""

esearch async

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

Run rate-limited esearch.fcgi and return matching UID strings.

Source code in src/seroverse/client/ncbi.py
async def esearch(self, db: str, term: str, retmax: int = 100) -> list[str]:
    """Run rate-limited `esearch.fcgi` and return matching UID strings."""
    try:
        params = self._eutils_params({"db": db, "term": term, "retmax": str(retmax)})
        env = await self.get_json("esearch.fcgi", type_=_ESearchEnvelope, params=params)
        if env.esearchresult and env.esearchresult.idlist:
            return [str(u) for u in env.esearchresult.idlist]
    except Exception:
        pass
    return []

esummary async

esummary(
    db: str, uids: Sequence[str], chunk_size: int = 200
) -> dict[str, dict[str, Any]]

Run batched esummary.fcgi POST requests and return {uid: info_dict}.

Source code in src/seroverse/client/ncbi.py
async def esummary(self, db: str, uids: Sequence[str], chunk_size: int = 200) -> dict[str, dict[str, Any]]:
    """Run batched `esummary.fcgi` POST requests and return `{uid: info_dict}`."""
    out: dict[str, dict[str, Any]] = {}
    chunks = [uids[i : i + chunk_size] for i in range(0, len(uids), chunk_size)]
    if not chunks:
        return out
    prog = current_progress()
    completed = 0
    total = len(chunks)

    async def _fetch_chunk(chunk: Sequence[str]) -> None:
        nonlocal completed
        try:
            data = self._eutils_params({"db": db, "id": ",".join(chunk)})
            raw = await self.stream_bytes("POST", "esummary.fcgi", data=data)
            payload = msgspec.json.decode(raw)
            if isinstance(payload, dict) and isinstance(res := payload.get("result"), dict):
                for u in chunk:
                    if isinstance(info := res.get(u), dict):
                        out[u] = info
        except Exception:
            pass
        finally:
            completed += 1
            if prog.is_active:
                prog.report(completed / total, detail=f"NCBI esummary ({db}): batch {completed}/{total}")

    await asyncio.gather(*[_fetch_chunk(c) for c in chunks])
    return out

fetch_nuccore_summaries async

fetch_nuccore_summaries(
    uids: Sequence[str],
    *,
    slen_range: tuple[int, int] | None = None,
    chunk_size: int = 200,
) -> dict[str, NuccoreSummary]

Fetch NuccoreSummary records for uids, optionally filtering by sequence length slen_range.

Source code in src/seroverse/client/ncbi.py
async def fetch_nuccore_summaries(
    self,
    uids: Sequence[str],
    *,
    slen_range: tuple[int, int] | None = None,
    chunk_size: int = 200,
) -> dict[str, NuccoreSummary]:
    """Fetch `NuccoreSummary` records for `uids`, optionally filtering by sequence length `slen_range`."""
    raw_map = await self.esummary("nuccore", uids, chunk_size=chunk_size)
    summaries: dict[str, NuccoreSummary] = {}
    for u, info in raw_map.items():
        slen = int(info.get("slen") or 0)
        if slen_range is not None and not (slen_range[0] <= slen <= slen_range[1]):
            continue
        acc_ver = str(info.get("accessionversion") or info.get("caption") or "").strip()
        if acc_ver:
            summaries[u] = NuccoreSummary(
                uid=u,
                accession_version=acc_ver,
                title=str(info.get("title") or ""),
                subname=str(info.get("subname") or ""),
                subtype=str(info.get("subtype") or ""),
                slen=slen,
            )
    return summaries

fetch_pubmed_summaries async

fetch_pubmed_summaries(
    pmids: Sequence[int | str], chunk_size: int = 150
) -> dict[int, PubMedSummary]

Fetch PubMedSummary records (including DOIs) for pmids.

Source code in src/seroverse/client/ncbi.py
async def fetch_pubmed_summaries(
    self, pmids: Sequence[int | str], chunk_size: int = 150
) -> dict[int, PubMedSummary]:
    """Fetch `PubMedSummary` records (including DOIs) for `pmids`."""
    uid_strs = [str(p) for p in pmids]
    raw_map = await self.esummary("pubmed", uid_strs, chunk_size=chunk_size)
    out: dict[int, PubMedSummary] = {}
    for u_s, info in raw_map.items():
        if not u_s.isdigit():
            continue
        aids: list[PubMedArticleId] = []
        doi = ""
        for aid in info.get("articleids") or ():
            if isinstance(aid, dict):
                idt = str(aid.get("idtype") or "")
                val = str(aid.get("value") or "").strip()
                aids.append(PubMedArticleId(idtype=idt, value=val))
                if idt == "doi" and val and not doi:
                    doi = val
        out[int(u_s)] = PubMedSummary(
            uid=u_s,
            title=str(info.get("title") or ""),
            pubdate=str(info.get("pubdate") or ""),
            doi=doi,
            articleids=tuple(aids),
        )
    return out
elink_pubmed_to_nuccore(
    pmids: Sequence[int], batch_size: int = 40
) -> dict[int, list[str]]

Map PubMed IDs to linked nuccore UIDs via elink.fcgi (pubmed_nuccore).

Source code in src/seroverse/client/ncbi.py
async def elink_pubmed_to_nuccore(self, pmids: Sequence[int], batch_size: int = 40) -> dict[int, list[str]]:
    """Map PubMed IDs to linked `nuccore` UIDs via `elink.fcgi` (`pubmed_nuccore`)."""
    out: dict[int, list[str]] = {}
    batches = [pmids[i : i + batch_size] for i in range(0, len(pmids), batch_size)]
    if not batches:
        return out
    prog = current_progress()
    total = len(batches)
    for idx, batch in enumerate(batches, start=1):
        try:
            params: list[tuple[str, str]] = [
                ("dbfrom", "pubmed"),
                ("db", "nuccore"),
                ("linkname", "pubmed_nuccore"),
                ("retmode", "json"),
            ]
            if self.api_key:
                params.append(("api_key", self.api_key))
            for p in batch:
                params.append(("id", str(p)))
            raw = await self.stream_bytes("GET", "elink.fcgi", params=params)
            payload = msgspec.json.decode(raw)
            if not isinstance(payload, dict):
                continue
            for lset in payload.get("linksets") or ():
                if not isinstance(lset, dict):
                    continue
                ids = lset.get("ids") or ()
                if not ids:
                    continue
                pmid = int(ids[0])
                uids: list[str] = []
                for ldb in lset.get("linksetdbs") or ():
                    if isinstance(ldb, dict) and ldb.get("linkname") == "pubmed_nuccore":
                        uids.extend(str(x) for x in (ldb.get("links") or ()))
                if uids:
                    out[pmid] = uids
        except Exception:
            pass
        finally:
            if prog.is_active:
                prog.report(idx / total, detail=f"NCBI elink: batch {idx}/{total}")
    return out

efetch_fasta async

efetch_fasta(
    uids: Sequence[str], db: str = "nuccore"
) -> bytes

Download multi-FASTA bytes for uids via efetch.fcgi.

Source code in src/seroverse/client/ncbi.py
async def efetch_fasta(self, uids: Sequence[str], db: str = "nuccore") -> bytes:
    """Download multi-FASTA bytes for `uids` via `efetch.fcgi`."""
    if not uids:
        return b""
    try:
        params = {"db": db, "id": ",".join(uids), "rettype": "fasta", "retmode": "text"}
        if self.api_key:
            params["api_key"] = self.api_key
        return await self.stream_bytes("GET", "efetch.fcgi", params=params)
    except Exception:
        return b""

fetch_taxon_assemblies async

fetch_taxon_assemblies(
    taxon: str, page_size: int = 1000
) -> list[DatasetAssemblyReport]

Query NCBI Datasets v2 genome/taxon/{taxon}/dataset_report and return assembly reports.

Source code in src/seroverse/client/ncbi.py
async def fetch_taxon_assemblies(self, taxon: str, page_size: int = 1000) -> list[DatasetAssemblyReport]:
    """Query NCBI Datasets v2 `genome/taxon/{taxon}/dataset_report` and return assembly reports."""
    import urllib.parse

    encoded = urllib.parse.quote(taxon)
    url = f"{NCBI_DATASETS_URL}/genome/taxon/{encoded}/dataset_report"
    try:
        raw = await self.stream_bytes("GET", url, params={"page_size": str(page_size)})
        payload = msgspec.json.decode(raw)
    except Exception:
        return []
    if not isinstance(payload, dict):
        return []
    reports: list[DatasetAssemblyReport] = []
    for rep in payload.get("reports") or ():
        if not isinstance(rep, dict):
            continue
        acc = str(rep.get("accession") or "").strip()
        if not acc:
            continue
        org = rep.get("organism") or {}
        infraspec = (org.get("infraspecific_names") or {}) if isinstance(org, dict) else {}
        asm_info = rep.get("assembly_info") or {}
        biosample = (asm_info.get("biosample") or {}) if isinstance(asm_info, dict) else {}
        strain = str(infraspec.get("strain") or biosample.get("strain") or "").strip()
        serovar = str(infraspec.get("serovar") or biosample.get("serovar") or "").strip()
        isolate = str(infraspec.get("isolate") or biosample.get("isolate") or "").strip()
        reports.append(
            DatasetAssemblyReport(
                accession=acc,
                organism_name=str(org.get("organism_name") or "").strip() if isinstance(org, dict) else "",
                tax_id=int(org.get("tax_id") or 0) if isinstance(org, dict) else 0,
                strain=strain,
                serovar=serovar,
                isolate=isolate,
                biosample_accession=str(biosample.get("accession") or "").strip(),
                assembly_level=str(asm_info.get("assembly_level") or "").strip()
                if isinstance(asm_info, dict)
                else "",
                paired_accession=str(rep.get("paired_accession") or "").strip(),
            )
        )
    return reports

download_assembly_fasta async

download_assembly_fasta(accession: str) -> bytes

Download genome FASTA bytes for accession via NCBI Datasets v2 archive endpoint.

Source code in src/seroverse/client/ncbi.py
async def download_assembly_fasta(self, accession: str) -> bytes:
    """Download genome FASTA bytes for `accession` via NCBI Datasets v2 archive endpoint."""
    url = f"{NCBI_DATASETS_URL}/genome/accession/{accession}/download"
    try:
        zip_bytes = await self.stream_bytes("GET", url, params={"include_annotation_type": "GENOME_FASTA"})
        with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
            fna_chunks = [
                zf.read(zi.filename) for zi in zf.infolist() if zi.filename.endswith((".fna", ".fasta", ".fa"))
            ]
            return b"\n".join(fna_chunks)
    except Exception:
        return b""

ENA Portal & Browser (seroverse.client.ena)

ena

Asynchronous client for the EMBL-EBI ENA Portal REST API.

Classes:

Name Description
ENASampleRecord

Immutable ENA Portal sample record decoded via msgspec.

ENAAssemblyRecord

Immutable ENA Portal assembly record decoded via msgspec.

ENAClient

Asynchronous client for the EMBL-EBI ENA Portal API.

ENASampleRecord dataclass

Immutable ENA Portal sample record decoded via msgspec.

Source code in src/seroverse/client/ena.py
@dataclass(frozen=True, slots=True)
class ENASampleRecord:
    """Immutable ENA Portal `sample` record decoded via `msgspec`."""

    sample_accession: str = ""
    scientific_name: str = ""
    strain: str = ""
    serotype: str = ""
    serovar: str = ""
    tax_id: str = ""

ENAAssemblyRecord dataclass

Immutable ENA Portal assembly record decoded via msgspec.

Source code in src/seroverse/client/ena.py
@dataclass(frozen=True, slots=True)
class ENAAssemblyRecord:
    """Immutable ENA Portal `assembly` record decoded via `msgspec`."""

    accession: str = ""
    sample_accession: str = ""
    scientific_name: str = ""
    strain: str = ""
    tax_id: str = ""

ENAClient

Bases: Client

Asynchronous client for the EMBL-EBI ENA Portal API.

Methods:

Name Description
search_samples

Query search?result=sample and decode directly into list[ENASampleRecord].

search_assemblies

Query search?result=assembly and decode directly into list[ENAAssemblyRecord].

Source code in src/seroverse/client/ena.py
class ENAClient(Client):
    """Asynchronous client for the EMBL-EBI ENA Portal API."""

    def __init__(
        self,
        *,
        timeout: float = 40.0,
        max_concurrency: int = 4,
        polite_delay: float = 0.05,
        recorder: Any | None = None,
    ) -> None:
        super().__init__(
            base_url=ENA_PORTAL_URL,
            user_agent="seroverse-ena-client/0.1",
            timeout=timeout,
            max_concurrency=max_concurrency,
            polite_delay=polite_delay,
            recorder=recorder,
        )

    async def search_samples(
        self,
        query: str,
        fields: Sequence[str] = ("sample_accession", "scientific_name", "strain", "serotype", "serovar", "tax_id"),
        limit: int = 0,
    ) -> list[ENASampleRecord]:
        """Query `search?result=sample` and decode directly into `list[ENASampleRecord]`."""
        params = {
            "result": "sample",
            "query": query,
            "fields": ",".join(fields),
            "limit": str(limit),
            "format": "json",
        }
        try:
            return await self.get_json("search", type_=list[ENASampleRecord], params=params)
        except Exception:
            return []

    async def search_assemblies(
        self,
        query: str,
        fields: Sequence[str] = ("accession", "sample_accession", "scientific_name", "strain", "tax_id"),
        limit: int = 0,
    ) -> list[ENAAssemblyRecord]:
        """Query `search?result=assembly` and decode directly into `list[ENAAssemblyRecord]`."""
        params = {
            "result": "assembly",
            "query": query,
            "fields": ",".join(fields),
            "limit": str(limit),
            "format": "json",
        }
        try:
            return await self.get_json("search", type_=list[ENAAssemblyRecord], params=params)
        except Exception:
            return []

search_samples async

search_samples(
    query: str,
    fields: Sequence[str] = (
        "sample_accession",
        "scientific_name",
        "strain",
        "serotype",
        "serovar",
        "tax_id",
    ),
    limit: int = 0,
) -> list[ENASampleRecord]

Query search?result=sample and decode directly into list[ENASampleRecord].

Source code in src/seroverse/client/ena.py
async def search_samples(
    self,
    query: str,
    fields: Sequence[str] = ("sample_accession", "scientific_name", "strain", "serotype", "serovar", "tax_id"),
    limit: int = 0,
) -> list[ENASampleRecord]:
    """Query `search?result=sample` and decode directly into `list[ENASampleRecord]`."""
    params = {
        "result": "sample",
        "query": query,
        "fields": ",".join(fields),
        "limit": str(limit),
        "format": "json",
    }
    try:
        return await self.get_json("search", type_=list[ENASampleRecord], params=params)
    except Exception:
        return []

search_assemblies async

search_assemblies(
    query: str,
    fields: Sequence[str] = (
        "accession",
        "sample_accession",
        "scientific_name",
        "strain",
        "tax_id",
    ),
    limit: int = 0,
) -> list[ENAAssemblyRecord]

Query search?result=assembly and decode directly into list[ENAAssemblyRecord].

Source code in src/seroverse/client/ena.py
async def search_assemblies(
    self,
    query: str,
    fields: Sequence[str] = ("accession", "sample_accession", "scientific_name", "strain", "tax_id"),
    limit: int = 0,
) -> list[ENAAssemblyRecord]:
    """Query `search?result=assembly` and decode directly into `list[ENAAssemblyRecord]`."""
    params = {
        "result": "assembly",
        "query": query,
        "fields": ",".join(fields),
        "limit": str(limit),
        "format": "json",
    }
    try:
        return await self.get_json("search", type_=list[ENAAssemblyRecord], params=params)
    except Exception:
        return []

DSMZ BacDive & StrainInfo (seroverse.client.dsmz)

dsmz

DSMZ StrainInfo v2 and BacDive v2 REST API clients and culture-collection models.

Classes:

Name Description
StrainLiterature

Immutable bibliographic entry linked to a StrainInfo culture deposit.

StrainPassport

Consolidated DSMZ StrainInfo v2 passport with culture-collection synonyms, PMIDs, and INSDC accessions.

StrainInfoClient

Asynchronous client for the public DSMZ StrainInfo v2 REST API.

BacDiveClient

Asynchronous client for the DSMZ BacDive v2 REST API.

StrainLiterature dataclass

Immutable bibliographic entry linked to a StrainInfo culture deposit.

Source code in src/seroverse/client/dsmz.py
@dataclass(frozen=True, slots=True)
class StrainLiterature:
    """Immutable bibliographic entry linked to a StrainInfo culture deposit."""

    id: int = 0
    pubmed: int = 0
    doi: str = ""
    title: str = ""
    year: int = 0

StrainPassport dataclass

Consolidated DSMZ StrainInfo v2 passport with culture-collection synonyms, PMIDs, and INSDC accessions.

Methods:

Name Description
from_raw_dict

Parse a raw StrainInfo /v2/data/strain/max/ JSON object into a StrainPassport.

Source code in src/seroverse/client/dsmz.py
@dataclass(frozen=True, slots=True)
class StrainPassport:
    """Consolidated DSMZ StrainInfo v2 passport with culture-collection synonyms, PMIDs, and INSDC accessions."""

    si_id: int
    taxon_name: str = ""
    ncbi_taxid: int = 0
    bacdive_id: int = 0
    type_strain: bool = False
    designations: tuple[str, ...] = ()
    pmids: tuple[int, ...] = ()
    dois: tuple[str, ...] = ()
    sequence_accessions: tuple[str, ...] = ()
    genome_accessions: tuple[str, ...] = ()

    @classmethod
    def from_raw_dict(cls, raw: dict[str, Any]) -> StrainPassport | None:
        """Parse a raw StrainInfo `/v2/data/strain/max/` JSON object into a `StrainPassport`."""
        si_obj = raw.get("strain") if isinstance(raw.get("strain"), dict) else raw
        if not isinstance(si_obj, dict):
            return None
        raw_id = si_obj.get("siID") if si_obj.get("siID") is not None else si_obj.get("id")
        si_id = int(raw_id or 0) if str(raw_id or "").isdigit() else 0
        if si_id <= 0:
            return None

        taxon = si_obj.get("taxon") or {}
        taxon_name = str(taxon.get("name") or "").strip() if isinstance(taxon, dict) else ""
        ncbi_taxid = (
            int(taxon.get("ncbi") or 0) if isinstance(taxon, dict) and str(taxon.get("ncbi") or "").isdigit() else 0
        )
        raw_bd = si_obj.get("bdID") if si_obj.get("bdID") is not None else si_obj.get("bacdive")
        bacdive_id = int(raw_bd or 0) if str(raw_bd or "").isdigit() else 0
        type_strain = bool(si_obj.get("typeStrain") if "typeStrain" in si_obj else si_obj.get("type_strain"))

        des_list: list[str] = []
        rel = si_obj.get("relation") or {}
        if isinstance(rel, dict):
            for c_item in (rel.get("deposit") or ()) or (rel.get("culture") or ()):
                if isinstance(c_item, dict):
                    sn = str(c_item.get("designation") or c_item.get("strain_number") or "").strip()
                    if sn and sn not in des_list:
                        des_list.append(sn)
            for d_item in rel.get("designation") or ():
                sn_d = str(d_item.get("designation") if isinstance(d_item, dict) else d_item or "").strip()
                if sn_d and sn_d not in des_list:
                    des_list.append(sn_d)
        for dep_item in raw.get("deposits") or ():
            if isinstance(dep_item, dict) and (sn_dep := str(dep_item.get("designation") or "").strip()):
                if sn_dep not in des_list:
                    des_list.append(sn_dep)

        pmids: list[int] = []
        dois: list[str] = []
        for lit in si_obj.get("literature") or ():
            if isinstance(lit, dict):
                pm_raw = str(lit.get("pubmed") or "").strip()
                if pm_raw.isdigit() and (pm_int := int(pm_raw)) > 0 and pm_int not in pmids:
                    pmids.append(pm_int)
                doi_raw = str(lit.get("doi") or "").strip()
                if doi_raw and doi_raw not in dois:
                    dois.append(doi_raw)

        seq_accs: list[str] = []
        gen_accs: list[str] = []
        for seq in (si_obj.get("sequence") or ()) or (si_obj.get("sequences") or ()):
            if isinstance(seq, dict) and (acc := str(seq.get("accessionNumber") or seq.get("accession") or "").strip()):
                s_type = str(seq.get("type") or "").lower()
                if "genome" in s_type or acc.startswith(("GCA_", "GCF_")):
                    if acc not in gen_accs:
                        gen_accs.append(acc)
                elif s_type != "rrnaop" and acc not in seq_accs:
                    seq_accs.append(acc)

        return cls(
            si_id=si_id,
            taxon_name=taxon_name,
            ncbi_taxid=ncbi_taxid,
            bacdive_id=bacdive_id,
            type_strain=type_strain,
            designations=tuple(des_list),
            pmids=tuple(pmids),
            dois=tuple(dois),
            sequence_accessions=tuple(seq_accs),
            genome_accessions=tuple(gen_accs),
        )

from_raw_dict classmethod

from_raw_dict(raw: dict[str, Any]) -> StrainPassport | None

Parse a raw StrainInfo /v2/data/strain/max/ JSON object into a StrainPassport.

Source code in src/seroverse/client/dsmz.py
@classmethod
def from_raw_dict(cls, raw: dict[str, Any]) -> StrainPassport | None:
    """Parse a raw StrainInfo `/v2/data/strain/max/` JSON object into a `StrainPassport`."""
    si_obj = raw.get("strain") if isinstance(raw.get("strain"), dict) else raw
    if not isinstance(si_obj, dict):
        return None
    raw_id = si_obj.get("siID") if si_obj.get("siID") is not None else si_obj.get("id")
    si_id = int(raw_id or 0) if str(raw_id or "").isdigit() else 0
    if si_id <= 0:
        return None

    taxon = si_obj.get("taxon") or {}
    taxon_name = str(taxon.get("name") or "").strip() if isinstance(taxon, dict) else ""
    ncbi_taxid = (
        int(taxon.get("ncbi") or 0) if isinstance(taxon, dict) and str(taxon.get("ncbi") or "").isdigit() else 0
    )
    raw_bd = si_obj.get("bdID") if si_obj.get("bdID") is not None else si_obj.get("bacdive")
    bacdive_id = int(raw_bd or 0) if str(raw_bd or "").isdigit() else 0
    type_strain = bool(si_obj.get("typeStrain") if "typeStrain" in si_obj else si_obj.get("type_strain"))

    des_list: list[str] = []
    rel = si_obj.get("relation") or {}
    if isinstance(rel, dict):
        for c_item in (rel.get("deposit") or ()) or (rel.get("culture") or ()):
            if isinstance(c_item, dict):
                sn = str(c_item.get("designation") or c_item.get("strain_number") or "").strip()
                if sn and sn not in des_list:
                    des_list.append(sn)
        for d_item in rel.get("designation") or ():
            sn_d = str(d_item.get("designation") if isinstance(d_item, dict) else d_item or "").strip()
            if sn_d and sn_d not in des_list:
                des_list.append(sn_d)
    for dep_item in raw.get("deposits") or ():
        if isinstance(dep_item, dict) and (sn_dep := str(dep_item.get("designation") or "").strip()):
            if sn_dep not in des_list:
                des_list.append(sn_dep)

    pmids: list[int] = []
    dois: list[str] = []
    for lit in si_obj.get("literature") or ():
        if isinstance(lit, dict):
            pm_raw = str(lit.get("pubmed") or "").strip()
            if pm_raw.isdigit() and (pm_int := int(pm_raw)) > 0 and pm_int not in pmids:
                pmids.append(pm_int)
            doi_raw = str(lit.get("doi") or "").strip()
            if doi_raw and doi_raw not in dois:
                dois.append(doi_raw)

    seq_accs: list[str] = []
    gen_accs: list[str] = []
    for seq in (si_obj.get("sequence") or ()) or (si_obj.get("sequences") or ()):
        if isinstance(seq, dict) and (acc := str(seq.get("accessionNumber") or seq.get("accession") or "").strip()):
            s_type = str(seq.get("type") or "").lower()
            if "genome" in s_type or acc.startswith(("GCA_", "GCF_")):
                if acc not in gen_accs:
                    gen_accs.append(acc)
            elif s_type != "rrnaop" and acc not in seq_accs:
                seq_accs.append(acc)

    return cls(
        si_id=si_id,
        taxon_name=taxon_name,
        ncbi_taxid=ncbi_taxid,
        bacdive_id=bacdive_id,
        type_strain=type_strain,
        designations=tuple(des_list),
        pmids=tuple(pmids),
        dois=tuple(dois),
        sequence_accessions=tuple(seq_accs),
        genome_accessions=tuple(gen_accs),
    )

StrainInfoClient

Bases: Client

Asynchronous client for the public DSMZ StrainInfo v2 REST API.

Methods:

Name Description
search_by_taxon

Return all StrainInfo IDs (SI-IDs) for a species or genus name.

search_by_cc_no

Return matching SI-IDs for a culture collection number (e.g. ATCC 25238, NCTC 9001).

search_by_designation

Return matching SI-IDs for an arbitrary strain designation (e.g. Prague 1550/57).

search_by_collection

Return all SI-IDs deposited in a culture collection (e.g. CNCTC, NCTC, ATCC).

fetch_passports

Batch-fetch StrainPassport records for si_ids via /v2/data/strain/max/{ids}.

fetch_taxon_passports

Search all strains for taxon_name and batch-fetch their complete StrainPassport records.

Source code in src/seroverse/client/dsmz.py
class StrainInfoClient(Client):
    """Asynchronous client for the public DSMZ StrainInfo v2 REST API."""

    def __init__(
        self,
        *,
        timeout: float = 30.0,
        max_concurrency: int = 5,
        polite_delay: float = 0.05,
        recorder: Any | None = None,
    ) -> None:
        super().__init__(
            base_url=STRAININFO_V2_URL,
            user_agent="seroverse-straininfo-client/0.1",
            timeout=timeout,
            max_concurrency=max_concurrency,
            polite_delay=polite_delay,
            recorder=recorder,
        )

    async def _search_ids(self, path: str) -> list[int]:
        try:
            raw_ids = await self.get_json(path, type_=list[int])
            return [int(x) for x in raw_ids if int(x) > 0]
        except Exception:
            return []

    async def search_by_taxon(self, taxon_name: str) -> list[int]:
        """Return all StrainInfo IDs (`SI-ID`s) for a species or genus name."""
        return await self._search_ids(f"search/strain/tax_name/{urllib.parse.quote(taxon_name.strip())}")

    async def search_by_cc_no(self, cc_no: str) -> list[int]:
        """Return matching `SI-ID`s for a culture collection number (e.g. `ATCC 25238`, `NCTC 9001`)."""
        return await self._search_ids(f"search/strain/cc_no/{urllib.parse.quote(cc_no.strip())}")

    async def search_by_designation(self, designation: str) -> list[int]:
        """Return matching `SI-ID`s for an arbitrary strain designation (e.g. `Prague 1550/57`)."""
        return await self._search_ids(f"search/strain/des/{urllib.parse.quote(designation.strip())}")

    async def search_by_collection(self, collection_code: str) -> list[int]:
        """Return all `SI-ID`s deposited in a culture collection (e.g. `CNCTC`, `NCTC`, `ATCC`)."""
        return await self._search_ids(f"search/strain/cc/{urllib.parse.quote(collection_code.strip())}")

    async def fetch_passports(self, si_ids: Sequence[int], batch_size: int = 50) -> list[StrainPassport]:
        """Batch-fetch `StrainPassport` records for `si_ids` via `/v2/data/strain/max/{ids}`."""
        unique_ids = list(dict.fromkeys(int(x) for x in si_ids if int(x) > 0))
        if not unique_ids:
            return []
        passports: list[StrainPassport] = []
        chunks = [unique_ids[i : i + batch_size] for i in range(0, len(unique_ids), batch_size)]
        prog = current_progress()
        completed = 0
        total = len(chunks)

        async def _fetch_chunk(chunk: Sequence[int]) -> list[StrainPassport]:
            nonlocal completed
            joined = ",".join(str(x) for x in chunk)
            try:
                raw = await self.stream_bytes("GET", f"data/strain/max/{joined}")
                items = msgspec.json.decode(raw)
                if isinstance(items, list):
                    return [
                        p
                        for item in items
                        if isinstance(item, dict) and (p := StrainPassport.from_raw_dict(item)) is not None
                    ]
            except Exception:
                pass
            finally:
                completed += 1
                if prog.is_active:
                    prog.report(completed / total, detail=f"StrainInfo passports: batch {completed}/{total}")
            return []

        for res in await asyncio.gather(*[_fetch_chunk(c) for c in chunks]):
            passports.extend(res)
        return passports

    async def fetch_taxon_passports(self, taxon_name: str, batch_size: int = 50) -> list[StrainPassport]:
        """Search all strains for `taxon_name` and batch-fetch their complete `StrainPassport` records."""
        si_ids = await self.search_by_taxon(taxon_name)
        return await self.fetch_passports(si_ids, batch_size=batch_size)

search_by_taxon async

search_by_taxon(taxon_name: str) -> list[int]

Return all StrainInfo IDs (SI-IDs) for a species or genus name.

Source code in src/seroverse/client/dsmz.py
async def search_by_taxon(self, taxon_name: str) -> list[int]:
    """Return all StrainInfo IDs (`SI-ID`s) for a species or genus name."""
    return await self._search_ids(f"search/strain/tax_name/{urllib.parse.quote(taxon_name.strip())}")

search_by_cc_no async

search_by_cc_no(cc_no: str) -> list[int]

Return matching SI-IDs for a culture collection number (e.g. ATCC 25238, NCTC 9001).

Source code in src/seroverse/client/dsmz.py
async def search_by_cc_no(self, cc_no: str) -> list[int]:
    """Return matching `SI-ID`s for a culture collection number (e.g. `ATCC 25238`, `NCTC 9001`)."""
    return await self._search_ids(f"search/strain/cc_no/{urllib.parse.quote(cc_no.strip())}")

search_by_designation async

search_by_designation(designation: str) -> list[int]

Return matching SI-IDs for an arbitrary strain designation (e.g. Prague 1550/57).

Source code in src/seroverse/client/dsmz.py
async def search_by_designation(self, designation: str) -> list[int]:
    """Return matching `SI-ID`s for an arbitrary strain designation (e.g. `Prague 1550/57`)."""
    return await self._search_ids(f"search/strain/des/{urllib.parse.quote(designation.strip())}")

search_by_collection async

search_by_collection(collection_code: str) -> list[int]

Return all SI-IDs deposited in a culture collection (e.g. CNCTC, NCTC, ATCC).

Source code in src/seroverse/client/dsmz.py
async def search_by_collection(self, collection_code: str) -> list[int]:
    """Return all `SI-ID`s deposited in a culture collection (e.g. `CNCTC`, `NCTC`, `ATCC`)."""
    return await self._search_ids(f"search/strain/cc/{urllib.parse.quote(collection_code.strip())}")

fetch_passports async

fetch_passports(
    si_ids: Sequence[int], batch_size: int = 50
) -> list[StrainPassport]

Batch-fetch StrainPassport records for si_ids via /v2/data/strain/max/{ids}.

Source code in src/seroverse/client/dsmz.py
async def fetch_passports(self, si_ids: Sequence[int], batch_size: int = 50) -> list[StrainPassport]:
    """Batch-fetch `StrainPassport` records for `si_ids` via `/v2/data/strain/max/{ids}`."""
    unique_ids = list(dict.fromkeys(int(x) for x in si_ids if int(x) > 0))
    if not unique_ids:
        return []
    passports: list[StrainPassport] = []
    chunks = [unique_ids[i : i + batch_size] for i in range(0, len(unique_ids), batch_size)]
    prog = current_progress()
    completed = 0
    total = len(chunks)

    async def _fetch_chunk(chunk: Sequence[int]) -> list[StrainPassport]:
        nonlocal completed
        joined = ",".join(str(x) for x in chunk)
        try:
            raw = await self.stream_bytes("GET", f"data/strain/max/{joined}")
            items = msgspec.json.decode(raw)
            if isinstance(items, list):
                return [
                    p
                    for item in items
                    if isinstance(item, dict) and (p := StrainPassport.from_raw_dict(item)) is not None
                ]
        except Exception:
            pass
        finally:
            completed += 1
            if prog.is_active:
                prog.report(completed / total, detail=f"StrainInfo passports: batch {completed}/{total}")
        return []

    for res in await asyncio.gather(*[_fetch_chunk(c) for c in chunks]):
        passports.extend(res)
    return passports

fetch_taxon_passports async

fetch_taxon_passports(
    taxon_name: str, batch_size: int = 50
) -> list[StrainPassport]

Search all strains for taxon_name and batch-fetch their complete StrainPassport records.

Source code in src/seroverse/client/dsmz.py
async def fetch_taxon_passports(self, taxon_name: str, batch_size: int = 50) -> list[StrainPassport]:
    """Search all strains for `taxon_name` and batch-fetch their complete `StrainPassport` records."""
    si_ids = await self.search_by_taxon(taxon_name)
    return await self.fetch_passports(si_ids, batch_size=batch_size)

BacDiveClient

Bases: Client

Asynchronous client for the DSMZ BacDive v2 REST API.

Methods:

Name Description
fetch_strain

Fetch a BacDive strain record by its integer bacdive_id.

Source code in src/seroverse/client/dsmz.py
class BacDiveClient(Client):
    """Asynchronous client for the DSMZ BacDive v2 REST API."""

    def __init__(
        self,
        api_key: str | None = None,
        *,
        timeout: float = 30.0,
        max_concurrency: int = 4,
        polite_delay: float = 0.05,
        recorder: Any | None = None,
    ) -> None:
        token = api_key if api_key is not None else os.environ.get("BACDIVE_API_KEY", "")
        super().__init__(
            base_url=BACDIVE_V2_URL,
            api_key_header="Authorization" if token else None,
            api_key=f"Bearer {token}" if token and not token.startswith("Bearer ") else (token or None),
            user_agent="seroverse-bacdive-client/0.1",
            timeout=timeout,
            max_concurrency=max_concurrency,
            polite_delay=polite_delay,
            recorder=recorder,
        )

    async def fetch_strain(self, bacdive_id: int) -> dict[str, Any]:
        """Fetch a BacDive strain record by its integer `bacdive_id`."""
        try:
            res = await self.get(f"fetch/{int(bacdive_id)}")
            return res if isinstance(res, dict) else {}
        except Exception:
            return {}

fetch_strain async

fetch_strain(bacdive_id: int) -> dict[str, Any]

Fetch a BacDive strain record by its integer bacdive_id.

Source code in src/seroverse/client/dsmz.py
async def fetch_strain(self, bacdive_id: int) -> dict[str, Any]:
    """Fetch a BacDive strain record by its integer `bacdive_id`."""
    try:
        res = await self.get(f"fetch/{int(bacdive_id)}")
        return res if isinstance(res, dict) else {}
    except Exception:
        return {}

Pathogenwatch Collections (seroverse.client.pathogenwatch)

pathogenwatch

Pathogenwatch REST API client and collection metadata models.

Classes:

Name Description
PathogenwatchCollection

Dataclass representing a Pathogenwatch collection metadata object.

PathogenwatchCollectionBundle

In-memory collection download bundle from Pathogenwatch.

PathogenwatchClient

Asynchronous and synchronous client for interacting with the Pathogenwatch REST API.

PathogenwatchCollection dataclass

Dataclass representing a Pathogenwatch collection metadata object.

Attributes:

Name Type Description
uuid str

Short UUID identifier of the collection.

name str

Human-readable title of the collection.

id int | str | None

Optional numeric or string internal Pathogenwatch collection ID.

description str | None

Optional collection description text.

size int | None

Total number of genomes in the collection, or None.

organism_id str | None

Taxonomic organism identifier.

organism_name str | None

Human-readable species/organism name.

owner str | None

Ownership tag (e.g., "me" for user-owned collections).

access str | None

Visibility/access level (e.g., "PUBLIC" or "PRIVATE").

binned bool | None

Whether the collection is binned/archived.

created_at str | None

ISO timestamp string when the collection was created.

Methods:

Name Description
from_dict

Instantiates a PathogenwatchCollection from a raw API dictionary payload.

group_choices

Builds grouped selectize choices organized by species/organism (with owned collections first).

Source code in src/seroverse/client/pathogenwatch.py
@dataclass(frozen=True, slots=True)
class PathogenwatchCollection:
    """Dataclass representing a Pathogenwatch collection metadata object.

    Attributes:
        uuid: Short UUID identifier of the collection.
        name: Human-readable title of the collection.
        id: Optional numeric or string internal Pathogenwatch collection ID.
        description: Optional collection description text.
        size: Total number of genomes in the collection, or ``None``.
        organism_id: Taxonomic organism identifier.
        organism_name: Human-readable species/organism name.
        owner: Ownership tag (e.g., ``"me"`` for user-owned collections).
        access: Visibility/access level (e.g., ``"PUBLIC"`` or ``"PRIVATE"``).
        binned: Whether the collection is binned/archived.
        created_at: ISO timestamp string when the collection was created.
    """

    uuid: str
    name: str
    id: int | str | None = None
    description: str | None = None
    size: int | None = None
    organism_id: str | None = None
    organism_name: str | None = None
    owner: str | None = None
    access: str | None = None
    binned: bool | None = None
    created_at: str | None = None

    ORGANISM_TAXID_MAP: ClassVar[dict[str, str]] = {
        "197": "Campylobacter jejuni",
        "210": "Helicobacter pylori",
        "287": "Pseudomonas aeruginosa",
        "470": "Acinetobacter baumannii",
        "485": "Neisseria gonorrhoeae",
        "487": "Neisseria meningitidis",
        "562": "Escherichia coli",
        "573": "Klebsiella pneumoniae",
        "590": "Salmonella",
        "620": "Shigella",
        "666": "Vibrio cholerae",
        "670": "Vibrio parahaemolyticus",
        "1280": "Staphylococcus aureus",
        "1311": "Streptococcus agalactiae",
        "1313": "Streptococcus pneumoniae",
        "1314": "Streptococcus pyogenes",
        "1336": "Streptococcus equi",
        "1351": "Enterococcus faecalis",
        "1352": "Enterococcus faecium",
        "1639": "Listeria monocytogenes",
        "1646": "Renibacterium salmoninarum",
        "1773": "Mycobacterium tuberculosis",
        "28901": "Salmonella enterica",
        "64320": "Zika virus",
        "90370": "Salmonella enterica serovar Typhi",
        "2697049": "SARS-CoV-2",
        "3390273": "Klebsiella pneumoniae species complex",
    }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> PathogenwatchCollection:
        """Instantiates a ``PathogenwatchCollection`` from a raw API dictionary payload."""
        uuid_val = str(data.get("uuid") or data.get("id") or "")
        name_val = str(data.get("name") or "")
        size_val = data.get("size")
        if size_val is None:
            size_val = data.get("genomeCount") if data.get("genomeCount") is not None else data.get("genome_count")
        org_id_raw = data.get("organismId") or data.get("organism_id")
        org_id = str(org_id_raw).strip() if org_id_raw is not None and str(org_id_raw).strip() else None
        org_name_raw = data.get("organismName") or data.get("organism_name")
        org_name = str(org_name_raw).strip() if org_name_raw is not None and str(org_name_raw).strip() else None
        if org_name is None and org_id is not None:
            org_name = cls.ORGANISM_TAXID_MAP.get(org_id)

        return cls(
            uuid=uuid_val,
            name=name_val,
            id=data.get("id"),
            description=data.get("description"),
            size=int(size_val) if size_val is not None else None,
            organism_id=org_id,
            organism_name=org_name,
            owner=data.get("owner"),
            access=data.get("access"),
            binned=data.get("binned"),
            created_at=data.get("createdAt") or data.get("created_at"),
        )

    @property
    def organism_label(self) -> str | None:
        """Returns the resolved human-readable species/organism label for grouping, or ``None``."""
        if self.organism_name and self.organism_name.strip():
            return self.organism_name.strip()
        if self.organism_id and self.organism_id.strip():
            oid = self.organism_id.strip()
            return self.ORGANISM_TAXID_MAP.get(oid, f"Organism ({oid})")
        return None

    @property
    def genome_count(self) -> int | None:
        """Returns the collection genome count."""
        return self.size

    @property
    def is_owned(self) -> bool:
        """Returns whether this collection belongs to the authenticated user (``"me"``)."""
        return self.owner == "me"

    @property
    def display_label(self) -> str:
        """Returns a human-readable selector label including genome count when available."""
        title = self.name.strip() or self.uuid
        if self.size is not None:
            return f"{title} (n={self.size:,})"
        return title

    @staticmethod
    def group_choices(collections: Sequence[PathogenwatchCollection]) -> dict[str, Any]:
        """Builds grouped selectize choices organized by species/organism (with owned collections first)."""
        valid = [c for c in collections if c.uuid and (c.size is None or c.size > 0)]
        by_organism: dict[str, list[PathogenwatchCollection]] = {}
        untyped_owned: dict[str, str] = {}
        untyped_shared: dict[str, str] = {}

        for c in valid:
            org_label = c.organism_label
            if org_label is not None:
                by_organism.setdefault(org_label, []).append(c)
            elif c.is_owned:
                untyped_owned[c.uuid] = c.display_label
            else:
                untyped_shared[c.uuid] = c.display_label

        grouped: dict[str, Any] = {}
        sorted_org_keys = sorted(
            by_organism.keys(),
            key=lambda k: (not any(c.is_owned for c in by_organism[k]), -len(by_organism[k]), k),
        )
        for org_key in sorted_org_keys:
            items = sorted(by_organism[org_key], key=lambda c: (not c.is_owned, c.name.lower()))
            grouped[org_key] = {c.uuid: f"★ {c.display_label}" if c.is_owned else c.display_label for c in items}

        if untyped_owned:
            grouped["My Collections"] = untyped_owned
        if untyped_shared:
            grouped["Public / Shared"] = untyped_shared
        return grouped

organism_label property

organism_label: str | None

Returns the resolved human-readable species/organism label for grouping, or None.

genome_count property

genome_count: int | None

Returns the collection genome count.

is_owned property

is_owned: bool

Returns whether this collection belongs to the authenticated user ("me").

display_label property

display_label: str

Returns a human-readable selector label including genome count when available.

from_dict classmethod

from_dict(data: dict[str, Any]) -> PathogenwatchCollection

Instantiates a PathogenwatchCollection from a raw API dictionary payload.

Source code in src/seroverse/client/pathogenwatch.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> PathogenwatchCollection:
    """Instantiates a ``PathogenwatchCollection`` from a raw API dictionary payload."""
    uuid_val = str(data.get("uuid") or data.get("id") or "")
    name_val = str(data.get("name") or "")
    size_val = data.get("size")
    if size_val is None:
        size_val = data.get("genomeCount") if data.get("genomeCount") is not None else data.get("genome_count")
    org_id_raw = data.get("organismId") or data.get("organism_id")
    org_id = str(org_id_raw).strip() if org_id_raw is not None and str(org_id_raw).strip() else None
    org_name_raw = data.get("organismName") or data.get("organism_name")
    org_name = str(org_name_raw).strip() if org_name_raw is not None and str(org_name_raw).strip() else None
    if org_name is None and org_id is not None:
        org_name = cls.ORGANISM_TAXID_MAP.get(org_id)

    return cls(
        uuid=uuid_val,
        name=name_val,
        id=data.get("id"),
        description=data.get("description"),
        size=int(size_val) if size_val is not None else None,
        organism_id=org_id,
        organism_name=org_name,
        owner=data.get("owner"),
        access=data.get("access"),
        binned=data.get("binned"),
        created_at=data.get("createdAt") or data.get("created_at"),
    )

group_choices staticmethod

group_choices(
    collections: Sequence[PathogenwatchCollection],
) -> dict[str, Any]

Builds grouped selectize choices organized by species/organism (with owned collections first).

Source code in src/seroverse/client/pathogenwatch.py
@staticmethod
def group_choices(collections: Sequence[PathogenwatchCollection]) -> dict[str, Any]:
    """Builds grouped selectize choices organized by species/organism (with owned collections first)."""
    valid = [c for c in collections if c.uuid and (c.size is None or c.size > 0)]
    by_organism: dict[str, list[PathogenwatchCollection]] = {}
    untyped_owned: dict[str, str] = {}
    untyped_shared: dict[str, str] = {}

    for c in valid:
        org_label = c.organism_label
        if org_label is not None:
            by_organism.setdefault(org_label, []).append(c)
        elif c.is_owned:
            untyped_owned[c.uuid] = c.display_label
        else:
            untyped_shared[c.uuid] = c.display_label

    grouped: dict[str, Any] = {}
    sorted_org_keys = sorted(
        by_organism.keys(),
        key=lambda k: (not any(c.is_owned for c in by_organism[k]), -len(by_organism[k]), k),
    )
    for org_key in sorted_org_keys:
        items = sorted(by_organism[org_key], key=lambda c: (not c.is_owned, c.name.lower()))
        grouped[org_key] = {c.uuid: f"★ {c.display_label}" if c.is_owned else c.display_label for c in items}

    if untyped_owned:
        grouped["My Collections"] = untyped_owned
    if untyped_shared:
        grouped["Public / Shared"] = untyped_shared
    return grouped

PathogenwatchCollectionBundle dataclass

In-memory collection download bundle from Pathogenwatch.

Attributes:

Name Type Description
uuid str

Short UUID identifier of the collection.

name str

Resolved collection name.

details dict[str, Any]

Raw collection details dictionary (downloads, trees, organismName).

records tuple[dict[str, Any], ...]

Paginated genome records tuple.

analyses tuple[tuple[str, bytes], ...]

Tuple of (download_type, csv_bytes) pairs in requested order.

metadata_csv bytes | None

Downloaded isolate metadata CSV bytes, or None.

matrix_csv bytes | None

Downloaded pairwise distance matrix CSV bytes, or None.

fallback_newick bytes

First tree Newick byte string from details['trees'] if available.

Source code in src/seroverse/client/pathogenwatch.py
@dataclass(frozen=True, slots=True)
class PathogenwatchCollectionBundle:
    """In-memory collection download bundle from Pathogenwatch.

    Attributes:
        uuid: Short UUID identifier of the collection.
        name: Resolved collection name.
        details: Raw collection details dictionary (`downloads`, `trees`, `organismName`).
        records: Paginated genome records tuple.
        analyses: Tuple of `(download_type, csv_bytes)` pairs in requested order.
        metadata_csv: Downloaded isolate metadata CSV bytes, or `None`.
        matrix_csv: Downloaded pairwise distance matrix CSV bytes, or `None`.
        fallback_newick: First tree Newick byte string from `details['trees']` if available.
    """

    uuid: str
    name: str
    details: dict[str, Any]
    records: tuple[dict[str, Any], ...]
    analyses: tuple[tuple[str, bytes], ...]
    metadata_csv: bytes | None = None
    matrix_csv: bytes | None = None
    fallback_newick: bytes = b""

PathogenwatchClient

Bases: Client

Asynchronous and synchronous client for interacting with the Pathogenwatch REST API.

Methods:

Name Description
get_collections

Fetches collections available to the authenticated account or public collections.

get_collection

Fetches complete metadata and analysis jobs for a specific collection.

get_collection_genomes

Fetches genome analysis records for a collection.

download_distance_matrix

Downloads pairwise genomic distance matrix CSV bytes for a collection.

download_analysis

Downloads discrete analysis CSV bytes (e.g. Kleborate, Kaptive) for a collection.

download_kleborate

Downloads Kleborate analysis CSV bytes for a collection.

download_kaptive

Downloads Kaptive analysis CSV bytes for a collection.

download_metadata

Downloads isolate metadata CSV bytes for a collection.

download_summary

Downloads summary CSV bytes for a collection.

fetch_collection_bundle

Downloads collection details, genomes, analyses, metadata, and distance matrix in memory.

download_collection_files

Downloads discrete collection files to an output directory on disk.

default_analysis_types

Selects the default genotype analysis types from a collection's downloads list.

get_collections_sync

Synchronously fetches collections available to the authenticated account or public collections.

get_collection_sync

Synchronously fetches complete metadata and analysis jobs for a collection.

get_collection_genomes_sync

Synchronously fetches genome analysis records for a collection.

download_distance_matrix_sync

Synchronously downloads the pairwise genomic distance matrix CSV bytes for a collection.

download_analysis_sync

Synchronously downloads discrete analysis CSV bytes for a collection.

download_kleborate_sync

Synchronously downloads Kleborate analysis CSV bytes for a collection.

download_kaptive_sync

Synchronously downloads Kaptive analysis CSV bytes for a collection.

download_metadata_sync

Synchronously downloads isolate metadata CSV bytes for a collection.

download_summary_sync

Synchronously downloads summary CSV bytes for a collection.

download_collection_files_sync

Synchronously downloads collection CSV files to disk.

fetch_collection_bundle_sync

Synchronously downloads a collection bundle in memory.

Source code in src/seroverse/client/pathogenwatch.py
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
class PathogenwatchClient(Client):
    """Asynchronous and synchronous client for interacting with the Pathogenwatch REST API."""

    DEFAULT_BASE_URL: ClassVar[str] = PATHOGENWATCH_API_URL

    def __init__(
        self,
        api_key: str,
        base_url: str = DEFAULT_BASE_URL,
        timeout: float = 30.0,
    ) -> None:
        """Initializes the Pathogenwatch API client."""
        clean_key = api_key.strip() if isinstance(api_key, str) else ""
        if not clean_key:
            raise ValueError("A non-empty Pathogenwatch API key is required.")
        resolved_url = base_url.rstrip("/")
        if not resolved_url.endswith("/api"):
            resolved_url = f"{resolved_url}/api"

        super().__init__(
            base_url=resolved_url,
            api_key_header="X-API-Key",
            api_key=clean_key,
            timeout=timeout,
        )

    async def get_collections(
        self,
        owner: str | None = None,
        exclude: str | None = None,
        binned: bool | None = None,
        limit: int | None = None,
    ) -> list[PathogenwatchCollection]:
        """Fetches collections available to the authenticated account or public collections."""
        params: dict[str, str] = {}
        if owner is not None:
            params["owner"] = owner
        if exclude is not None:
            params["exclude"] = exclude
        if binned is not None:
            params["binned"] = "true" if binned else "false"
        if limit is not None:
            params["limit"] = str(limit)

        res = await self.get("collections/list", params=params or None)
        if isinstance(res, list):
            return [PathogenwatchCollection.from_dict(c) for c in res]
        return []

    async def get_collection(self, uuid: str) -> dict[str, Any]:
        """Fetches complete metadata and analysis jobs for a specific collection."""
        res = await self.get("collections/details", params={"uuid": uuid})
        return res if isinstance(res, dict) else {}

    async def get_collection_genomes(
        self,
        uuid: str,
        share_id: str | None = None,
        page: int = 1,
        all_pages: bool = True,
    ) -> list[dict[str, Any]]:
        """Fetches genome analysis records for a collection."""
        all_records: list[dict[str, Any]] = []
        current_page = page
        prog = current_progress()

        while True:
            params: dict[str, str] = {"uuid": uuid, "page": str(current_page)}
            if share_id:
                params["shareId"] = share_id

            res = await self.get("collections/genomes", params=params)
            if not isinstance(res, dict):
                break

            records = res.get("genomes") or res.get("genome") or []
            all_records.extend(records)

            if not all_pages or not res.get("hasMore") or not records:
                break

            if prog.is_active:
                prog.report(
                    min(0.95, 1.0 - 1.0 / (current_page + 1.0)),
                    detail=f"Fetched page {current_page} ({len(all_records):,} genomes)...",
                )
            current_page += 1

        return all_records

    async def download_distance_matrix(
        self,
        uuid: str,
        matrix_type: str = "difference",
    ) -> bytes:
        """Downloads pairwise genomic distance matrix CSV bytes for a collection."""
        return await self.stream_bytes("GET", f"downloads/matrix/{matrix_type}", params={"uuid": uuid})

    async def download_analysis(
        self,
        uuid: str,
        download_type: str,
        job: str | None = None,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Downloads discrete analysis CSV bytes (e.g. Kleborate, Kaptive) for a collection."""
        if job is None:
            details = await self.get_collection(uuid)
            downloads = details.get("downloads", [])
            for d in downloads:
                if d.get("name", "").lower() == download_type.lower():
                    job = d.get("job")
                    break
            if not job:
                raise ValueError(
                    f"Analysis download '{download_type}' not found in collection '{uuid}'. "
                    f"Available downloads: {[d.get('name') for d in downloads]}"
                )

        if genome_ids is None:
            records = await self.get_collection_genomes(uuid, all_pages=True)
            genome_ids = [str(r["id"]) for r in records if "id" in r]
        if not genome_ids:
            raise ValueError(f"No genomes found for collection '{uuid}'.")

        ids_param = ",".join(genome_ids)
        params: dict[str, str] = {"job": job}
        if prefix:
            params["prefix"] = prefix

        return await self.stream_bytes(
            "POST",
            f"downloads/{download_type.lower()}",
            params=params,
            json={"ids": ids_param},
        )

    async def download_kleborate(
        self,
        uuid: str,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Downloads Kleborate analysis CSV bytes for a collection."""
        return await self.download_analysis(
            uuid=uuid,
            download_type="kleborate",
            genome_ids=genome_ids,
            prefix=prefix,
        )

    async def download_kaptive(
        self,
        uuid: str,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Downloads Kaptive analysis CSV bytes for a collection."""
        return await self.download_analysis(
            uuid=uuid,
            download_type="kaptive",
            genome_ids=genome_ids,
            prefix=prefix,
        )

    async def download_metadata(
        self,
        uuid: str,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Downloads isolate metadata CSV bytes for a collection."""
        if genome_ids is None:
            records = await self.get_collection_genomes(uuid, all_pages=True)
            genome_ids = [str(r["id"]) for r in records if "id" in r]
        if not genome_ids:
            raise ValueError(f"No genomes found for collection '{uuid}'.")

        ids_param = ",".join(genome_ids)
        params: dict[str, str] = {}
        if prefix:
            params["prefix"] = prefix

        return await self.stream_bytes(
            "POST",
            "downloads/metadata",
            params=params or None,
            json={"ids": ids_param},
        )

    async def download_summary(
        self,
        uuid: str,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Downloads summary CSV bytes for a collection."""
        if genome_ids is None:
            records = await self.get_collection_genomes(uuid, all_pages=True)
            genome_ids = [str(r["id"]) for r in records if "id" in r]
        if not genome_ids:
            raise ValueError(f"No genomes found for collection '{uuid}'.")

        ids_param = ",".join(genome_ids)
        params: dict[str, str] = {}
        if prefix:
            params["prefix"] = prefix

        return await self.stream_bytes(
            "POST",
            "downloads/summary",
            params=params or None,
            json={"ids": ids_param},
        )

    async def fetch_collection_bundle(
        self,
        uuid: str,
        *,
        with_distances: bool = True,
        with_metadata: bool = True,
        matrix_type: str = "difference",
        dataset_name: str | None = None,
        strict_distances: bool = False,
        analysis_types: Sequence[str] | str | None = None,
    ) -> PathogenwatchCollectionBundle:
        """Downloads collection details, genomes, analyses, metadata, and distance matrix in memory.

        Args:
            uuid: Short UUID of the collection.
            with_distances: Whether to download the pairwise distance matrix CSV.
            with_metadata: Whether to download spatiotemporal isolate metadata CSV.
            matrix_type: Distance matrix type (``"difference"`` or ``"score"``).
            dataset_name: Optional custom name override. Defaults to the collection title.
            strict_distances: If ``True``, raise if the distance matrix endpoint fails.
                If ``False`` (default), fall back to Newick tree or emit a ``RuntimeWarning``.
            analysis_types: Optional analysis name or sequence of analysis names to fetch.
                When ``None``, automatically selects ``"kleborate"`` if available, else ``"kaptive"``.

        Returns:
            In-memory ``PathogenwatchCollectionBundle`` containing all downloaded CSV byte buffers.
        """
        prog = current_progress()
        prog.report(
            0.05,
            message="Fetching Pathogenwatch collection",
            detail=f"Metadata and isolate list for {uuid}...",
            force=True,
        )

        async def _try_matrix() -> tuple[bytes | None, Exception | None]:
            if not with_distances:
                return None, None
            try:
                return await self.download_distance_matrix(uuid=uuid, matrix_type=matrix_type), None
            except Exception as exc:
                return None, exc

        matrix_task = asyncio.create_task(_try_matrix())
        try:
            with prog.subrange(0.05, 0.25, message="Fetching Pathogenwatch collection"):
                details, records = await asyncio.gather(
                    self.get_collection(uuid),
                    self.get_collection_genomes(uuid, all_pages=True),
                )
        except Exception:
            matrix_task.cancel()
            raise

        resolved_name = dataset_name or str(details.get("name") or "") or f"Pathogenwatch {uuid}"
        genome_ids = [str(r["id"]) for r in records if "id" in r]
        if not genome_ids:
            matrix_task.cancel()
            raise ValueError(f"Collection '{resolved_name}' ({uuid}) contains no genomes.")

        raw_downloads = details.get("downloads", [])
        available_downloads = {
            str(d.get("name", "")).strip().lower(): d.get("job")
            for d in raw_downloads
            if isinstance(d, dict) and d.get("name")
        }

        if analysis_types is None:
            requested_analyses = [a for a in ("kleborate", "kaptive") if a in available_downloads][:1]
        elif isinstance(analysis_types, str):
            clean_a = analysis_types.strip().lower()
            requested_analyses = [clean_a] if clean_a and clean_a in available_downloads else []
        else:
            requested_analyses = []
            for a in analysis_types:
                clean_a = str(a).strip().lower()
                if clean_a and clean_a in available_downloads and clean_a not in requested_analyses:
                    requested_analyses.append(clean_a)

        prog.report(
            0.25,
            message=f"Downloading '{resolved_name}' ({len(genome_ids):,} isolates)",
            detail="Requesting genotypes, metadata, and distance matrix...",
            force=True,
        )
        completed_downloads = 0
        total_downloads = max(1, len(requested_analyses)) + int(with_metadata) + int(with_distances)

        def _bump_download(stage_label: str) -> None:
            nonlocal completed_downloads
            completed_downloads += 1
            frac = 0.25 + 0.75 * (completed_downloads / max(1, total_downloads))
            prog.report(frac, message=f"Downloading '{resolved_name}'", detail=stage_label, force=True)

        async def _fetch_single_analysis(kind: str) -> tuple[str, bytes]:
            raw_bytes = await self.download_analysis(
                uuid=uuid,
                download_type=kind,
                job=available_downloads[kind],
                genome_ids=genome_ids,
            )
            _bump_download(f"Genotype calls ({kind.capitalize()}) downloaded")
            return kind, raw_bytes

        async def _fetch_genotypes() -> list[tuple[str, bytes]]:
            if not requested_analyses:
                _bump_download("No genotype export required")
                return []
            return list(await asyncio.gather(*(_fetch_single_analysis(k) for k in requested_analyses)))

        async def _fetch_meta() -> bytes | None:
            if with_metadata:
                res = await self.download_metadata(uuid=uuid, genome_ids=genome_ids)
                _bump_download("Spatiotemporal metadata downloaded")
                return res
            return None

        trees_list = details.get("trees") or []
        fallback_newick = (
            str(trees_list[0].get("newick", "")).strip().encode("utf-8")
            if (isinstance(trees_list, list) and trees_list and isinstance(trees_list[0], dict))
            else b""
        )

        async def _fetch_distances() -> bytes | None:
            res, exc = await matrix_task
            if not with_distances:
                return None
            if exc is not None:
                if strict_distances:
                    raise exc
                if fallback_newick:
                    _bump_download("Using collection Newick tree for pairwise distances")
                    return None
                warnings.warn(
                    f"Could not download '{matrix_type}' distance matrix for Pathogenwatch collection "
                    f"'{uuid}' ({exc}); proceeding without pairwise distances.",
                    RuntimeWarning,
                    stacklevel=2,
                )
                _bump_download("Distance matrix unavailable (skipped)")
                return None
            _bump_download("Pairwise distance matrix downloaded")
            return res

        geno_items, meta_csv, dist_csv = await asyncio.gather(
            _fetch_genotypes(),
            _fetch_meta(),
            _fetch_distances(),
        )

        if not geno_items and meta_csv is None:
            raise ValueError(f"Collection '{uuid}' has no genotype or metadata exports available.")

        return PathogenwatchCollectionBundle(
            uuid=uuid,
            name=resolved_name,
            details=details,
            records=tuple(records),
            analyses=tuple(geno_items),
            metadata_csv=meta_csv,
            matrix_csv=dist_csv,
            fallback_newick=fallback_newick,
        )

    async def download_collection_files(
        self,
        uuid: str,
        output_dir: str | Path,
        analysis_types: Sequence[str] = ("kleborate", "kaptive"),
        include_metadata: bool = True,
        include_matrix: bool = True,
        matrix_type: str = "difference",
        prefix: str | None = None,
    ) -> dict[str, Path]:
        """Downloads discrete collection files to an output directory on disk."""
        out = Path(output_dir)
        out.mkdir(parents=True, exist_ok=True)
        paths: dict[str, Path] = {}
        prog = current_progress()
        prog.report(0.05, message=f"Downloading collection {uuid}", detail="Fetching collection details...")

        async def _try_matrix_raw() -> bytes | None:
            if not include_matrix:
                return None
            return await self.download_distance_matrix(uuid=uuid, matrix_type=matrix_type)

        details, records, matrix_csv = await asyncio.gather(
            self.get_collection(uuid),
            self.get_collection_genomes(uuid, all_pages=True),
            _try_matrix_raw(),
        )
        genome_ids = [str(r["id"]) for r in records if "id" in r]
        if not genome_ids:
            raise ValueError(f"No genomes found for collection '{uuid}'.")
        available_downloads = {d.get("name", "").lower(): d.get("job") for d in details.get("downloads", [])}

        tasks = []
        task_names = []

        for at in analysis_types:
            at_lower = at.lower()
            if at_lower in available_downloads:
                tasks.append(
                    self.download_analysis(
                        uuid=uuid,
                        download_type=at_lower,
                        job=available_downloads[at_lower],
                        genome_ids=genome_ids,
                        prefix=prefix,
                    )
                )
                task_names.append(at_lower)

        if include_metadata:
            tasks.append(self.download_metadata(uuid=uuid, genome_ids=genome_ids, prefix=prefix))
            task_names.append("metadata")

        prog.report(0.35, message=f"Downloading collection {uuid}", detail="Downloading analysis exports...")
        results = list(await asyncio.gather(*tasks)) if tasks else []
        if include_matrix and matrix_csv is not None:
            task_names.append(f"{matrix_type}_matrix")
            results.append(matrix_csv)

        async def _write_csv(name: str, csv_bytes: bytes) -> tuple[str, Path]:
            fpath = out / f"{name}.csv"
            await asyncio.to_thread(fpath.write_bytes, csv_bytes)
            return name, fpath

        written = await asyncio.gather(*(_write_csv(n, raw) for n, raw in zip(task_names, results, strict=True)))
        for name, fpath in written:
            paths[name] = fpath

        prog.report(1.0, message=f"Downloaded collection {uuid}", detail=f"{len(paths)} files written", force=True)
        return paths

    @staticmethod
    def default_analysis_types(downloads: Sequence[dict[str, Any]]) -> tuple[str, ...]:
        """Selects the default genotype analysis types from a collection's ``downloads`` list."""
        names = [
            str(d["name"]).strip().lower()
            for d in downloads
            if isinstance(d, dict) and d.get("name") and str(d["name"]).strip()
        ]
        if "kleborate" in names:
            return ("kleborate",)
        if "kaptive" in names:
            return ("kaptive",)
        if names:
            return (names[0],)
        return ()

    # Synchronous wrappers
    def get_collections_sync(
        self,
        owner: str | None = None,
        exclude: str | None = None,
        binned: bool | None = None,
        limit: int | None = None,
    ) -> list[PathogenwatchCollection]:
        """Synchronously fetches collections available to the authenticated account or public collections."""
        return self.run_sync(self.get_collections(owner=owner, exclude=exclude, binned=binned, limit=limit))

    def get_collection_sync(self, uuid: str) -> dict[str, Any]:
        """Synchronously fetches complete metadata and analysis jobs for a collection."""
        return self.run_sync(self.get_collection(uuid))

    def get_collection_genomes_sync(
        self,
        uuid: str,
        share_id: str | None = None,
        page: int = 1,
        all_pages: bool = True,
    ) -> list[dict[str, Any]]:
        """Synchronously fetches genome analysis records for a collection."""
        return self.run_sync(self.get_collection_genomes(uuid, share_id=share_id, page=page, all_pages=all_pages))

    def download_distance_matrix_sync(self, uuid: str, matrix_type: str = "difference") -> bytes:
        """Synchronously downloads the pairwise genomic distance matrix CSV bytes for a collection."""
        return self.run_sync(self.download_distance_matrix(uuid, matrix_type=matrix_type))

    def download_analysis_sync(
        self,
        uuid: str,
        download_type: str,
        job: str | None = None,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Synchronously downloads discrete analysis CSV bytes for a collection."""
        return self.run_sync(
            self.download_analysis(
                uuid=uuid,
                download_type=download_type,
                job=job,
                genome_ids=genome_ids,
                prefix=prefix,
            )
        )

    def download_kleborate_sync(
        self,
        uuid: str,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Synchronously downloads Kleborate analysis CSV bytes for a collection."""
        return self.run_sync(self.download_kleborate(uuid=uuid, genome_ids=genome_ids, prefix=prefix))

    def download_kaptive_sync(
        self,
        uuid: str,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Synchronously downloads Kaptive analysis CSV bytes for a collection."""
        return self.run_sync(self.download_kaptive(uuid=uuid, genome_ids=genome_ids, prefix=prefix))

    def download_metadata_sync(
        self,
        uuid: str,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Synchronously downloads isolate metadata CSV bytes for a collection."""
        return self.run_sync(self.download_metadata(uuid=uuid, genome_ids=genome_ids, prefix=prefix))

    def download_summary_sync(
        self,
        uuid: str,
        genome_ids: Sequence[str] | None = None,
        prefix: str | None = None,
    ) -> bytes:
        """Synchronously downloads summary CSV bytes for a collection."""
        return self.run_sync(self.download_summary(uuid=uuid, genome_ids=genome_ids, prefix=prefix))

    def download_collection_files_sync(
        self,
        uuid: str,
        output_dir: str | Path,
        analysis_types: Sequence[str] = ("kleborate", "kaptive"),
        include_metadata: bool = True,
        include_matrix: bool = True,
        matrix_type: str = "difference",
        prefix: str | None = None,
    ) -> dict[str, Path]:
        """Synchronously downloads collection CSV files to disk."""
        return self.run_sync(
            self.download_collection_files(
                uuid=uuid,
                output_dir=output_dir,
                analysis_types=analysis_types,
                include_metadata=include_metadata,
                include_matrix=include_matrix,
                matrix_type=matrix_type,
                prefix=prefix,
            )
        )

    def fetch_collection_bundle_sync(
        self,
        uuid: str,
        *,
        with_distances: bool = True,
        with_metadata: bool = True,
        matrix_type: str = "difference",
        dataset_name: str | None = None,
        strict_distances: bool = False,
        analysis_types: Sequence[str] | str | None = None,
    ) -> PathogenwatchCollectionBundle:
        """Synchronously downloads a collection bundle in memory."""
        return self.run_sync(
            self.fetch_collection_bundle(
                uuid=uuid,
                with_distances=with_distances,
                with_metadata=with_metadata,
                matrix_type=matrix_type,
                dataset_name=dataset_name,
                strict_distances=strict_distances,
                analysis_types=analysis_types,
            )
        )

get_collections async

get_collections(
    owner: str | None = None,
    exclude: str | None = None,
    binned: bool | None = None,
    limit: int | None = None,
) -> list[PathogenwatchCollection]

Fetches collections available to the authenticated account or public collections.

Source code in src/seroverse/client/pathogenwatch.py
async def get_collections(
    self,
    owner: str | None = None,
    exclude: str | None = None,
    binned: bool | None = None,
    limit: int | None = None,
) -> list[PathogenwatchCollection]:
    """Fetches collections available to the authenticated account or public collections."""
    params: dict[str, str] = {}
    if owner is not None:
        params["owner"] = owner
    if exclude is not None:
        params["exclude"] = exclude
    if binned is not None:
        params["binned"] = "true" if binned else "false"
    if limit is not None:
        params["limit"] = str(limit)

    res = await self.get("collections/list", params=params or None)
    if isinstance(res, list):
        return [PathogenwatchCollection.from_dict(c) for c in res]
    return []

get_collection async

get_collection(uuid: str) -> dict[str, Any]

Fetches complete metadata and analysis jobs for a specific collection.

Source code in src/seroverse/client/pathogenwatch.py
async def get_collection(self, uuid: str) -> dict[str, Any]:
    """Fetches complete metadata and analysis jobs for a specific collection."""
    res = await self.get("collections/details", params={"uuid": uuid})
    return res if isinstance(res, dict) else {}

get_collection_genomes async

get_collection_genomes(
    uuid: str,
    share_id: str | None = None,
    page: int = 1,
    all_pages: bool = True,
) -> list[dict[str, Any]]

Fetches genome analysis records for a collection.

Source code in src/seroverse/client/pathogenwatch.py
async def get_collection_genomes(
    self,
    uuid: str,
    share_id: str | None = None,
    page: int = 1,
    all_pages: bool = True,
) -> list[dict[str, Any]]:
    """Fetches genome analysis records for a collection."""
    all_records: list[dict[str, Any]] = []
    current_page = page
    prog = current_progress()

    while True:
        params: dict[str, str] = {"uuid": uuid, "page": str(current_page)}
        if share_id:
            params["shareId"] = share_id

        res = await self.get("collections/genomes", params=params)
        if not isinstance(res, dict):
            break

        records = res.get("genomes") or res.get("genome") or []
        all_records.extend(records)

        if not all_pages or not res.get("hasMore") or not records:
            break

        if prog.is_active:
            prog.report(
                min(0.95, 1.0 - 1.0 / (current_page + 1.0)),
                detail=f"Fetched page {current_page} ({len(all_records):,} genomes)...",
            )
        current_page += 1

    return all_records

download_distance_matrix async

download_distance_matrix(
    uuid: str, matrix_type: str = "difference"
) -> bytes

Downloads pairwise genomic distance matrix CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
async def download_distance_matrix(
    self,
    uuid: str,
    matrix_type: str = "difference",
) -> bytes:
    """Downloads pairwise genomic distance matrix CSV bytes for a collection."""
    return await self.stream_bytes("GET", f"downloads/matrix/{matrix_type}", params={"uuid": uuid})

download_analysis async

download_analysis(
    uuid: str,
    download_type: str,
    job: str | None = None,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Downloads discrete analysis CSV bytes (e.g. Kleborate, Kaptive) for a collection.

Source code in src/seroverse/client/pathogenwatch.py
async def download_analysis(
    self,
    uuid: str,
    download_type: str,
    job: str | None = None,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Downloads discrete analysis CSV bytes (e.g. Kleborate, Kaptive) for a collection."""
    if job is None:
        details = await self.get_collection(uuid)
        downloads = details.get("downloads", [])
        for d in downloads:
            if d.get("name", "").lower() == download_type.lower():
                job = d.get("job")
                break
        if not job:
            raise ValueError(
                f"Analysis download '{download_type}' not found in collection '{uuid}'. "
                f"Available downloads: {[d.get('name') for d in downloads]}"
            )

    if genome_ids is None:
        records = await self.get_collection_genomes(uuid, all_pages=True)
        genome_ids = [str(r["id"]) for r in records if "id" in r]
    if not genome_ids:
        raise ValueError(f"No genomes found for collection '{uuid}'.")

    ids_param = ",".join(genome_ids)
    params: dict[str, str] = {"job": job}
    if prefix:
        params["prefix"] = prefix

    return await self.stream_bytes(
        "POST",
        f"downloads/{download_type.lower()}",
        params=params,
        json={"ids": ids_param},
    )

download_kleborate async

download_kleborate(
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Downloads Kleborate analysis CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
async def download_kleborate(
    self,
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Downloads Kleborate analysis CSV bytes for a collection."""
    return await self.download_analysis(
        uuid=uuid,
        download_type="kleborate",
        genome_ids=genome_ids,
        prefix=prefix,
    )

download_kaptive async

download_kaptive(
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Downloads Kaptive analysis CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
async def download_kaptive(
    self,
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Downloads Kaptive analysis CSV bytes for a collection."""
    return await self.download_analysis(
        uuid=uuid,
        download_type="kaptive",
        genome_ids=genome_ids,
        prefix=prefix,
    )

download_metadata async

download_metadata(
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Downloads isolate metadata CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
async def download_metadata(
    self,
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Downloads isolate metadata CSV bytes for a collection."""
    if genome_ids is None:
        records = await self.get_collection_genomes(uuid, all_pages=True)
        genome_ids = [str(r["id"]) for r in records if "id" in r]
    if not genome_ids:
        raise ValueError(f"No genomes found for collection '{uuid}'.")

    ids_param = ",".join(genome_ids)
    params: dict[str, str] = {}
    if prefix:
        params["prefix"] = prefix

    return await self.stream_bytes(
        "POST",
        "downloads/metadata",
        params=params or None,
        json={"ids": ids_param},
    )

download_summary async

download_summary(
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Downloads summary CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
async def download_summary(
    self,
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Downloads summary CSV bytes for a collection."""
    if genome_ids is None:
        records = await self.get_collection_genomes(uuid, all_pages=True)
        genome_ids = [str(r["id"]) for r in records if "id" in r]
    if not genome_ids:
        raise ValueError(f"No genomes found for collection '{uuid}'.")

    ids_param = ",".join(genome_ids)
    params: dict[str, str] = {}
    if prefix:
        params["prefix"] = prefix

    return await self.stream_bytes(
        "POST",
        "downloads/summary",
        params=params or None,
        json={"ids": ids_param},
    )

fetch_collection_bundle async

fetch_collection_bundle(
    uuid: str,
    *,
    with_distances: bool = True,
    with_metadata: bool = True,
    matrix_type: str = "difference",
    dataset_name: str | None = None,
    strict_distances: bool = False,
    analysis_types: Sequence[str] | str | None = None,
) -> PathogenwatchCollectionBundle

Downloads collection details, genomes, analyses, metadata, and distance matrix in memory.

Parameters:

Name Type Description Default
uuid str

Short UUID of the collection.

required
with_distances bool

Whether to download the pairwise distance matrix CSV.

True
with_metadata bool

Whether to download spatiotemporal isolate metadata CSV.

True
matrix_type str

Distance matrix type ("difference" or "score").

'difference'
dataset_name str | None

Optional custom name override. Defaults to the collection title.

None
strict_distances bool

If True, raise if the distance matrix endpoint fails. If False (default), fall back to Newick tree or emit a RuntimeWarning.

False
analysis_types Sequence[str] | str | None

Optional analysis name or sequence of analysis names to fetch. When None, automatically selects "kleborate" if available, else "kaptive".

None

Returns:

Type Description
PathogenwatchCollectionBundle

In-memory PathogenwatchCollectionBundle containing all downloaded CSV byte buffers.

Source code in src/seroverse/client/pathogenwatch.py
async def fetch_collection_bundle(
    self,
    uuid: str,
    *,
    with_distances: bool = True,
    with_metadata: bool = True,
    matrix_type: str = "difference",
    dataset_name: str | None = None,
    strict_distances: bool = False,
    analysis_types: Sequence[str] | str | None = None,
) -> PathogenwatchCollectionBundle:
    """Downloads collection details, genomes, analyses, metadata, and distance matrix in memory.

    Args:
        uuid: Short UUID of the collection.
        with_distances: Whether to download the pairwise distance matrix CSV.
        with_metadata: Whether to download spatiotemporal isolate metadata CSV.
        matrix_type: Distance matrix type (``"difference"`` or ``"score"``).
        dataset_name: Optional custom name override. Defaults to the collection title.
        strict_distances: If ``True``, raise if the distance matrix endpoint fails.
            If ``False`` (default), fall back to Newick tree or emit a ``RuntimeWarning``.
        analysis_types: Optional analysis name or sequence of analysis names to fetch.
            When ``None``, automatically selects ``"kleborate"`` if available, else ``"kaptive"``.

    Returns:
        In-memory ``PathogenwatchCollectionBundle`` containing all downloaded CSV byte buffers.
    """
    prog = current_progress()
    prog.report(
        0.05,
        message="Fetching Pathogenwatch collection",
        detail=f"Metadata and isolate list for {uuid}...",
        force=True,
    )

    async def _try_matrix() -> tuple[bytes | None, Exception | None]:
        if not with_distances:
            return None, None
        try:
            return await self.download_distance_matrix(uuid=uuid, matrix_type=matrix_type), None
        except Exception as exc:
            return None, exc

    matrix_task = asyncio.create_task(_try_matrix())
    try:
        with prog.subrange(0.05, 0.25, message="Fetching Pathogenwatch collection"):
            details, records = await asyncio.gather(
                self.get_collection(uuid),
                self.get_collection_genomes(uuid, all_pages=True),
            )
    except Exception:
        matrix_task.cancel()
        raise

    resolved_name = dataset_name or str(details.get("name") or "") or f"Pathogenwatch {uuid}"
    genome_ids = [str(r["id"]) for r in records if "id" in r]
    if not genome_ids:
        matrix_task.cancel()
        raise ValueError(f"Collection '{resolved_name}' ({uuid}) contains no genomes.")

    raw_downloads = details.get("downloads", [])
    available_downloads = {
        str(d.get("name", "")).strip().lower(): d.get("job")
        for d in raw_downloads
        if isinstance(d, dict) and d.get("name")
    }

    if analysis_types is None:
        requested_analyses = [a for a in ("kleborate", "kaptive") if a in available_downloads][:1]
    elif isinstance(analysis_types, str):
        clean_a = analysis_types.strip().lower()
        requested_analyses = [clean_a] if clean_a and clean_a in available_downloads else []
    else:
        requested_analyses = []
        for a in analysis_types:
            clean_a = str(a).strip().lower()
            if clean_a and clean_a in available_downloads and clean_a not in requested_analyses:
                requested_analyses.append(clean_a)

    prog.report(
        0.25,
        message=f"Downloading '{resolved_name}' ({len(genome_ids):,} isolates)",
        detail="Requesting genotypes, metadata, and distance matrix...",
        force=True,
    )
    completed_downloads = 0
    total_downloads = max(1, len(requested_analyses)) + int(with_metadata) + int(with_distances)

    def _bump_download(stage_label: str) -> None:
        nonlocal completed_downloads
        completed_downloads += 1
        frac = 0.25 + 0.75 * (completed_downloads / max(1, total_downloads))
        prog.report(frac, message=f"Downloading '{resolved_name}'", detail=stage_label, force=True)

    async def _fetch_single_analysis(kind: str) -> tuple[str, bytes]:
        raw_bytes = await self.download_analysis(
            uuid=uuid,
            download_type=kind,
            job=available_downloads[kind],
            genome_ids=genome_ids,
        )
        _bump_download(f"Genotype calls ({kind.capitalize()}) downloaded")
        return kind, raw_bytes

    async def _fetch_genotypes() -> list[tuple[str, bytes]]:
        if not requested_analyses:
            _bump_download("No genotype export required")
            return []
        return list(await asyncio.gather(*(_fetch_single_analysis(k) for k in requested_analyses)))

    async def _fetch_meta() -> bytes | None:
        if with_metadata:
            res = await self.download_metadata(uuid=uuid, genome_ids=genome_ids)
            _bump_download("Spatiotemporal metadata downloaded")
            return res
        return None

    trees_list = details.get("trees") or []
    fallback_newick = (
        str(trees_list[0].get("newick", "")).strip().encode("utf-8")
        if (isinstance(trees_list, list) and trees_list and isinstance(trees_list[0], dict))
        else b""
    )

    async def _fetch_distances() -> bytes | None:
        res, exc = await matrix_task
        if not with_distances:
            return None
        if exc is not None:
            if strict_distances:
                raise exc
            if fallback_newick:
                _bump_download("Using collection Newick tree for pairwise distances")
                return None
            warnings.warn(
                f"Could not download '{matrix_type}' distance matrix for Pathogenwatch collection "
                f"'{uuid}' ({exc}); proceeding without pairwise distances.",
                RuntimeWarning,
                stacklevel=2,
            )
            _bump_download("Distance matrix unavailable (skipped)")
            return None
        _bump_download("Pairwise distance matrix downloaded")
        return res

    geno_items, meta_csv, dist_csv = await asyncio.gather(
        _fetch_genotypes(),
        _fetch_meta(),
        _fetch_distances(),
    )

    if not geno_items and meta_csv is None:
        raise ValueError(f"Collection '{uuid}' has no genotype or metadata exports available.")

    return PathogenwatchCollectionBundle(
        uuid=uuid,
        name=resolved_name,
        details=details,
        records=tuple(records),
        analyses=tuple(geno_items),
        metadata_csv=meta_csv,
        matrix_csv=dist_csv,
        fallback_newick=fallback_newick,
    )

download_collection_files async

download_collection_files(
    uuid: str,
    output_dir: str | Path,
    analysis_types: Sequence[str] = (
        "kleborate",
        "kaptive",
    ),
    include_metadata: bool = True,
    include_matrix: bool = True,
    matrix_type: str = "difference",
    prefix: str | None = None,
) -> dict[str, Path]

Downloads discrete collection files to an output directory on disk.

Source code in src/seroverse/client/pathogenwatch.py
async def download_collection_files(
    self,
    uuid: str,
    output_dir: str | Path,
    analysis_types: Sequence[str] = ("kleborate", "kaptive"),
    include_metadata: bool = True,
    include_matrix: bool = True,
    matrix_type: str = "difference",
    prefix: str | None = None,
) -> dict[str, Path]:
    """Downloads discrete collection files to an output directory on disk."""
    out = Path(output_dir)
    out.mkdir(parents=True, exist_ok=True)
    paths: dict[str, Path] = {}
    prog = current_progress()
    prog.report(0.05, message=f"Downloading collection {uuid}", detail="Fetching collection details...")

    async def _try_matrix_raw() -> bytes | None:
        if not include_matrix:
            return None
        return await self.download_distance_matrix(uuid=uuid, matrix_type=matrix_type)

    details, records, matrix_csv = await asyncio.gather(
        self.get_collection(uuid),
        self.get_collection_genomes(uuid, all_pages=True),
        _try_matrix_raw(),
    )
    genome_ids = [str(r["id"]) for r in records if "id" in r]
    if not genome_ids:
        raise ValueError(f"No genomes found for collection '{uuid}'.")
    available_downloads = {d.get("name", "").lower(): d.get("job") for d in details.get("downloads", [])}

    tasks = []
    task_names = []

    for at in analysis_types:
        at_lower = at.lower()
        if at_lower in available_downloads:
            tasks.append(
                self.download_analysis(
                    uuid=uuid,
                    download_type=at_lower,
                    job=available_downloads[at_lower],
                    genome_ids=genome_ids,
                    prefix=prefix,
                )
            )
            task_names.append(at_lower)

    if include_metadata:
        tasks.append(self.download_metadata(uuid=uuid, genome_ids=genome_ids, prefix=prefix))
        task_names.append("metadata")

    prog.report(0.35, message=f"Downloading collection {uuid}", detail="Downloading analysis exports...")
    results = list(await asyncio.gather(*tasks)) if tasks else []
    if include_matrix and matrix_csv is not None:
        task_names.append(f"{matrix_type}_matrix")
        results.append(matrix_csv)

    async def _write_csv(name: str, csv_bytes: bytes) -> tuple[str, Path]:
        fpath = out / f"{name}.csv"
        await asyncio.to_thread(fpath.write_bytes, csv_bytes)
        return name, fpath

    written = await asyncio.gather(*(_write_csv(n, raw) for n, raw in zip(task_names, results, strict=True)))
    for name, fpath in written:
        paths[name] = fpath

    prog.report(1.0, message=f"Downloaded collection {uuid}", detail=f"{len(paths)} files written", force=True)
    return paths

default_analysis_types staticmethod

default_analysis_types(
    downloads: Sequence[dict[str, Any]],
) -> tuple[str, ...]

Selects the default genotype analysis types from a collection's downloads list.

Source code in src/seroverse/client/pathogenwatch.py
@staticmethod
def default_analysis_types(downloads: Sequence[dict[str, Any]]) -> tuple[str, ...]:
    """Selects the default genotype analysis types from a collection's ``downloads`` list."""
    names = [
        str(d["name"]).strip().lower()
        for d in downloads
        if isinstance(d, dict) and d.get("name") and str(d["name"]).strip()
    ]
    if "kleborate" in names:
        return ("kleborate",)
    if "kaptive" in names:
        return ("kaptive",)
    if names:
        return (names[0],)
    return ()

get_collections_sync

get_collections_sync(
    owner: str | None = None,
    exclude: str | None = None,
    binned: bool | None = None,
    limit: int | None = None,
) -> list[PathogenwatchCollection]

Synchronously fetches collections available to the authenticated account or public collections.

Source code in src/seroverse/client/pathogenwatch.py
def get_collections_sync(
    self,
    owner: str | None = None,
    exclude: str | None = None,
    binned: bool | None = None,
    limit: int | None = None,
) -> list[PathogenwatchCollection]:
    """Synchronously fetches collections available to the authenticated account or public collections."""
    return self.run_sync(self.get_collections(owner=owner, exclude=exclude, binned=binned, limit=limit))

get_collection_sync

get_collection_sync(uuid: str) -> dict[str, Any]

Synchronously fetches complete metadata and analysis jobs for a collection.

Source code in src/seroverse/client/pathogenwatch.py
def get_collection_sync(self, uuid: str) -> dict[str, Any]:
    """Synchronously fetches complete metadata and analysis jobs for a collection."""
    return self.run_sync(self.get_collection(uuid))

get_collection_genomes_sync

get_collection_genomes_sync(
    uuid: str,
    share_id: str | None = None,
    page: int = 1,
    all_pages: bool = True,
) -> list[dict[str, Any]]

Synchronously fetches genome analysis records for a collection.

Source code in src/seroverse/client/pathogenwatch.py
def get_collection_genomes_sync(
    self,
    uuid: str,
    share_id: str | None = None,
    page: int = 1,
    all_pages: bool = True,
) -> list[dict[str, Any]]:
    """Synchronously fetches genome analysis records for a collection."""
    return self.run_sync(self.get_collection_genomes(uuid, share_id=share_id, page=page, all_pages=all_pages))

download_distance_matrix_sync

download_distance_matrix_sync(
    uuid: str, matrix_type: str = "difference"
) -> bytes

Synchronously downloads the pairwise genomic distance matrix CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
def download_distance_matrix_sync(self, uuid: str, matrix_type: str = "difference") -> bytes:
    """Synchronously downloads the pairwise genomic distance matrix CSV bytes for a collection."""
    return self.run_sync(self.download_distance_matrix(uuid, matrix_type=matrix_type))

download_analysis_sync

download_analysis_sync(
    uuid: str,
    download_type: str,
    job: str | None = None,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Synchronously downloads discrete analysis CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
def download_analysis_sync(
    self,
    uuid: str,
    download_type: str,
    job: str | None = None,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Synchronously downloads discrete analysis CSV bytes for a collection."""
    return self.run_sync(
        self.download_analysis(
            uuid=uuid,
            download_type=download_type,
            job=job,
            genome_ids=genome_ids,
            prefix=prefix,
        )
    )

download_kleborate_sync

download_kleborate_sync(
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Synchronously downloads Kleborate analysis CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
def download_kleborate_sync(
    self,
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Synchronously downloads Kleborate analysis CSV bytes for a collection."""
    return self.run_sync(self.download_kleborate(uuid=uuid, genome_ids=genome_ids, prefix=prefix))

download_kaptive_sync

download_kaptive_sync(
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Synchronously downloads Kaptive analysis CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
def download_kaptive_sync(
    self,
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Synchronously downloads Kaptive analysis CSV bytes for a collection."""
    return self.run_sync(self.download_kaptive(uuid=uuid, genome_ids=genome_ids, prefix=prefix))

download_metadata_sync

download_metadata_sync(
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Synchronously downloads isolate metadata CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
def download_metadata_sync(
    self,
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Synchronously downloads isolate metadata CSV bytes for a collection."""
    return self.run_sync(self.download_metadata(uuid=uuid, genome_ids=genome_ids, prefix=prefix))

download_summary_sync

download_summary_sync(
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes

Synchronously downloads summary CSV bytes for a collection.

Source code in src/seroverse/client/pathogenwatch.py
def download_summary_sync(
    self,
    uuid: str,
    genome_ids: Sequence[str] | None = None,
    prefix: str | None = None,
) -> bytes:
    """Synchronously downloads summary CSV bytes for a collection."""
    return self.run_sync(self.download_summary(uuid=uuid, genome_ids=genome_ids, prefix=prefix))

download_collection_files_sync

download_collection_files_sync(
    uuid: str,
    output_dir: str | Path,
    analysis_types: Sequence[str] = (
        "kleborate",
        "kaptive",
    ),
    include_metadata: bool = True,
    include_matrix: bool = True,
    matrix_type: str = "difference",
    prefix: str | None = None,
) -> dict[str, Path]

Synchronously downloads collection CSV files to disk.

Source code in src/seroverse/client/pathogenwatch.py
def download_collection_files_sync(
    self,
    uuid: str,
    output_dir: str | Path,
    analysis_types: Sequence[str] = ("kleborate", "kaptive"),
    include_metadata: bool = True,
    include_matrix: bool = True,
    matrix_type: str = "difference",
    prefix: str | None = None,
) -> dict[str, Path]:
    """Synchronously downloads collection CSV files to disk."""
    return self.run_sync(
        self.download_collection_files(
            uuid=uuid,
            output_dir=output_dir,
            analysis_types=analysis_types,
            include_metadata=include_metadata,
            include_matrix=include_matrix,
            matrix_type=matrix_type,
            prefix=prefix,
        )
    )

fetch_collection_bundle_sync

fetch_collection_bundle_sync(
    uuid: str,
    *,
    with_distances: bool = True,
    with_metadata: bool = True,
    matrix_type: str = "difference",
    dataset_name: str | None = None,
    strict_distances: bool = False,
    analysis_types: Sequence[str] | str | None = None,
) -> PathogenwatchCollectionBundle

Synchronously downloads a collection bundle in memory.

Source code in src/seroverse/client/pathogenwatch.py
def fetch_collection_bundle_sync(
    self,
    uuid: str,
    *,
    with_distances: bool = True,
    with_metadata: bool = True,
    matrix_type: str = "difference",
    dataset_name: str | None = None,
    strict_distances: bool = False,
    analysis_types: Sequence[str] | str | None = None,
) -> PathogenwatchCollectionBundle:
    """Synchronously downloads a collection bundle in memory."""
    return self.run_sync(
        self.fetch_collection_bundle(
            uuid=uuid,
            with_distances=with_distances,
            with_metadata=with_metadata,
            matrix_type=matrix_type,
            dataset_name=dataset_name,
            strict_distances=strict_distances,
            analysis_types=analysis_types,
        )
    )

Comments