Source code for lcmd_db.client

"""Core dataset loading implementation."""

from __future__ import annotations

import itertools
import json
import logging
from collections.abc import Iterator, Sequence
from pathlib import Path
from urllib.parse import urlencode

import polars as pl
from pydantic import ValidationError

from ._cache import DownloadCache
from ._filters import MoleculeFilters
from ._http import fetch_json
from .dataset._molecules import MoleculeDataset
from .dataset._source import EagerSource
from .exceptions import (
    DatasetNotFoundError,
    DownloadError,
    ResultTooLargeError,
    SchemaError,
)
from .settings import settings
from .types import (
    DataFormat,
    Include,
    MoleculePage,
    SubsetData,
    SubsetMetadata,
    Value,
)

logger = logging.getLogger(__name__)

_MOLECULE_PAGE_SIZE = 100


def _load_dataset_impl(
    subset: str,
    *,
    include: Sequence[Include] = ("molecules", "reactions", "fragments"),
    data_format: DataFormat | str = DataFormat.PARQUET,
    include_structures: bool = False,
    molecule_properties: Sequence[str] | None = None,
    reaction_properties: Sequence[str] | None = None,
    fragment_properties: Sequence[str] | None = None,
    smarts: str | None = None,
    cache_dir: str | Path | None = None,
    force_download: bool = False,
) -> SubsetData:
    """Download (or load from cache) a subset and return its data.

    Args:
        subset: Subset slug to download.
        include: Entity types to include in the download.
        data_format: Serialisation format for entity data files.
        include_structures: Whether to download XYZ structure files.
        molecule_properties: Restrict molecule columns to these properties.
        reaction_properties: Restrict reaction columns to these properties.
        fragment_properties: Restrict fragment columns to these properties.
        smarts: Filter downloaded molecules by SMARTS substructure.
        cache_dir: Override the default cache directory.
        force_download: Re-download even if cached data exists.
    """
    fmt = DataFormat(data_format) if isinstance(data_format, str) else data_format
    include_set = _build_include_set(include, include_structures)
    smarts = smarts.strip() if smarts else None

    url = _build_download_url(
        subset,
        include_set,
        fmt,
        molecule_properties,
        reaction_properties,
        fragment_properties,
        smarts=smarts,
    )

    cache = DownloadCache(Path(cache_dir) if cache_dir is not None else None)
    cache_key = DownloadCache.compute_cache_key(
        subset,
        sorted(include_set),
        fmt.value,
        molecule_properties,
        reaction_properties,
        fragment_properties,
        smarts=smarts,
    )

    data_dir = _download(cache, url, subset, cache_key, force_download)

    result = SubsetData._from_download(
        data_dir=data_dir,
        data_format=fmt,
        molecules=_read_entity(data_dir, "molecules", fmt)
        if "molecules" in include_set
        else None,
        reactions=_read_entity(data_dir, "reactions", fmt)
        if "reactions" in include_set
        else None,
        fragments=_read_entity(data_dir, "fragments", fmt)
        if "fragments" in include_set
        else None,
        metadata=_read_metadata(data_dir),
    )

    if "structures" in include_set and result.molecules is not None:
        result.molecules = _attach_structure_paths(
            result.molecules, data_dir / "structures"
        )

    _trigger_stub_sync(cache)

    return result


