Source code for mgnipy.V2.collect.biosampler

from __future__ import annotations

import logging

logger = logging.getLogger(__name__)

import asyncio
from typing import (
    Any,
    Optional,
)

import pandas as pd
from tqdm import tqdm as tqdm_sync
from tqdm.asyncio import tqdm_asyncio

from mgnipy._models.config import MGnipyConfig
from mgnipy._models.constants.CONSTANTS import SupportedEndpoints
from mgnipy._shared_helpers.biosamples_helper import (
    GIVEN_ID as BIOSAMPLES_GIVEN_ID,
    HEADERS as BIOSAMPLES_HEADERS,
    SAMPLE_ID as BIOSAMPLES_SAMPLE_ID,
    URL as BIOSAMPLES_URL,
    aget_biosample_metadata,
    get_biosample_metadata,
)
from mgnipy._shared_helpers.httpx_helpers import init_httpx_client
from mgnipy.emgapi_v2_client.client import AuthenticatedClient, Client
from mgnipy.V2.mgnifier.metadata import MGnifyMetadata
from mgnipy.V2.mixins import CheckpointMixin, ClientManagerMixin

BIOSAMPLES_CONFIG_ADDONS = {"base_url": BIOSAMPLES_URL}


[docs] class BioSampler(CheckpointMixin, ClientManagerMixin): """Fetches BioSamples metadata for a given list of ENA run or sample accessions. BioSampler is designed to retrieve the rich sample metadata from `BioSamples`_ for a list of Run or Sample `ENA`_ accessions. It uses the :func:`~.biosamples_helper.get_biosample_metadata` or :func:`~.biosamples_helper.aget_biosample_metadata` (TODO) function to fetch the metadata for each accession with option to cache the results using the :class:`.CheckpointMixin` to avoid redundant API calls in future runs. Parameters ---------- sample_ids : list of str A list of ENA run or sample accessions for which to fetch the BioSamples metadata. If a run accession then :meth:`~BioSampler.enrich(incl_ena=True)` is required so that the sample accession can be retrieved from ENA first and then passed to a BioSamples API request. config : MGnipyConfig, optional An optional configuration object for MGnipy. If not provided, a default configuration will be used. client : Client or AuthenticatedClient, optional An optional HTTP client for making requests. If not provided, a default client will be initialized using the provided or default configuration. metadata : ResultsHandler, optional An optional :class:`.ResultsHandler` instance to store the enriched metadata. If not provided, a new :class:`~.ResultsHandler` will be created to hold the results. Attributes ---------- all_ids : list of str The complete list of ENA run or sample accessions provided during initialization. metadata : ResultsHandler The enriched metadata as a :class:`~.ResultsHandler` instance. Notes ----- - The :meth:`enrich` method iterates through the list of accessions and fetches their metadata, storing the results in a :class:`~ResultsHandler` instance. - By default there is the option to include ENA metadata in the enrichment process, which can be controlled via the `incl_ena` parameter. If `incl_ena` is False, then only sample accessions will return BioSamples metadata! - The :meth:`aenrich` method is intended to provide an asynchronous version of the enrichment process, but it is currently not implemented. Future updates will include asynchronous fetching of metadata to improve performance for large datasets. - The class is designed to be flexible, allowing users to specify a limit on the number of accessions to enrich in a single run, which is useful for testing or when dealing with large datasets to avoid long runtimes during development. If the limit is set to None, there will be no limit on the number of accessions enriched. - It uses the CheckpointMixin to cache results and avoid redundant API calls. .. _BioSamples: https://www.ebi.ac.uk/biosamples/ .. _ENA: https://www.ebi.ac.uk/ena/browser/home """ def __init__( self, sample_ids: list[str], config: MGnipyConfig = None, metadata: MGnifyMetadata = None, ): self._all_ids: list[str] = sorted(sample_ids) self._metadata: MGnifyMetadata = metadata or MGnifyMetadata() self._results = self._metadata._results # For CheckpointMixin if config: # replace with BIOSAMPLES_URL and BIOSAMPLES_HEADERS self.config: MGnipyConfig = MGnipyConfig.model_validate( config.model_dump() | BIOSAMPLES_CONFIG_ADDONS ) else: self.config = MGnipyConfig(**BIOSAMPLES_CONFIG_ADDONS) self.client: Client | AuthenticatedClient = init_httpx_client( self.config, headers=BIOSAMPLES_HEADERS ) self._resource: SupportedEndpoints = SupportedEndpoints("_custom_endpoint") def __call__( self, sample_ids: list[str], metadata: MGnifyMetadata = None, ) -> "BioSampler": """ Creates a new instance of BioSampler with the specified resource, all_ids, mgnify_metadata, and detail_proxy. This allows for creating a new BioSampler instance with different parameters without modifying the existing instance. """ return BioSampler( sample_ids=sample_ids, config=self.config, metadata=metadata or self._metadata, ) def __repr__(self): return ( f"BioSampler(len(sample_ids)={len(self._all_ids)}, " f"metadata={self._metadata})" ) def __str__(self): return ( f"BioSampler with {len(self._all_ids)} sample_ids. \n" f"Progress: {len(self._metadata)} ids. \n" f"Cache directory: {self.cache_path} \n" ) @property def resource(self) -> str: """For :class:`.CheckpointMixin`""" return self._resource @property def metadata(self) -> MGnifyMetadata: """The enriched metadata as a MGnifyMetadata instance.""" return self._metadata @metadata.setter def metadata(self, value: MGnifyMetadata): if not isinstance(value, MGnifyMetadata): raise ValueError("metadata must be an instance of MGnifyMetadata.") self._metadata = value @property def all_ids(self) -> list[str]: """The list of ENA run or sample accessions set during initialization.""" return self._all_ids @all_ids.setter def all_ids(self, value: list[str]): if not isinstance(value, list) or not all(isinstance(i, str) for i in value): raise ValueError("all_ids must be a list of strings.") self._all_ids = sorted(value) @property def params(self) -> dict[str, Any]: """For :class:`.CheckpointMixin`""" return { "annotator": str(self.__class__), "resource": "biosamples", "given_ids": self.all_ids, } def _iter_leftovers(self) -> list[str]: """Identify and return the list of run accessions that have not yet been enriched. Returns ------- list of str A list of run accessions that are present in :attr:`all_ids` but not in :meth:`.ResultsHandler.get_ids`. """ self.try_load_cache() self._metadata._sync_data() return [ x for x in self.all_ids if x not in self._metadata.get_ids(BIOSAMPLES_SAMPLE_ID) ]
[docs] def enrich( self, limit: Optional[int] = 200, hide_progress: bool = False, incl_ena: bool = False, skip_failed: bool = True, ): """Fetches BioSample metadata for the given run/sample accessions. This method iterates through the list of ENA run or sample accessions provided during initialization and retrieves their corresponding BioSample metadata. The results are stored in the :class:`.ResultsHandler` instance associated with this class. This does not return anything. Parameters ---------- limit : Optional[int], default=200 An optional integer to limit the number of biosamples to enrich. If set to None, there will be no limit on the number of biosamples enriched. hide_progress : bool, default=False Whether to hide the progress bar during enrichment. incl_ena : bool, default=False Whether to include an API call to ENA prior to the BioSamples requeßst. If set to False, only sample accessions will return BioSamples metadata. skip_failed : bool, default=True Whether to skip failed enrichments. If set to True, failed enrichments will be logged and skipped, and a placeholder with the GivenID will be appended to the results (appear as completed in :meth:`.ResultsHandler.get_ids`). If set to False, any failed enrichments will not be appended to the results (appear as still left to do). """ logger.debug( f"Starting enrichment of biosample meta for short description with limit {limit}." ) runs_todo: list[str] = self._iter_leftovers()[:limit] logger.warning( f"Enriching {len(runs_todo)} biosamples. Total samples (with runs enriched): {len(self._metadata.get_ids(BIOSAMPLES_SAMPLE_ID))}. Already enriched: {len(self._metadata)}." ) for count, run in enumerate( tqdm_sync( runs_todo, total=len(self.all_ids), initial=len(self._metadata), desc="Enriching biosamples", disable=hide_progress, ) ): logger.info(f"Enriching biosample {run}. Count: {count}") # get metadata try: bm = get_biosample_metadata( run, client=self.client.get_httpx_client(), incl_ena=incl_ena ) except RuntimeError: self.renew_client() bm = get_biosample_metadata( run, client=self.client.get_httpx_client(), incl_ena=incl_ena ) except Exception as e: logger.error(f"Error occurred while enriching run {run}: {e}") bm = False if isinstance(bm, pd.DataFrame) and not bm.empty: logger.debug( "Enriched biosample metadata is non-empty DataFrame. Appending to results." ) self._metadata.append_result(page_num=1, value=[bm.iloc[0].to_dict()]) self.write_results(1, self._metadata.data) continue if not skip_failed: continue else: logger.error( f"Enrichment for biosample {run} did not return a valid DataFrame. Appending placeholder with GivenID only." ) self._metadata.append_result( page_num=1, value=[{BIOSAMPLES_GIVEN_ID: run}] ) self.write_results(1, self._metadata.data)
[docs] async def aenrich( self, limit: Optional[int] = 200, hide_progress: bool = False, incl_ena: bool = False, skip_failed: bool = False, ) -> None: """Async version of :meth:`enrich`. See :meth:`enrich` for details on parameters and behavior. This is a placeholder and not yet implemented. """ ids_todo: list[str] = self._iter_leftovers()[:limit] logger.warning( f"Enriching {len(ids_todo)} ids. " f"Total runs: {len(self.all_ids)}. " f"Already enriched: {len(self._metadata)}." ) async def _fetch(run: str) -> list[dict[str, Any]]: """Fetches the biosample metadata for a given run accession asynchronously.""" try: logger.debug(f"client: {self.client}") r = await aget_biosample_metadata( run, client=self.client.get_async_httpx_client(), incl_ena=incl_ena ) return r except RuntimeError: self.renew_client() r = await aget_biosample_metadata( run, client=self.client.get_async_httpx_client(), incl_ena=incl_ena ) return r except Exception as e: logger.error(f"Error occurred while enriching run {run}: {e}") return False # to coroutines tasks = [asyncio.create_task(_fetch(run)) for run in ids_todo] for done in tqdm_asyncio.as_completed( tasks, total=len(self.all_ids), initial=len(self._metadata), desc="Enriching biosamples", disable=hide_progress, ): bm = await done if isinstance(bm, pd.DataFrame) and not bm.empty: logger.debug( "(Async) Enriched biosample metadata is non-empty DataFrame. Appending to results." ) self._metadata.append_result(page_num=1, value=[bm.iloc[0].to_dict()]) self.write_results(1, self._metadata.data) continue if not skip_failed: continue else: logger.error("`skip_failed` is not yet implemented for async.") continue