Source code for mgnipy.V2.proxies

from __future__ import annotations

import logging

logger = logging.getLogger(__name__)
import re
from typing import (
    TYPE_CHECKING,
    Any,
    AsyncIterator,
    Callable,
    ClassVar,
    Iterator,
    Literal,
    Optional,
)

import pandas as pd
from tqdm import tqdm as tqdm_sync
from tqdm.asyncio import tqdm_asyncio
from mgnipy._models.constants.CONSTANTS import (
    PipelineVersions,
    SupportedEndpoints,
)
from mgnipy.V2.core import ID_PARAM, MGnifier
from mgnipy.V2.endpoints import (
    BETWEEN_RESOURCE_RELATIONSHIPS,
    PARENT_CHILD_RESOURCES,
    WITHIN_RESOURCE_RELATIONSHIPS,
)
from mgnipy.V2.mixins import ResultsHandler

if TYPE_CHECKING:
    from mgnipy.V2.query_set import QuerySet

ListResource = Literal[
    "biomes",
    "studies",
    "samples",
    "runs",
    "analyses",
    "genomes",
    "assemblies",
    "publications",
    "catalogues",
    "private_studies",
]

DetailResource = Literal[
    "biome",
    "study",
    "sample",
    "run",
    "analysis",
    "genome",
    "assembly",
    "publication",
    "catalogue",
]