[docs] def load_molecules( *, subset: str | None = None, smarts: str | None = None, smiles: str | None = None, molecular_formula: str | None = None, limit: int | None = None, ) -> MoleculeDataset[dict[str, Value]]: """Load molecules from the list endpoint with simple server-side filters. Filters are optional, single-value, and combined with ``AND`` semantics. Pass ``subset`` to restrict the search to one subset; omit it to search across all molecules in the database. Args: subset: Restrict the search to a single subset slug. smarts: SMARTS substructure pattern. smiles: SMILES substring. molecular_formula: Molecular-formula substring. limit: Stop after this many molecules. Passing a ``limit`` also opts out of the unbounded-result safety check. Raises: ResultTooLargeError: When no ``limit`` is given and the query matches more than ``settings.max_molecules`` molecules. Narrow the query, pass ``limit``, or raise ``LCMD_DB_MAX_MOLECULES``. """ if limit is not None and limit < 0: raise ValueError("limit must be non-negative") filters = MoleculeFilters( smarts=smarts, smiles=smiles, molecular_formula=molecular_formula, ) url = _build_molecule_list_url(subset=subset, filters=filters) first_page = _fetch_molecule_page(url) _guard_molecule_count(first_page.count, limit=limit) rows: Iterator[dict[str, object]] = _iter_molecule_rows(first_page) if limit is not None: rows = itertools.islice(rows, limit) return MoleculeDataset(EagerSource(pl.DataFrame(list(rows))))
def _guard_molecule_count(count: int, *, limit: int | None) -> None: """Refuse to stream an unbounded full-table result above the safety ceiling.""" ceiling = settings.max_molecules if limit is not None or ceiling <= 0: return if count > ceiling: raise ResultTooLargeError( f"load_molecules matched {count} molecules, above the safety ceiling " f"of {ceiling}. Narrow the query, pass limit=, or raise the ceiling " f"via the LCMD_DB_MAX_MOLECULES environment variable." )
[docs] def clear_cache( subset: str | None = None, cache_dir: str | Path | None = None, ) -> None: """Remove cached data files. Args: subset: If given, only clear the cache for this subset. Otherwise clear all. cache_dir: Override the default cache directory. """ DownloadCache(Path(cache_dir) if cache_dir is not None else None).clear(subset)
_VALID_INCLUDE: frozenset[str] = frozenset( {"molecules", "reactions", "fragments", "structures"} ) def _build_include_set(include: Sequence[str], include_structures: bool) -> set[str]: invalid = set(include) - _VALID_INCLUDE if invalid: raise ValueError( f"Invalid include values: {sorted(invalid)}. " f"Valid options: {sorted(_VALID_INCLUDE)}" ) result = set(include) if include_structures: result |= {"structures", "molecules"} return result def _build_download_url( subset: str, include_set: set[str], fmt: DataFormat, molecule_properties: Sequence[str] | None, reaction_properties: Sequence[str] | None, fragment_properties: Sequence[str] | None, smarts: str | None = None, ) -> str: params: dict[str, str] = { "include": ",".join(sorted(include_set)), "data_format": fmt.value, } for key, props in [ ("molecule_properties", molecule_properties), ("reaction_properties", reaction_properties), ("fragment_properties", fragment_properties), ]: if props is not None: params[key] = ",".join(props) if smarts: params["smarts"] = smarts return f"{settings.base_url}/api/v1/subsets/{subset}/download/?{urlencode(params)}" def _build_molecule_list_url( *, subset: str | None, filters: MoleculeFilters, page_size: int = _MOLECULE_PAGE_SIZE, page: int | None = None, ) -> str: params = filters.to_query_params() if subset: params["subset"] = subset params["page_size"] = str(page_size) if page is not None: params["page"] = str(page) return f"{settings.base_url}/api/v1/molecules/?{urlencode(params)}" def _fetch_molecule_page(url: str) -> MoleculePage: try: return MoleculePage.model_validate(fetch_json(url)) except ValidationError as exc: raise SchemaError(f"Unexpected molecule list response from {url}") from exc def _iter_molecule_rows(first_page: MoleculePage) -> Iterator[dict[str, object]]: page = first_page while True: yield from page.results next_url = page.next if not next_url: return page = _fetch_molecule_page(next_url) def _download( cache: DownloadCache, url: str, subset: str, cache_key: str, force: bool, ) -> Path: try: return cache.get_or_download(url, subset, cache_key, force=force) except FileNotFoundError as exc: raise DatasetNotFoundError(str(exc)) from exc except Exception as exc: if _is_not_found(exc): raise DatasetNotFoundError(f"Subset '{subset}' not found") from exc raise DownloadError(f"Failed to download dataset: {exc}") from exc def _is_not_found(exc: BaseException) -> bool: """Check if the exception represents an HTTP 404.""" response = getattr(exc, "response", None) if response is not None and getattr(response, "status_code", None) == 404: return True code = getattr(exc, "code", None) if code == 404: return True return False _READERS = { DataFormat.PARQUET: pl.read_parquet, DataFormat.CSV: pl.read_csv, DataFormat.TSV: lambda p: pl.read_csv(p, separator="\t"), DataFormat.XLSX: pl.read_excel, DataFormat.JSON: pl.read_json, } def _read_entity(data_dir: Path, entity: str, fmt: DataFormat) -> pl.DataFrame | None: data_file = data_dir / f"{entity}.{fmt.value}" return _READERS[fmt](data_file) if data_file.exists() else None def _attach_structure_paths(df: pl.DataFrame, structures_dir: Path) -> pl.DataFrame: if not structures_dir.exists(): return df return df.with_columns( pl.col("id") .map_elements( lambda mid: str(structures_dir / f"{mid}.xyz"), return_dtype=pl.Utf8 ) .alias("structure_path") ) def _read_metadata(data_dir: Path) -> SubsetMetadata | None: meta_file = data_dir / "metadata.json" if not meta_file.exists(): return None return SubsetMetadata.model_validate(json.loads(meta_file.read_bytes())) def _trigger_stub_sync(cache: DownloadCache) -> None: if not settings.auto_sync_stubs: return from ._sync import sync_stubs sync_stubs(cache)