Source code for mgnipy.V2.mixins

from __future__ import annotations

import asyncio
import hashlib
import io
import json
import logging

from mgnipy._shared_helpers.httpx_helpers import init_httpx_client

logger = logging.getLogger(__name__)
from http.client import IncompleteRead
from pathlib import Path
from typing import Any, Generator, Literal, Optional
import webbrowser
import zlib

from bigtree import Tree
import httpx
import ijson
import pandas as pd
import polars as pl
from pydantic import HttpUrl
from skbio.io import read

from mgnipy._shared_helpers.writers import atomic_write_bytes, atomic_write_json


[docs] class CheckpointMixin: """ Checkpoint manager for request-makers. This mixin provides methods to write paginated results to disk as they are retrieved, and to load them back into memory on subsequent runs. It generates a unique cache key based on the query parameters and resource type, and organizes cached pages in a directory structure under a specified cache root. The mixin assumes the host class has the following attributes: - `self.params`: A dictionary of query parameters for the current request. - `self.config`: An instance of `MGnipyConfig` containing configuration settings, including the cache directory. - `self.resource`: A string representing the type of resource being queried (e.g., "samples", "runs"). - `self._results`: A dictionary mapping page numbers to lists of dictionaries containing the query results. - `self.count`: An integer representing the total number of records for the current query. - `self.num_requests`: An integer representing the total number of paginated requests needed for the current query. which is the case for: QuerySets, MGnifier, proxies Example ------- >>> from mgnipy.V2.mgnifier.query_set import QuerySet >>> from mgnipy.V2.mixins import CheckpointMixin >>> # Creating a class that uses mixin >>> class MyQuerySet(QuerySet, CheckpointMixin): ... # Initialize QuerySet (not mixin) ... def __init__(self, *args, **kwargs): ... super().__init__(*args, **kwargs) >>> # init >>> qs = MyQuerySet(resource="studies") # default config is w/ cache enabled >>> # now can use checkpoint methods, e.g. >>> qs.cache_key # print the unique cache key '...' >>> qs.load_cache() [] """ @property def cache_key(self) -> str: """ Generate deterministic hash from resource + params. Returns ------- str A unique cache key for the current query parameters and resource. For a query to the 'samples' resource with parameters {'biome_lineage': 'root:Environmental:Terrestrial'}, the cache key will be a SHA256 hash of the string representation of the resource and parameters, ensuring that identical queries will have the same cache key and thus access the same cached results. Example ------- >>> from mgnipy.V2.mixins import CheckpointMixin >>> from mgnipy import MGnipyConfig >>> # Prepare parameters and config >>> params = {'lineage': 'root:Environmental:Terrestrial'} >>> resource = 'biome' >>> config = MGnipyConfig(cache_dir="/path/to/cache") >>> # Create CheckpointMixin instance and compute cache key >>> cache_handler = CheckpointMixin() >>> cache_handler.params = params >>> cache_handler.resource = resource >>> cache_handler.config = config >>> cache_handler.cache_key '1eb56ddf5a2e7d60d8155c8bbe01f032f959a2519d43e99f31f533abffa3166f' """ params: dict = self.params.copy() serial: str = json.dumps( {"resource": str(self.resource), "params": params}, sort_keys=True, default=str, ) cache_key: str = hashlib.sha256(serial.encode("utf-8")).hexdigest() return cache_key @property def cache_path(self) -> Optional[Path]: """Directory for this query's cached pages.""" if self.config.cache_dir is None: return None return self.config.cache_dir / self.cache_key @property def manifest_path(self) -> Optional[Path]: """Path to mgnipy_manifest.json storing metadata.""" if self.cache_path is None: return None return self.cache_path / "mgnipy_manifest.json"
[docs] def write_results( self, request_num: int, items: Any, include_manifest: bool = True, ) -> None: """Auto atomic write to disk.""" save_to = self.cache_path if save_to is None: logger.debug(f"Cache disabled: Skipping cache write for page {request_num}") return logger.debug(f"Creating cache dir if not exists: {save_to}") save_to.mkdir(parents=True, exist_ok=True) # prep filenames/paths filepath = save_to / f"mgnipy_page_{request_num}.json" logger.info(f"Writing page {request_num} to {filepath}") # now actually writing the response results: # Write bytes (binary downloads) using atomic_write_bytes, otherwise JSON try: if isinstance(items, (bytes, bytearray)): bin_path = filepath.with_suffix(".bin") atomic_write_bytes(bin_path, bytes(items)) else: atomic_write_json(filepath, items) except Exception: logger.error(f"Failed to write cache file for page {request_num}") if include_manifest: manifest_path = self.manifest_path logger.info(f"Writing manifest to {manifest_path}") manifest = { "resource": str(self.resource), "params": self.params, "count": self.count, "total_pages": self.num_requests, } if manifest_path is not None: atomic_write_json(manifest_path, manifest) else: logger.debug("Skipping manifest write for this page")
[docs] async def awrite_results(self, request_num: int, items: Any) -> None: """Async wrapper for write_results.""" logger.debug(f"Asynchronously writing cached results for page {request_num}") await asyncio.to_thread(self.write_results, request_num, items)
[docs] def load_cache_results(self) -> list[int]: """ Load cached pages/request nums into results. Loads cached pages from disk into the in-memory results dictionary (self._results), if available. Returns ------- list of int A list of request numbers (page numbers) that were successfully loaded from the cache. """ # where to load from (or not) load_from = self.cache_path if load_from is None: logger.debug( f"Cache disabled: Skipping cache load for {str(self.resource)}." ) return [] logger.info(f"Loading cached pages from {load_from}") if not load_from.exists(): logger.info(f"No cache to load yet from {load_from}") return [] # initialize stores if self._results is None: logger.debug("Initializing self._results as empty dict for cache load") self._results = {} pages_loaded = [] possible_pages = sorted(load_from.glob("mgnipy_page_*.*")) logger.debug(f"Possible pages: {possible_pages}") # load each page file for cache_file in possible_pages: if cache_file.suffix not in {".json", ".bin"}: logger.info(f"Skipping {cache_file}") continue # Extract page number from filename try: request_num = int(cache_file.stem.split("_")[-1]) except Exception as e: logger.error(f"Failed to extract page number from {cache_file}: {e}") continue if request_num in self._results: logger.debug( f"Page {request_num} already loaded in memory; skipping {cache_file}" ) continue logger.info(f"Loading {cache_file}") try: if cache_file.suffix == ".bin": with cache_file.open("rb") as fh: data = fh.read() else: with cache_file.open("r", encoding="utf-8") as fh: data = json.load(fh) # logger.debug(f"Loaded JSON data for page {request_num}: {data}") # load page to results self._results[request_num] = data logger.debug(f"Loaded page {request_num} to self._results") # tracking pages_loaded.append(request_num) logger.debug(f"Pages loaded so far: {pages_loaded}") except Exception as e: logger.warning(f"Failed to load cache file: {cache_file}. Error: {e}") return pages_loaded
[docs] def load_cache_manifest(self) -> dict: """ Load the cache manifest file if present, and update total records and total requests. Returns ------- dict The contents of the manifest file, or an empty dictionary if the manifest is not found or fails to load. """ # Load manifest if present mpath = self.manifest_path if mpath is None: return {} if mpath.exists(): logger.info(f"Loading cache manifest from {mpath}") try: with mpath.open("r", encoding="utf-8") as fh: manifest = json.load(fh) self.count = manifest.get("count") self.num_requests = manifest.get("total_pages") except Exception: logger.warning(f"Failed to load manifest file: {mpath}") manifest = {} else: manifest = {} return manifest
[docs] def load_cache(self) -> list[int]: """ Pick up where you left off. Loads cached results and manifest into memory. Returns ------- list of int A list of request numbers (page numbers) that were successfully loaded from the cache. """ load_from = self.cache_path if load_from is None: logger.debug("Skipping cache load because cache is disabled") return [] logger.info(f"Loading cache from {self.cache_path}") pages_loaded = self.load_cache_results() mani = self.load_cache_manifest() logger.info(f"Loaded {len(pages_loaded)} cached pages and manifest: {mani}") return pages_loaded
[docs] async def aload_cache(self) -> list[int]: """Async wrapper for load_cache.""" return await asyncio.to_thread(self.load_cache)
[docs] def clear_cache(self) -> None: """Remove all cached pages for this set of queries.""" load_from = self.cache_path if load_from is None: logger.info("Cache is disabled; skipping cache clearing") return if load_from.exists(): logger.info(f"Clearing cache directory {load_from}") for cache_file in load_from.iterdir(): # extra check just in case if cache_file.name == "mgnipy_manifest.json" or ( cache_file.name.startswith("mgnipy_page_") and cache_file.suffix in {".json", ".bin"} ): try: logger.debug(f"Deleting cache file {cache_file}") cache_file.unlink() except Exception: logger.warning(f"Failed to delete cache file: {cache_file}") try: load_from.rmdir() except Exception: logger.warning(f"Failed to delete cache directory: {load_from}") # reset loaded cache state self._pages_from_cache = [] self._cached_manifest = {}
[docs] def try_load_cache(self) -> None: """ Attempt to load cached results and manifest into memory if not already loaded. This method checks if the cache has already been loaded to avoid redundant operations. If the cache has not been loaded, it will attempt to load it and set the `_cache_loaded` attribute accordingly. Notes ----- - This method is intended to be called internally before accessing cached results. - If cache_dir is None then _cache_loaded will be True after initial attempt. - If an error occurs during cache loading, it will be logged, and `_cache_loaded` will be set to False. - Dependent on `.mixins.CheckpointMixin` """ if getattr(self, "_cache_loaded", False): logger.debug( f"Cache already loaded; skipping load attempt. " f"Pages loaded: {self._pages_from_cache}" f"Manifest: {self._cached_manifest}" ) return try: # load results self._pages_from_cache = self.load_cache_results() # load manifest self._cached_manifest = self.load_cache_manifest() # update counts from manifest self.count = self._cached_manifest.get("count", None) self.num_requests = self._cached_manifest.get("total_pages", None) # set flag self._cache_loaded = True except Exception as e: logger.error(f"Error occurred while loading cache: {e}") self._cache_loaded = ( False # Q: Or should this be set to True to avoid repeated attempts? )
[docs] class StreamMixin: """ Mixin providing streaming helpers for downloads. # TODO remove below dependencies on mgnifier This mixin assumes the host class provides the following helpers/properties: `.exec.httpx_client` and `.exec.httpx_aclient` attributes - `_get_type_by_alias(alias)` to resolve file types - `downloads_df` when needed for examples/tests The implementation mirrors the streaming helpers previously defined on :class:`MGazine` so they can be reused by other classes. """ def _handle_incomplete_read(self, url: str): # TODO logger.warning( f"You can also download the file {url} using the 'download' method instead of streaming and then read it into memory from disk, which may be more reliable for unstable connections." ) raise IncompleteRead( f"Incomplete read error encountered when streaming {url}. This may be due to a network issue or server timeout. Consider retrying the request or checking your connection." ) from None
[docs] def stream_pandas( self, url: str, sep: str = "\t", chunksize: Optional[int] = None, max_skip: int = 5, low_memory: bool = False, **pd_kwargs, ) -> pd.DataFrame | pd.io.parsers.readers.TextFileReader: """ Read a TSV from a URL or local file with resilient header handling. The helper will retry with increasing ``skiprows`` when ``pandas`` raises a ``ParserError`` (useful for files with extra header lines). When ``chunksize`` is provided an iterator is returned. Parameters ---------- url : str The URL or local file path to read the TSV from. sep : str The delimiter to use (default is tab). chunksize : int or None If an integer is provided, returns an iterator that yields DataFrames of that many rows. If None, returns a single DataFrame. max_skip : int The maximum number of lines to skip when trying to parse the TSV. **pd_kwargs Additional keyword arguments passed to ``pd.read_csv``. Returns ------- pd.DataFrame or TextFileReader A DataFrame containing the TSV data, or an iterator yielding DataFrames if ``chunksize`` is specified. Raises ------ ValueError If ``chunksize`` is not a positive integer or None. RuntimeError If the TSV cannot be parsed after skipping up to ``max_skip`` lines. Pandas ParserError If the TSV cannot be parsed due to a format error (after retries). """ for skip in range(max_skip + 1): try: return pd.read_csv( url, sep=sep, chunksize=chunksize, skiprows=skip if skip > 0 else None, low_memory=low_memory, **pd_kwargs, ) except pd.errors.ParserError: continue # Try next skiprows value except IncompleteRead: self._handle_incomplete_read(url) except Exception as err: raise err raise pd.errors.ParserError( f"Failed to parse {url} after skipping up to {max_skip} rows." )
[docs] def stream_polars( self, url: str, sep: str = "\t", chunksize: Optional[int] = None, max_skip: int = 5, low_memory: bool = False, **pl_kwargs, ) -> pl.DataFrame | pl.LazyFrame: """ Read a TSV from a URL or local file into a Polars DataFrame with resilient header handling. The helper will retry with increasing ``skip_rows`` when Polars raises an error (useful for files with extra header lines). When ``chunksize`` is provided an iterator is returned. Parameters ---------- url : str The URL or local file path to read the TSV from. sep : str The delimiter to use (default is tab). chunksize : int or None If an integer is provided, returns an iterator that yields DataFrames of that many rows. If None, returns a single DataFrame. max_skip : int The maximum number of lines to skip when trying to parse the TSV. **pl_kwargs Additional keyword arguments passed to ``pl.read_csv``. Returns ------- pl.DataFrame or Iterator[pl.DataFrame] A Polars DataFrame containing the TSV data, or an iterator yielding DataFrames if ``chunksize`` is specified. Raises ------ ValueError If ``chunksize`` is not a positive integer or None. RuntimeError If the TSV cannot be parsed after skipping up to ``max_skip`` lines. Polars Error If the TSV cannot be parsed due to a format error (after retries). """ if chunksize is None: the_chosen = pl.read_csv else: the_chosen = pl.scan_csv for skip in range(max_skip + 1): try: return the_chosen( url, separator=sep, skip_rows_after_header=skip, truncate_ragged_lines=True, infer_schema_length=10000, low_memory=low_memory, **pl_kwargs, ) except pl.exceptions.PolarsError: continue # Try next skip_rows value except IncompleteRead: self._handle_incomplete_read(url) except Exception as err: raise err raise pl.exceptions.PolarsError( f"Failed to parse {url} after skipping up to {max_skip} rows." )
[docs] def stream_html(self, url: str, **web_kwargs) -> bool: """ Open an HTML URL in the default web browser. Parameters ---------- url : str The URL to open in the web browser. **web_kwargs Additional keyword arguments passed to `webbrowser.open()`, such as `new` and `autoraise`. Returns ------- bool True if the URL was opened successfully, False otherwise. """ return webbrowser.open(url, **web_kwargs)
[docs] def stream_txt( self, url: str, chunksize: Optional[int] = None, **httpx_kwargs, ) -> str | Generator: """ Stream a plain-text resource. When ``chunksize`` is ``None`` the full text is returned as a string. When ``chunksize`` is an integer the function yields lists of lines. Parameters ---------- url : str The URL to stream the text from. chunksize : int or None If an integer is provided, yields lists of lines of that size. If None, yields the entire text as a single string. httpx_client : httpx.Client, optional An optional httpx.Client to use for the request. If None, a new client will be created for the request. **httpx_kwargs Additional keyword arguments passed to the httpx.Client.request() method Returns ------- str or Generator The full text as a string if chunksize is None, or a generator yielding lists of lines if chunksize is an integer. """ if chunksize is None: # load as whole with self.httpx_client.get(url, **httpx_kwargs) as response: response.raise_for_status() return response.text elif isinstance(chunksize, int) and chunksize > 0: # load in chunks with self.httpx_client.stream("GET", url, **httpx_kwargs) as response: response.raise_for_status() chunk = [] for line in response.iter_text(): chunk.append(line) if len(chunk) == chunksize: yield chunk chunk = [] if chunk: yield chunk else: raise ValueError("`chunksize` must be a positive integer or None.")
[docs] def stream_fasta(self, url: str, **skbio_kwargs) -> Generator: """ Stream a FASTA file from a URL using scikit-bio's read function. Refer there for more info. Parameters ---------- url : str The URL to the FASTA file to stream. **skbio_kwargs Additional keyword arguments passed to `skbio.io.read()`, such as `into` and `verify`. Returns ------- Generator A generator yielding scikit-bio Sequence objects parsed from the FASTA file. """ return read(url, format="fasta", **skbio_kwargs)
[docs] def stream_gff(self, url: str, **skbio_kwargs) -> Generator: """ Stream a GFF file from a URL using scikit-bio's read function. Refer there for more info. Parameters ---------- url : str The URL to the GFF file to stream. **skbio_kwargs Additional keyword arguments passed to `skbio.io.read()`, such as `into` and `verify`. Returns ------- Generator A generator yielding scikit-bio Sequence objects parsed from the GFF file. """ return read(url, format="gff3", **skbio_kwargs)
[docs] def stream_biom(self, url: str, **skbio_kwargs) -> Generator: """ Stream a biom file from a URL using scikit-bio's read function. Refer there for more info. Parameters ---------- url : str The URL to the biom file to stream. **skbio_kwargs Additional keyword arguments passed to `skbio.io.read()`, such as `into` and `verify`. Returns ------- Generator A generator yielding scikit-bio Sequence objects parsed from the biom file. """ return read(url, format="biom", **skbio_kwargs)
[docs] def stream_gzipped( self, url: str, chunksize: Optional[int] = None, decode: bool = False, encoding: str = "utf-8", errors: str = "replace", **httpx_kwargs, ) -> bytes | str | io.BufferedReader | io.TextIOWrapper: """Stream a gzipped HTTP resource and present a file-like interface. When ``chunksize`` is None the entire compressed payload is fetched and decompressed into memory. When ``chunksize`` is provided a streaming file-like object is returned. """ logger.debug( "stream_gzipped called url=%s chunksize=%s decode=%s", url, chunksize, decode, ) if chunksize is None: logger.debug("Using full-download mode (chunksize=None)") r = self.httpx_client.get(url, timeout=None, **httpx_kwargs) r.raise_for_status() decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS) data = decompressor.decompress(r.content) + decompressor.flush() logger.debug( "Full-download mode complete: compressed=%d decompressed=%d", len(r.content), len(data), ) return data.decode(encoding, errors=errors) if decode else data if not isinstance(chunksize, int) or chunksize <= 0: raise ValueError("`chunksize` must be a positive integer or None.") class _HTTPGzipRaw(io.RawIOBase): def __init__(self): self._cm = self.httpx_client.stream( "GET", url, timeout=None, **httpx_kwargs ) self._resp = self._cm.__enter__() self._resp.raise_for_status() self._iter = self._resp.iter_raw(chunk_size=chunksize) self._decomp = zlib.decompressobj(16 + zlib.MAX_WBITS) self._buf = bytearray() self._eof = False self._flushed = False logger.debug("Streaming HTTP/gzip reader initialized") def readable(self) -> bool: return True def _fill(self, need: int) -> None: while len(self._buf) < need and not self._eof: try: chunk = next(self._iter) except StopIteration: if not self._flushed: tail = self._decomp.flush() if tail: self._buf.extend(tail) self._flushed = True self._eof = True logger.debug("Reached end of HTTP stream") break if chunk: out = self._decomp.decompress(chunk) if out: self._buf.extend(out) def readinto(self, b) -> int: if self.closed: return 0 mv = memoryview(b) self._fill(len(mv)) n = min(len(mv), len(self._buf)) if n <= 0: return 0 mv[:n] = self._buf[:n] del self._buf[:n] return n def close(self) -> None: if not self.closed: try: self._cm.__exit__(None, None, None) finally: super().close() logger.debug("Streaming HTTP/gzip reader closed") raw = _HTTPGzipRaw() buffered = io.BufferedReader(raw, buffer_size=chunksize) if decode: return io.TextIOWrapper(buffered, encoding=encoding, errors=errors) return buffered
[docs] def stream_jsonl( self, url: str, orient: Optional[ Literal["records", "split", "index", "columns", "values", "table"] ] = None, chunksize: Optional[int] = None, df_engine: Optional[Literal["pandas", "polars"]] = "pandas", **df_kwargs, ) -> dict: if df_engine == "pandas": return pd.read_json( url, orient=orient, lines=True, chunksize=chunksize, **df_kwargs ) elif df_engine == "polars": if chunksize is None: return pl.read_ndjson(url, infer_schema_length=10000, **df_kwargs) else: return pl.scan_ndjson(url, infer_schema_length=10000, **df_kwargs)
[docs] def stream_json( self, url: str, chunksize: Optional[int] = None, **httpx_kwargs, ) -> dict | Generator: if chunksize is None and not (url.endswith(".gz") or url.endswith(".gzip")): with self.httpx_client.get(url, **httpx_kwargs) as response: response.raise_for_status() return response.json() elif chunksize is not None and not ( url.endswith(".gz") or url.endswith(".gzip") ): with self.httpx_client.stream("GET", url, **httpx_kwargs) as response: response.raise_for_status() for entry in ijson.kvitems(response.iter_text(), ""): yield entry elif url.endswith(".gz") or url.endswith(".gzip"): with self.stream_gzipped( url, chunksize=chunksize, decode=True, **httpx_kwargs, ) as gzipped_stream: for entry in ijson.kvitems(gzipped_stream, ""): yield entry else: raise ValueError(f"Unsupported file type for URL: {url}")
def _fix_inconsistent_cols( self, fields: list[str], pad_to: int = 15 ) -> list[str] | None: """Pad or truncate list of fields to ``pad_to`` length. Parameters ---------- fields : list of str List of column names to adjust. pad_to : int, optional Desired length of the returned list. Defaults to 15. Returns ------- list of str or None The adjusted list of fields or ``None`` when ``pad_to`` is 0. """ if len(fields) < pad_to: return fields + [""] * (pad_to - len(fields)) if len(fields) > pad_to: return fields[:pad_to] return fields
[docs] def stream_tree(self, url: str, **skbio_kwargs) -> Generator: return read(url, format="newick", **skbio_kwargs)
def _get_streamer( self, alias: Optional[str] = None, url: Optional[HttpUrl] = None, chunksize: int = 1000, max_skip: int = 5, df_engine: Optional[Literal["pandas", "polars"]] = "pandas", low_memory: bool = False, **kwargs, ): _alias, _url = self._prioritize_alias(alias, url, required=True) file_type = self._get_type_by_alias(_alias) if df_engine == "polars" and file_type == "tsv": return self.stream_polars( _url, sep="\t", chunksize=chunksize, max_skip=max_skip, low_memory=low_memory, **kwargs, ) if df_engine == "polars" and file_type == "csv": return self.stream_polars( _url, sep=",", chunksize=chunksize, max_skip=max_skip, low_memory=low_memory, **kwargs, ) if file_type == "tsv": if _url.endswith(".gz") or _url.endswith(".gzip"): logger.debug(f"tsv file type ends with .gz: {_url}") try: return self.stream_pandas( _url, chunksize=chunksize, max_skip=max_skip, compression="gzip", **kwargs, ) except pd.errors.ParserError as e: logger.error(f"ParserError: {e}") return self.stream_pandas( _url, chunksize=chunksize, max_skip=max_skip, compression="gzip", engine="python", on_bad_lines=self._fix_inconsistent_cols, **kwargs, ) elif _url.endswith(".txt") or _url.endswith(".tsv"): return self.stream_pandas( _url, chunksize=chunksize, max_skip=max_skip, low_memory=low_memory, **kwargs, ) if file_type == "csv": return self.stream_pandas( _url, sep=",", chunksize=chunksize, max_skip=max_skip, low_memory=low_memory, **kwargs, ) if file_type == "html": return lambda: self.stream_html(_url, **kwargs) if file_type == "txt": return self.stream_txt(_url, chunksize=chunksize, **kwargs) if file_type == "gff": return self.stream_gff(_url, **kwargs) if file_type == "biom": return self.stream_biom(_url, **kwargs) if file_type == "fasta": return self.stream_fasta(_url, **kwargs) if file_type == "tree": return self.stream_tree(_url, **kwargs) if file_type == "json": return self.stream_jsonl( _url, orient="records", chunksize=chunksize, df_engine=df_engine, low_memory=low_memory, **kwargs, ) if file_type == "other" and ".json" in _url: if _url.endswith("json.gz") or _url.endswith("json.gzip"): return self.stream_json(_url, chunksize=chunksize, **kwargs) logger.info( f"{_alias} is only available for download (e.g., `.download({_alias}))`" ) logger.debug( f"Alias: {_alias}\nURL: {_url}\nFile type: {file_type}. Only '.json' files can be streamed under 'other' type, otherwise this download is only available for download." ) else: raise ValueError(f"Unsupported file type for streaming: {file_type}")
[docs] def stream( self, *, alias: Optional[str] = None, url: Optional[HttpUrl] = None, chunksize: Optional[int] = None, max_skip: int = 5, **kwargs, ) -> Any: """ Streams a single download based on its alias or url. If ``chunksize`` is specified then iterators of dataframes or strings will be returned; otherwise the full data will be returned as a single object. Supported formats and their handlers ------------------------------------ - tsv: handled by :meth:`stream_pandas` (pandas) or :meth:`stream_polars` (polars). Gzipped TSVs are supported via the gzip/compression options. - csv: handled by :meth:`stream_pandas` / :meth:`stream_polars` (sep=","). - txt: handled by :meth:`stream_txt` (returns full text or yields line chunks). - html: handled by :meth:`stream_html` (opens URL in browser). - fasta: handled by :meth:`stream_fasta` (scikit-bio generator). - gff: handled by :meth:`stream_gff` (scikit-bio generator). - biom: handled by :meth:`stream_biom` (scikit-bio generator). - gzipped HTTP resources: use :meth:`stream_gzipped` for a file-like object, or :meth:`stream_json` for gzipped JSON content. - jsonl / ndjson: handled by :meth:`stream_jsonl` (pandas or polars modes). - json: handled by :meth:`stream_json` (returns full JSON or streams via ijson). - tree/newick: handled by :meth:`stream_tree` (scikit-bio newick reader). - other: if the URL ends with ``.json`` it's streamed via :meth:`stream_json`; otherwise use the download helper for unsupported binary formats. Parameters ---------- alias : Optional[str] The alias of the download to stream. url : Optional[HttpUrl] The url of the download to stream. chunksize : Optional[int] The size of the chunks to read from the stream. max_skip : int, optional The maximum number of rows to skip before raising an error. Default is 5. **kwargs Additional keyword arguments to pass to the streamer function. Returns ------- Any The streamer result for the resolved alias or url. """ # resolve a single alias/url target _alias, _url = self._prioritize_alias(alias, url, required=True) # return a single streamer result, not a dict of all streams logger.info("Setting up stream for alias=%s url=%s", _alias, _url) try: return self._get_streamer( alias=_alias, url=_url, chunksize=chunksize, max_skip=max_skip, **kwargs, ) except httpx.HTTPError as err: logger.error("HTTP error for alias=%s url=%s: %s", _alias, _url, err) raise
[docs] class BiomesTreeMixin: @property def lineages(self) -> list[str]: mgnify_metadata = self.search_results return mgnify_metadata.ids @property def tree(self) -> Tree: """ Convert the biomes metadata to a tree structure for visualization or analysis. Returns ------- Tree A tree representation of the biomes and their relationships. """ logger.debug("Building tree from %s lineages", len(self.lineages)) # TODO generate nodes first return Tree.from_list(self.lineages, sep=":")
[docs] def show_tree( self, method: Literal[ "compact", "show", "print", "horizontal", "hshow", "h", "hprint", "vertical", "vshow", "v", "vprint", ] = "compact", ): logger.info("Showing tree using method %s", method) if method in ["compact", "show", "print"]: # TODO print_tree(self._tree) self.tree.show() elif method in ["horizontal", "hshow", "h", "hprint"]: self.tree.hshow() elif method in ["vertical", "vshow", "v", "vprint"]: self.tree.vshow() else: raise ValueError( f"Invalid method: {method}. " "Supported methods: 'compact', 'show', 'print', " "'horizontal', 'hshow', 'h', 'hprint', " "'vertical', 'vshow', 'v', 'vprint'." )
@property def results(self) -> dict: """Get results and auto-normalize lineage field.""" parent_results = super().results # Always normalize if results exist if parent_results: logger.debug("Normalizing lineage fields in results") self._normalise_lineage() return parent_results def _normalise_lineage(self): """ Rename field "lineage" to "biome_lineage" for consistency with other resources. """ if self._results: logger.debug("Renaming lineage fields to biome_lineage") for page_data in self._results.values(): if isinstance(page_data, list): for record in page_data: if isinstance(record, dict) and "lineage" in record: record["biome_lineage"] = record.pop("lineage")
[docs] class ClientManagerMixin: """ Context manager mixin for managing the lifecycle of an HTTP client (synchronous or asynchronous) within a class. This mixin provides methods to enter and exit the context, ensuring that the client is properly initialized and closed, and handles cases where the client may have been closed or is not available. Requirements ------------ - `self.client` must be an instance of a class that implements the context manager protocol (i.e., has `__enter__` and `__exit__` methods for synchronous clients, and `__aenter__` and `__aexit__` methods for asynchronous clients). - `self.config` must be a configuration object or dictionary that can be used to initialize a new HTTP client instance if the existing client is closed or unavailable. """ def __enter__(self): try: self.client.__enter__() except RuntimeError as e: logger.warning(f"Opening a new client instance due to: {e}") self.client = init_httpx_client(self.config) self.client.__enter__() return self def __exit__(self, *args, **kwargs): if not getattr(self, "_owns_client", True): logger.debug("Client is not owned by this instance; skipping __exit__().") return False self.client.__exit__(*args, **kwargs) self.close() logger.debug(f"Closing client: {self.client._client}") return False async def __aenter__(self): try: await self.client.__aenter__() except RuntimeError as e: logger.warning(f"Opening a new async client instance due to: {e}") self.client = init_httpx_client(self.config) await self.client.__aenter__() return self async def __aexit__(self, *args, **kwargs): if not getattr(self, "_owns_client", True): logger.debug("Client is not owned by this instance; skipping __aexit__().") return False await self.client.__aexit__(*args, **kwargs) await self.aclose() logger.debug(f"Closing async client: {self.client._async_client}") return False
[docs] def close(self): if self.client is not None and self.client._client is not None: if not self.client._client.is_closed: self.client._client.close() self.client._client = None
[docs] async def aclose(self): if self.client is not None and self.client._async_client is not None: if not self.client._async_client.is_closed: await self.client._async_client.aclose() self.client._async_client = None
[docs] def status(self) -> None: """ Print the status of the MGnipy client, including the type of client and whether the synchronous and asynchronous httpx client sessions are open. """ print( f"Client or AuthenticatedClient type: {type(self.client).__name__}\n" f"HTTP client open: {self.client._client is not None and not self.client._client.is_closed}\n" f"Async client open: {self.client._async_client is not None and not self.client._async_client.is_closed}\n" )
[docs] def renew_client(self): """ Init a new client instance and replace the existing one. This is useful if the current client has been closed or is no longer valid, allowing for a fresh start with a new HTTP client session. """ logger.info(f"Renewing HTTP client instance. Old: {self.client}") self.client = init_httpx_client(self.config) logger.info(f"HTTP client refreshed. Now: {self.client}")
@property def httpx_client(self) -> httpx.Client: """ Get the synchronous httpx client instance from the AuthenticatedClient. Returns ------- httpx.Client The synchronous httpx client instance. """ if self.client is None: logger.info("Client is None; initializing a new client instance.") self.renew_client() elif self.client._client is None: logger.info( "Synchronous httpx Client._client is None. Getting httpx client" ) elif self.client._client.is_closed: logger.info("Synchronous httpx Client._client is closed; renewing client.") self.renew_client() self.client.get_httpx_client() return self.client._client @property def async_httpx_client(self) -> httpx.AsyncClient: """ Get the asynchronous httpx client instance from the AuthenticatedClient. Returns ------- httpx.AsyncClient The asynchronous httpx client instance. """ if self.client is None: logger.info("Client is None; initializing a new client instance.") self.renew_client() elif self.client._async_client is None: logger.info( "Asynchronous httpx Client._async_client is None. Getting async httpx client" ) elif self.client._async_client.is_closed: logger.info( "Asynchronous httpx Client._async_client is closed; renewing client." ) self.renew_client() self.client.get_httpx_async_client() return self.client._async_client