[docs] class MGnifyList(MGnifier): """Base class for MGnify list endpoints. Concrete subclasses bind a specific list resource such as studies, samples, or analyses. Calling the proxy returns a new instance with merged filters. """ RESOURCE: ClassVar[Optional[ListResource]] = None def __init__( self, *, config: Optional[dict] = None, params: Optional[dict[str, Any]] = None, **kwargs, ): # Accept accidental "resource" in kwargs, but do not expose it in signature passed_resource = kwargs.pop("resource", None) resolved_resource = self.RESOURCE or passed_resource if resolved_resource is None: raise TypeError( "`resource` is required for base MGnifyList; " "use a concrete subclass like Analyses/Runs/... " f"or pass a resource param: {ListResource!r}" ) if self.RESOURCE is not None and passed_resource not in ( None, self.RESOURCE, ): raise ValueError( f"Conflicting resource: expected {self.RESOURCE!r}, got {passed_resource!r}" ) super().__init__( resource=resolved_resource, params=params, config=config, **kwargs, ) self.child_resource: str = PARENT_CHILD_RESOURCES.get(self.resource, None) self._collected_details: dict[str, "MGnifyDetail"] = {} self._collected_details_results: dict[str, dict] = {} self._collected_details_downloads: dict[str, list[dict[str, Any]]] = {} def __call__(self, **kwargs) -> "MGnifyList": """Return a cloned list proxy with updated parameters. Parameters ---------- **kwargs Query parameters to merge into the current parameter set. If ``params`` is supplied, it replaces the current parameters before the remaining keyword arguments are merged in. Returns ------- MGnifyList A new proxy instance with the same resource and updated filters. Examples -------- >>> from mgnipy.V2.proxies import Studies >>> studies = Studies(params={"search": "gut"}, config={}) # doctest: +SKIP >>> studies(search="soil") # doctest: +SKIP """ params = kwargs.pop("params", None) or {} # Merge with params, giving precedence to kwargs params.update(kwargs) return self.__class__(config=self.config, params=params) def __len__(self) -> int: """Return the number of child details based on results. Examples -------- >>> from mgnipy.V2.proxies import Studies # doctest: +SKIP >>> studies = Studies(config={}) # doctest: +SKIP >>> len(studies) # doctest: +SKIP """ return len(self.results_ids or []) def _reset_detail_iterator(self) -> None: """ Initialize or reset the internal state for iterating over MGnifyDetails """ # if refresh: # try: # self.exec.first() # except Exception: # pass self._detail_ids = list(self.results_ids or []) self._detail_index = 0 self._last_successful_detail = None
[docs] def get_detail( self, ) -> Optional["MGnifyDetail"]: """ Get the next MGnifyDetail based on current _detail_index. Updates `_last_successful_detail` on success. Returns ------- MGnifyDetail or None The next detail proxy, or None if no more details to iterate. Example ------- >>> from mgnipy.V2.proxies import Studies # doctest: +SKIP >>> studies = Studies(search="tomato") # doctest: +SKIP >>> studies.bulk_fetch() # doctest: +SKIP >>> first_detail = studies.get_detail() # doctest: +SKIP >>> second_detail = studies.get_detail() # doctest: +SKIP """ if not hasattr(self, "_detail_ids"): self._reset_detail_iterator() if self._detail_index >= len(self._detail_ids): # nothing left to iter return None # otherwise return next MGnifyDetail in the list the_id = self._detail_ids[self._detail_index] logger.debug( f"Fetching detail for {self.child_resource!r} with id {the_id!r} (index {self._detail_index})" ) child = self._single_detail(the_id) # update counters self._detail_index += 1 self._last_successful_detail = self._detail_index - 1 return child.page(1)
[docs] async def aget_detail(self) -> "MGnifyDetail": """ Async variant of `get_detail`. Returns ------- MGnifyDetail or None The next detail proxy, or None if no more details to iterate. """ if not hasattr(self, "_detail_ids"): self._reset_detail_iterator() if self._detail_index >= len(self._detail_ids): return None the_id = self._detail_ids[self._detail_index] child = await self._asingle_detail(the_id) self._detail_index += 1 self._last_successful_detail = self._detail_index - 1 return await child.apage(1)
[docs] def continue_detail_iterator( self, start_index: Optional[int] = None ) -> "MGnifyList": """ Continue iterating for MGnifyDetails from `start_index` or the next index after the last successful detail. Parameters ---------- start_index : int, optional The index to continue from. If None, will continue from the next index after the last successful detail, or 0 if no successful detail yet. Returns ------- MGnifyList The current instance with the detail iterator reset to the specified index. """ # ensure the detail ids are initialized if not hasattr(self, "_detail_ids"): self._reset_detail_iterator() # if start_index is not provided if start_index is None: # if no successful detail yet, start from the beginning if getattr(self, "_last_successful_detail", None) is None: start_index = 0 # otherwise continue from the next index after the last successful detail else: start_index = self._last_successful_detail + 1 # validate start_index if not (0 <= start_index <= len(self._detail_ids)): raise ValueError("start_index out of range") self._detail_index = start_index return self
[docs] def resume_detail_iterator(self) -> "MGnifyList": """ Resume from the element after the last successful MGnifyDetail fetch. Returns ------- MGnifyList The current instance with the detail iterator reset to the specified index. Raises ------ RuntimeError If there is no last successful detail to resume from. """ if getattr(self, "_last_successful_detail", None) is None: raise RuntimeError("No last successful detail to resume from") return self.continue_detail_iterator(self._last_successful_detail + 1)
@property def _detail_endpoint(self) -> Callable: """ Return the endpoint module for the child/detail endpoint. Returns ------- Callable The endpoint function or module used by the child resource. E.g., mgnipy.emgapi_v2_client.studies.get_study_detail Examples -------- >>> from mgnipy.V2.proxies import Studies >>> studies = Studies() >>> studies._detail_endpoint <module 'mgnipy.emgapi_v2_client.api.studies.get_mgnify_study' from ...mgnipy/emgapi_v2_client/api/studies/get_mgnify_study.py'> """ # check if len(self.list_relationships()) == 0: raise AttributeError(f"{self.resource} does not have any linked resources.") # quick check assert ( len(self.list_relationships()) == 1 and self.child_resource.value == self.list_relationships()[0] ), ( "Should only be be parent to detail endpoint: " f"{self.child_resource!r}, but got {self.list_relationships()[0]!r}" ) detail_endpoint = WITHIN_RESOURCE_RELATIONSHIPS[self.resource][ self.child_resource ] return detail_endpoint @property def iter_details(self) -> Iterator[dict]: """ Yield MGnifyDetail results one by one. Returns ------- Iterator[dict] An iterator that yields MGnifyDetail results one by one, fetched on demand. Examples -------- >>> from mgnipy.V2.proxies import Studies # doctest: +SKIP >>> studies = Studies() # doctest: +SKIP >>> result_dict = next(studies.iter_details) # doctest: +SKIP """ for acc in self.results_ids or []: yield self._single_detail(acc).page(1) @property async def aiter_details(self) -> AsyncIterator[dict]: """ Async version of iter_details. Returns ------- AsyncIterator[dict] An async iterator that yields MGnifyDetail results one by one, fetched on demand. """ for acc in self.results_ids or []: child = await self._asingle_detail(acc) yield await child.apage(1) def _single_detail( self, key: str | int, ) -> "MGnifyDetail": """ Get detail proxy for a specific accession/pubmed_id/catalogue_id. Parameters ---------- key : str | int The identifier for the detail resource, or an integer index to look up the identifier from results_ids. Returns ------- MGnifyDetail A proxy for the child/detail for the given key Examples ------- sample = samples._single_detail(id="MGYS00001234")}) """ # get the child detail class e.g. SampleDetail for "samples" list resource detail_cls = V2_ENDPOINT_DETAIL_PROXIES.get(self.child_resource)( config=self.config ) if not detail_cls: raise ValueError( f"Unsupported child resource for detail: {self.child_resource}" ) logger.debug( f"Got detail class {detail_cls} for child resource {self.child_resource!r}" ) # prep id param for given resource e.g. {"accession": "MGYS00001234"} or {"biome_lineage": "root"} custom_id_param_key = detail_cls.id_param_key id_param = self._resolve_id_param(key, param_name=custom_id_param_key) resolved_id = id_param[custom_id_param_key] logger.debug(f"Resolved id param for detail: {id_param}") # init detail proxy with id param child = detail_cls.filter(**id_param) logger.debug(f"Initialized detail proxy {child} with params {child.params!r}") # set endpoint module (might not be necessary actually) # child.endpoint_module = self._detail_endpoint # cache detail data mem self._collected_details_results[resolved_id] = child.page(1) self._collected_details[resolved_id] = child self._collected_details_downloads[resolved_id] = child.downloads return child async def _asingle_detail( self, key: int | str, ) -> "QuerySet": """ Async version of _single_detail. Get MGnifyDetail for a specific accession/pubmed_id/catalogue_id. Parameters ---------- key : int | str The identifier for the detail resource, or an integer index to look up the identifier from results_ids. Examples ------- sample = await samples._asingle_detail({"accession": "MGYS00001234"}) """ detail_cls = V2_ENDPOINT_DETAIL_PROXIES.get(self.child_resource)( config=self.config ) logger.debug( f"Got detail class {detail_cls} for child resource {self.child_resource!r}" ) if not detail_cls: raise ValueError( f"Unsupported child resource for detail: {self.child_resource}" ) custom_id_param_key = detail_cls.id_param_key id_param = self._resolve_id_param(key, param_name=custom_id_param_key) resolved_id = id_param[custom_id_param_key] logger.debug(f"Resolved id param for detail: {id_param}") child = detail_cls.filter(**id_param) child.endpoint_module = self._detail_endpoint # cache detail data mem self._collected_details_results[resolved_id] = await child.apage(1) self._collected_details[resolved_id] = child self._collected_details_downloads[resolved_id] = child.downloads return child @property def details(self) -> list[MGnifyDetail]: return self._collected_details @property def _details_handler(self) -> ResultsHandler: """Internal property to get a ResultsHandler for the collected details results.""" return ResultsHandler(list(self._collected_details_results.values())) @property def details_results(self) -> list[dict[str, Any]]: """A list of detail results dicts for the detail, extracted from the details results.""" return self._details_handler.to_list()
[docs] def details_df(self, *args, **kwargs) -> pd.DataFrame: """ Convert the current or provided metadata to a pandas DataFrame. Parameters ---------- data : list of dict, optional List of records to convert. If ``None``, uses :pyattr:`data`. expand_nested_dicts : list of str or bool, optional List of keys to expand into separate columns, or ``True`` to expand defaults. rename_columns : dict of str to str, optional A dictionary mapping old column names to new column names. **kwargs Additional keyword arguments passed to ``pd.DataFrame``. Returns ------- pd.DataFrame or None DataFrame containing the metadata or ``None`` when no data is available. """ return self._details_handler.to_df(*args, **kwargs)
@property def details_ids(self) -> list[str]: """A list of detail identifiers (e.g. accessions) extracted from the details results.""" ids = list(self._collected_details.keys()) if len(ids) == 0: logger.warning( "Did you run `enrich_details`? No details collected yet; details_ids is empty." ) return ids @property def details_downloads(self) -> list[dict[str, Any]] | None: return [ item for sublist in self._collected_details_downloads.values() for item in sublist ] def __getitem__(self, key: int | str) -> "MGnifyDetail": """ Allow index or accession-based access to child details. Default is not lazy and will fetch immediately, but can be configured to return proxies without fetching. """ return self._single_detail(key)
[docs] def page_size(self, n: int) -> "QuerySet": """ Set the page size for paginated API calls. Parameters ---------- n : int Returns ------- QuerySet A new QuerySet instance with the updated page size parameter. """ if not isinstance(n, int) or n <= 0: raise ValueError("Page size must be a positive integer.") # make a copy of current instance new_qs = self._clone(page_size=n) return new_qs
[docs] def enrich_details(self, limit: Optional[int] = 200, hide_progress: bool = False): """ Gets the details for each mgnify list item. Iterates through the accessions/ids (`.results_ids`) and retrieves their details using the corresponding detail proxy (e.g., `RunDetail` for `Runs`). Parameters ---------- limit : Optional[int], default=200 An optional integer to limit the number of runs to enrich. If not provided, it defaults to 200. If set to None, there will be no limit on the number of runs enriched. hide_progress : bool, default=False A boolean flag to control the display of the progress bar. If set to True, the progress bar will be hidden. Returns ------- None This method does not return anything. It updates the internal state of the MGnifyList instance by populating the `.details` `.details_df` and `.details_results` with the details of each item. """ logger.debug( f"Starting enrichment of {self.child_resource} details with limit {limit}." ) if self.results_ids is None: logger.warning("No results_ids found to enrich details.") return details_todo: list[str] = [ x for x in self.results_ids if x not in self._collected_details_results ][:limit] for count, detail_id in enumerate( tqdm_sync( details_todo, total=len(self.results_ids), initial=len(self._collected_details_results), desc=f"Enriching {self.child_resource} details", disable=hide_progress, ) ): logger.info(f"Enriching detail {detail_id}. Count: {count}") # get detail self._single_detail(detail_id)
[docs] async def aenrich_details( self, limit: Optional[int] = 200, hide_progress: bool = False ): """ Async version of `enrich_details` that retrieves details for each item in the MGnifyList asynchronously. Parameters ---------- limit : Optional[int], default=200 An optional integer to limit the number of items to enrich. If not provided, it defaults to 200. If set to None, there will be no limit on the number of items enriched. hide_progress : bool, default=False A boolean flag to control the display of the progress bar. If set to True, the progress bar will be hidden. Returns ------- None This method does not return anything. It updates the internal state of the MGnifyList instance by populating the `.details` `.details_df` and `.details_results` with the details of each item. """ logger.debug( f"Starting async enrichment of {self.child_resource} details with limit {limit}." ) details_todo: list[str] = [ x for x in self.results_ids if x not in self._collected_details_results ][:limit] logging.debug( f"Number of details to enrich: {len(details_todo)}. First: {details_todo[0]}" ) logging.debug( f"Enriching details for {len(details_todo)} items asynchronously." ) tasks = [self._asingle_detail(identifier) for identifier in details_todo] for done in tqdm_asyncio.as_completed( tasks, total=len(self.results_ids), initial=len(self._collected_details_results), desc=f"Enriching {self.child_resource} details", disable=hide_progress, ): await done
[docs] class MGnifyDetail(MGnifier): RESOURCE: ClassVar[Optional[DetailResource]] = None def __init__( self, id: str, config: Optional[dict] = None, **kwargs, ): passed_resource = kwargs.pop("resource", None) resolved_resource = self.RESOURCE or passed_resource if resolved_resource is None: raise TypeError( "`resource` is required for base MGnifyDetail; " "init a concrete subclass like Biome/Study/Sample... " f"or pass as a resource param: {DetailResource!r}" ) if self.RESOURCE is not None and passed_resource not in ( None, self.RESOURCE, ): raise ValueError( f"Conflicting resource: expected {self.RESOURCE!r}, got {passed_resource!r}" ) try: id_param_key = ID_PARAM[SupportedEndpoints.validate(resolved_resource)] except Exception: id_param_key = None logger.debug( f"Resolved id param key for {resolved_resource!r}: {id_param_key!r}" ) # init MGnifier without id first super().__init__( resource=resolved_resource, config=config, **kwargs, **{id_param_key: id}, ) # then add it to param # self._params.update({self.id_param_key: id}) def _clone(self, **param_overrides) -> "MGnifyDetail": """ Overriding QuerySet._clone to handle accession/id extraction and proper initialization of detail proxies. Parameters ---------- **param_overrides Keyword arguments representing the parameters to override in the new instance. These will be merged with the existing parameters, with the provided overrides taking precedence. Returns ------- MGnifyDetail A new instance of the same class with the updated parameters. """ merged_params = {**self.params, **param_overrides} # rm resource if acci passed merged_params.pop("resource", None) # Extract id from params for detail resources detail_id = merged_params.pop(self.id_param_key, None) new_qs = self.__class__( id=detail_id, config=self.config, params=merged_params, ) new_qs.endpoint_module = self.endpoint_module return new_qs def _next_rel_module(self, name: str) -> SupportedEndpoints: """ Get the next resource name based on the relationship name """ if name in self.list_relationships(): return BETWEEN_RESOURCE_RELATIONSHIPS[self.resource][ SupportedEndpoints.validate(name) ] raise AttributeError(f"{self.resource} does not have linked resource: {name!r}") def __getattr__(self, name: str): # if is a supported relationship if name in self.list_relationships(): return self.get_list( resource=name, fetch=True, explain=False, ) # if not a supported attr then raise error raise AttributeError( f"{self.__class__.__name__} object has no attribute {name!r}." ) @property def downloads(self) -> list[dict[str, Any]]: """ A list of download information dicts for the detail, extracted from the details results. Each dict is updated with the identifier of the detail. The identifier key is determined by the id_param_key of the detail class, e.g. "accession" for studies, samples, runs, analyses, genomes, assemblies; "biome_lineage" for biomes; "pubmed_id" for publications; "catalogue_id" for catalogues. """ if not self.results: logger.debug( "No results found for detail; cannot extract downloads. Returning empty list." ) return [] if "downloads" not in self.to_df().columns: logger.debug( "Details DataFrame does not have 'downloads' column. Returning empty list." ) return [] logger.debug( f"Updating download info with identifier {self.identifier!r} to id_param_key {self.id_param_key!r}" ) # updates the dicts with the id from the index, maybe pipeline_version if available for _, row in self.to_df().iterrows(): # get downloads list from row downloads_list = row["downloads"] # get pipeline_version from row if avail, i.e., analysisdetail if "pipeline_version" in row and isinstance(row["pipeline_version"], str): a_pipe = row["pipeline_version"].lower().strip("v") else: a_pipe = None # for each downlaod dict, add id and pipeline_version for each_download in downloads_list: # keep id each_download.update({self.id_param_key: self.identifier}) # now pipe from download_group? v_group = re.search( r"\.v(\d+(?:\.\d+)?)", each_download.get("download_group", ""), re.IGNORECASE, ).group(1) pipe = v_group or a_pipe if pipe is not None: try: pipe = PipelineVersions(float(pipe)).name except Exception as e: logger.debug( f"Could not parse pipeline version from {pipe!r} for download {each_download!r}: {e}" ) each_download.update({"pipeline_version": pipe}) return [ item for sublist in self.to_df()["downloads"].values for item in sublist ] @property def identifier(self) -> Optional[str]: """Get the identifier value from the query parameters. Used for constructing URLs to related resources. Returns ------- str or None The identifier value, or ``None`` if not set. Examples -------- >>> from mgnipy.V2.core import MGnifier # doctest: +SKIP >>> query = MGnifier("studies", accession="MGYS000000001", config={}) # doctest: +SKIP >>> query.identifier # doctest: +SKIP """ try: return self.params[self.id_param_key] except KeyError: raise AttributeError( f"Identifier key '{self.id_param_key}' not found in parameters for resource '{self.resource}'." ) from None
[docs] def get_list( self, resource: ListResource, *, fetch: bool = True, explain: bool = False, ) -> "MGnifyList": """ Get list proxy for a specific accession/pubmed_id/catalogue_id detail. Parameters ---------- resource : str Valid child resource name e.g. in list_relationships(), such as "samples" for a study detail, or "analyses" for a run detail. fetch : bool Whether to immediately fetch the detail after creating the proxy. explain : bool Whether to print example URLs that would be called. Returns ------- MGnifyList A proxy for the next resource. Examples ------- samples = study.get_list("samples", fetch=False) """ # get related MGnifyList class for the resource, e.g. Samples for "samples" logger.debug( f"Given resource: {resource}, {SupportedEndpoints.validate(resource)!r}" ) proxy_cls = V2_ENDPOINT_LIST_PROXIES.get(SupportedEndpoints.validate(resource))( config=self.config ) logger.debug(f"Getting proxy class {proxy_cls!r} for resource {resource!r}") logger.debug( f"Resolving id param for identifier {self.identifier!r} with id_param_key {self.id_param_key!r}" ) # prep access param e.g. {"accession": "MGYS00001234"} or {"biome_lineage": "root"} id_param = self._resolve_id_param(self.identifier) logger.debug(f"Resolved access param for list proxy: {id_param}") # init list endpoint list_endpoint = proxy_cls.filter(**id_param) logger.debug( f"Set endpoint module for list proxy: {list_endpoint.endpoint_module} with params {list_endpoint.params!r}" ) list_endpoint.endpoint_module = self._next_rel_module(resource) # extra auto if explain: list_endpoint.explain() if fetch: list_endpoint.bulk_fetch() return list_endpoint
[docs] async def aget_list( self, resource: ListResource, *, fetch: bool = True, explain: bool = False, ) -> "MGnifyList": """ Get list proxy for a specific accession/pubmed_id/catalogue_id detail. Parameters ---------- resource : str Valid list resource name e.g. in list_relationships(), such as "samples" for a study detail, or "analyses" for a run detail. fetch : bool Whether to immediately fetch the detail after creating the proxy. explain : bool Whether to print example URLs that would be called. Returns ------- MGnifyList A proxy for the next resource. Examples ------- samples = await study.aget_list("samples", fetch=False) """ logger.debug( f"Given resource: {resource}, {SupportedEndpoints.validate(resource)!r}" ) proxy_cls = V2_ENDPOINT_LIST_PROXIES.get(SupportedEndpoints.validate(resource))( config=self.config ) logger.debug(f"Getting proxy class {proxy_cls!r} for resource {resource!r}") logger.debug( f"Resolving id param for identifier {self.identifier!r} with id_param_key {self.id_param_key!r}" ) id_param = self._resolve_id_param(self.identifier) logger.debug(f"Resolved access param for list proxy: {id_param}") # init list endpoint list_endpoint = proxy_cls.filter(**id_param) logger.debug( f"Set endpoint module for list proxy: {list_endpoint.endpoint_module} with params {list_endpoint.params!r}" ) list_endpoint.endpoint_module = self._next_rel_module(resource) if explain: list_endpoint.explain() if fetch: await list_endpoint.abulk_fetch() return list_endpoint
# import concrete proxy classes from sibling modules. These imports occur # after the base `MGnifyList`/`MGnifyDetail` classes are defined to avoid # circular imports: concrete modules import the base classes from this # package during their import. from .analyses import Analyses, AnalysisDetail from .assemblies import Assemblies, AssemblyDetail from .biomes import BiomeDetail, Biomes from .catalogues import CatalogueDetail, Catalogues from .genomes import GenomeDetail, Genomes from .publications import PublicationDetail, Publications from .runs import RunDetail, Runs from .samples import SampleDetail, Samples from .studies import PrivateStudies, Studies, StudyDetail V2_ENDPOINT_LIST_PROXIES = { SupportedEndpoints.ANALYSES: Analyses, SupportedEndpoints.RUNS: Runs, SupportedEndpoints.SAMPLES: Samples, SupportedEndpoints.STUDIES: Studies, SupportedEndpoints.BIOMES: Biomes, SupportedEndpoints.ASSEMBLIES: Assemblies, SupportedEndpoints.GENOMES: Genomes, SupportedEndpoints.PUBLICATIONS: Publications, SupportedEndpoints.CATALOGUES: Catalogues, SupportedEndpoints.PRIVATE_STUDIES: PrivateStudies, } V2_ENDPOINT_DETAIL_PROXIES = { SupportedEndpoints.ANALYSIS: AnalysisDetail, SupportedEndpoints.RUN: RunDetail, SupportedEndpoints.SAMPLE: SampleDetail, SupportedEndpoints.STUDY: StudyDetail, SupportedEndpoints.BIOME: BiomeDetail, SupportedEndpoints.ASSEMBLY: AssemblyDetail, SupportedEndpoints.GENOME: GenomeDetail, SupportedEndpoints.PUBLICATION: PublicationDetail, SupportedEndpoints.CATALOGUE: CatalogueDetail, }