Source code for mgnipy.V2.query_set

import logging

logger = logging.getLogger(__name__)
import os
from copy import deepcopy
from itertools import chain
from pathlib import Path
from typing import Any, Callable, Literal, Optional

from mgnipy._models.config import MGnipyConfig, to_mgnipy_config
from mgnipy._models.constants.CONSTANTS import SupportedEndpoints
from mgnipy._shared_helpers.validators import validate_gt_int
from mgnipy.V2.describe import DescribeEmgapiModule
from mgnipy.V2.endpoints import RESOURCES_ALL_ENDPOINTS
from mgnipy.V2.mixins import DiskCheckpointer


[docs] class QuerySet: """ Builds and stores the current state of a query, including the resource type, parameters, and results. """ def __init__( self, resource: Literal[ "biomes", "biome", "studies", "study", "samples", "sample", "runs", "run", "genomes", "genome", "analyses", "analysis", "assemblies", "assembly", ], *, config: Optional[MGnipyConfig] = None, params: Optional[dict[str, Any]] = None, **param_kwargs, ): logger.debug("Initializing QuerySet for resource %s", resource) # attribute initialization self._resource: SupportedEndpoints = SupportedEndpoints.validate(resource) self.count: Optional[int] = None self.num_requests: Optional[int] = None self._results: dict[int, list[dict]] = None self._params: dict[str, Any] = params or {} # add param_kwargs to params if provided, prioritizing param_kwargs if param_kwargs: self._params.update(param_kwargs) # handlers # for emgapi_v2_client self.emgapi_handler: DescribeEmgapiModule = DescribeEmgapiModule( endpoint_module=RESOURCES_ALL_ENDPOINTS[self._resource] ) # configuration and auth init self.config: MGnipyConfig = to_mgnipy_config(config) # interactive auth? if os.getenv("MGNIPY_AUTHENTICATION_OFF") == "1": logger.debug( "Authentication disabled e.g. for docs build. Set env MGNIPY_AUTHENTICATION_OFF=0 to enable authentication." ) elif self.emgapi_handler.is_private: logger.debug( f"Endpoint module {self.emgapi_handler.endpoint_module.__name__} corresponds to a private endpoint. Authentication will be required." ) self.config.resolve_auth_token(interactive=True) else: # silently attemp to resolve but no pop up self.config.resolve_auth_token(interactive=False) # cache handler logger.debug("Creating cache handler for %s", self._resource.value) self.cache_handler = DiskCheckpointer( params_getter=lambda: self.params, resource_str=self.resource.value, config=self.config, results_store=self._results, ) self._try_load_cache() def _try_load_cache(self): # try to load from cache logger.info("Attempting to load cached results for %s", self.resource.value) try: # results self._pages_from_cache = self.cache_handler.load_cache_results() logger.info( f"Loaded pages {self._pages_from_cache} from cache for resource {self.resource.value}" ) # if cache results loaded, update if self._pages_from_cache: self._results = self.cache_handler._results # manifest self._cached_manifest = self.cache_handler.load_cache_manifest() # update self.count = self._cached_manifest.get("count", None) self.num_requests = self._cached_manifest.get("total_pages", None) except Exception as e: logger.warning(f"Failed to load from cache: {e}") self._pages_from_cache = [] self._cached_manifest = {}
[docs] def clear_cache(self): """ Clear the cached results for the current resource and parameters. This will delete any cached files associated with the current query parameters. """ logger.info("Clearing cache for %s", self.resource.value) self.cache_handler.clear_cache() # reset loaded cache state self._pages_from_cache = [] self._cached_manifest = {}
@property def cache_dir(self) -> Optional[Path]: return self.cache_handler._cache_dir @property def endpoint_module(self) -> Callable: return self.emgapi_handler.endpoint_module @endpoint_module.setter def endpoint_module(self, value: Callable): """ Default endpoint modules based on resource at initialization but can be re-assigned. When re-assigning, the QuerySet should be re-instantiated to update the urls and other info. """ logger.info("Reassigning endpoint module for %s", self.resource.value) self.emgapi_handler = DescribeEmgapiModule(endpoint_module=value) self.count: Optional[int] = None self.num_requests: Optional[int] = None self._results: dict[int, list[dict]] = None # check that params are valid for new endpoint module # _ = self.emgapi_handler.validate_endpoint_kwargs(**self.params) # reset cache? resource_str = ( self.resource.value if hasattr(self, "resource") else self.__class__.__name__ ) self.cache_handler = DiskCheckpointer( params_getter=lambda: self.params, resource_str=resource_str, config=self.config, results_store=self._results, count=self.count, num_requests=self.num_requests, ) self._try_load_cache() @property def request_url(self) -> str: """ Get the URL for the API request based on the current resource and parameters. This is a single URL that represents the request for the current page of results. Returns ------- str The constructed URL for the API request. """ request_url = self._build_request_url() logger.debug( "Resolved request URL for %s: %s", self.resource.value, request_url ) return request_url @property def params(self) -> dict[str, Any]: return self._params @params.setter def params(self, new_params: dict[str, Any]): logger.info("Updating params for %s", self.resource.value) self._params = new_params # check that params are valid for endpoint module _ = self.emgapi_handler.validate_endpoint_kwargs(**self._params) # reset cache? logger.debug( "Rebuilding cache handler after params update for %s", self.resource.value, ) self.cache_handler = DiskCheckpointer( params_getter=lambda: self.params, resource_str=self.resource.value, config=self.config, results_store=self._results, count=self.count, num_requests=self.num_requests, ) self._try_load_cache() @property def results(self) -> dict[int, list[dict]]: """ Get the retrieved metadata results, if available. Results are stored in a dictionary with request number (e.g. page number) as keys. """ if self._results is None: logger.warning("No results available for %s", self.resource.value) print( "No results available. Please execute a query first e.g. .get(), .page()" ) else: logger.debug( "Returning results for %s with pages: %s", self.resource.value, list(self._results.keys()), ) return self._results def _unpageinate_results(self, data: Optional[dict] = None) -> chain: """ Flattening the results into a single iterator of records. If paginated results are stored in a dictionary with page numbers as keys, this method will extract the records from all pages and combine them into a single iterable sequence. Returns ------- chain An iterator that yields individual metadata records from all pages. """ logger.debug("Flattening paginated results for %s", self.resource.value) _data = data or self.results def _page_to_records(page): if page is None: return [] if isinstance(page, list): return page if isinstance(page, dict): return [page] return [page] return chain.from_iterable(_page_to_records(v) for v in _data.values()) @property def records(self) -> Optional[chain]: """ Get an iterator of individual metadata records from the retrieved results, if available. This property provides a convenient way to access the metadata records without needing to handle pagination. Returns ------- chain or None An iterator that yields individual metadata records if results are available, otherwise None. """ if self.results is None: logger.debug("No record iterator available for %s", self.resource.value) return None logger.debug("Returning record iterator for %s", self.resource.value) return self._unpageinate_results() @property def resource(self) -> SupportedEndpoints: return self._resource @resource.setter def resource(self, value: str): logger.info("Setting resource to %s", value) self._resource = SupportedEndpoints.validate(value) self.endpoint_module = RESOURCES_ALL_ENDPOINTS[self._resource] def _is_in_results(self, request_num: int) -> bool: """ Check if results for a specific request number already exist in the results. Parameters ---------- request_num : int The request number (e.g., page number) to check for existing results. Returns ------- bool True if results for the specified request number exist, False otherwise. """ # validate num is positive int validated_int = validate_gt_int(request_num, 0) in_results = validated_int in (self._results or []) logger.debug( "Result presence check for %s page %s: %s", self.resource.value, validated_int, in_results, ) return in_results # PARAM HANDLING def _spawn( self, *, target_resource: Optional[str] = None, params: Optional[dict[str, Any]] = None, **kwargs, ) -> "QuerySet": """ Spawn a new QuerySet instance for a related resource with given parameters. Returns ------- QuerySet A new QuerySet instance with other resource and parameters. """ logger.debug( "Spawning QuerySet from %s to %s", self.resource.value, target_resource or self.resource, ) merged_params = {**(params or {}), **kwargs} resource_override = merged_params.pop("resource", None) return QuerySet( resource=target_resource or resource_override or self.resource, config=self.config, params=merged_params, ) def _clone(self, **param_overrides): """ 'polymorphism-aware, immutable-style clone helper' to create a new instance of the same class with updated parameters. This method is used internally to create new QuerySet instances with updated parameters while preserving the original instance's state. 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 ------- QuerySet A new instance of the same class with the updated parameters. """ logger.debug( "Cloning QuerySet for %s with overrides: %s", self.resource.value, sorted(param_overrides.keys()), ) merged_params = {**self.params, **param_overrides} resource_override = merged_params.pop("resource", None) target_resource = ( getattr(self, "RESOURCE", None) or resource_override or self.resource ) new_qs = self.__class__( resource=target_resource, config=self.config, params=merged_params, ) new_qs.endpoint_module = self.endpoint_module return new_qs
[docs] def filter( self, **filters, ) -> "QuerySet": """ Update the parameters for the API call to filter results. Parameters ---------- **filters Keyword arguments corresponding to the supported parameters for the current resource. These will be used to filter the results returned by the API. Returns ------- QuerySet A new QuerySet instance with updated parameters for filtering results. """ # make a copy of current instance but with updated params logger.info( "Filtering QuerySet for %s with keys: %s", self.resource.value, sorted(filters.keys()), ) new_qs = self._clone(**filters) return new_qs
@property def base_url(self) -> str: return self.config.base_url def _build_request_url( self, params: Optional[dict[str, Any]] = None, ) -> str: """ Build a URL for the current resource and parameters using the endpoint module's URL template and the provided parameters. (currently for logging/verbose output only). Parameters ---------- params : dict, optional Parameters to include in the URL. If None, uses self.params. exclude : list of str, optional List of parameter names to exclude from the URL query string. These are typically parameters that are not used for filtering in the API call, such as 'accession' or 'pubmed_id'. Returns ------- str The constructed URL. """ # accept given params or use self.params _params = deepcopy(params or self.params) # combine sub_url and encoded query params path = self.emgapi_handler.url_path(**_params) # return full url with base url+sub_url+encoded params request_url = f"{str(self.base_url).rstrip('/')}/{path.lstrip('/')}" logger.debug("Built request URL for %s: %s", self.resource.value, request_url) return request_url
[docs] def list_urls(self) -> list[str]: """ Generate and return a list of URLs for all the API requests that would be made to retrieve the data based on the current parameters. This allows the user to see exactly which endpoints and query parameters will be used in the API calls before executing them. Returns ------- list of str A list of URLs corresponding to each API request that would be made. """ logger.info("Listing request URLs for %s", self.resource.value) if self.num_requests is None: logger.warning( "Number of requests is not set. Call planning helpers (e.g., .dry_run, explain) for accurate URL list" ) total_pages = 0 else: total_pages = self.num_requests if not self.emgapi_handler.is_list_endpoint: return [self._build_request_url()] # otherwise _parm = deepcopy(self.params) urls = [] for pg in self.emgapi_handler.page_param_iter(total_pages): _parm.update(pg) urls.append(self._build_request_url(params=_parm)) logger.debug("Generated %s URLs for %s", len(urls), self.resource.value) return urls
def __call__(self, **kwargs): return self.filter(**kwargs)
[docs] def queries(self, **httpx_kwargs) -> list[dict[str, Any]]: """ Generate a list of query parameter dictionaries for each API request that would be made based on the current parameters. This allows the user to see the specific query parameters for each request before executing them. Returns ------- list of dict A list of dictionaries, each containing the query parameters for a corresponding API request. """ logger.info("Building query plan for %s", self.resource.value) if not self.emgapi_handler.is_list_endpoint: query_setup = { "url": self.emgapi_handler.sub_url(**self.params), "params": self.params, } logger.debug("Built single-query plan for %s", self.resource.value) return {1: query_setup} if self.num_requests is None: logger.warning( "Number of requests is not set. Call planning helpers (e.g., .dry_run, explain) for accurate URL list" ) total_pages = 0 else: total_pages = self.num_requests queries = {} for pg, pg_param in enumerate( self.emgapi_handler.page_param_iter(total_pages), start=1 ): # prep numbereed params _parm = deepcopy(self.params) _parm.update(pg_param) # save set up query_setup = { "url": self.emgapi_handler.sub_url(**_parm), "params": _parm, } queries[pg] = query_setup logger.debug("Built %s query entries for %s", len(queries), self.resource.value) return queries