diff --git a/DATASET_CATALOG_USAGE.md b/DATASET_CATALOG_USAGE.md index c277271..14db65b 100644 --- a/DATASET_CATALOG_USAGE.md +++ b/DATASET_CATALOG_USAGE.md @@ -85,7 +85,7 @@ datasets = list_available_datasets(catalog) # } # Resolve a specific dataset to its CID -cid = resolve_dataset_cid_from_stac( +resolved = resolve_dataset_cid_from_stac( catalog=catalog, collection="ecmwf_era5", dataset="temperature_2m", @@ -93,7 +93,7 @@ cid = resolve_dataset_cid_from_stac( organization="ecmwf", ) -print(f"Dataset CID: {cid}") +print(f"Dataset CID: {resolved.cid} (variant: {resolved.variant})") ``` ## STAC Catalog Structure @@ -317,7 +317,7 @@ def resolve_dataset_cid_from_stac( collection: str, dataset: str, variant: Optional[str] = None -) -> str +) -> ResolvedDataset ``` **Parameters:** @@ -327,7 +327,7 @@ def resolve_dataset_cid_from_stac( - `variant`: Optional variant name **Returns:** -- `str`: IPFS CID (without "ipfs://" prefix) +- `ResolvedDataset`: IPFS CID (without "ipfs://" prefix) and selected variant **Raises:** - `ValueError`: If collection, dataset, or variant is not found @@ -340,14 +340,14 @@ from dclimate_client_py import load_stac_catalog, resolve_dataset_cid_from_stac catalog = load_stac_catalog("https://ipfs-gateway.dclimate.net") # Resolve dataset to CID -cid = resolve_dataset_cid_from_stac( +resolved = resolve_dataset_cid_from_stac( catalog=catalog, collection="ifs", dataset="temperature", variant="single" ) -print(f"Dataset CID: {cid}") +print(f"Dataset CID: {resolved.cid} (variant: {resolved.variant})") ``` ### `get_root_catalog_cid()` @@ -362,7 +362,7 @@ def get_root_catalog_cid() -> str - `str`: The IPFS CID of the latest root STAC catalog **Raises:** -- `requests.HTTPError`: If the API request fails +- `httpx.HTTPError`: If the API request fails - `KeyError`: If response doesn't contain expected 'cid' field **Example:** diff --git a/dclimate_client_py/__init__.py b/dclimate_client_py/__init__.py index 52749f6..ed2dd9a 100644 --- a/dclimate_client_py/__init__.py +++ b/dclimate_client_py/__init__.py @@ -13,6 +13,7 @@ TemporalExtent, ) from .stac_server import ( + ResolvedDataset, resolve_cid_from_stac_server, list_available_datasets_from_stac_server, STAC_SERVER_URL, @@ -74,6 +75,7 @@ def __dir__() -> list[str]: "TemporalExtent", "load_stac_catalog", "list_available_datasets", + "ResolvedDataset", "resolve_cid_from_stac_server", "list_available_datasets_from_stac_server", "STAC_SERVER_URL", diff --git a/dclimate_client_py/client.py b/dclimate_client_py/client.py index 093ea97..e100c25 100644 --- a/dclimate_client_py/client.py +++ b/dclimate_client_py/client.py @@ -36,21 +36,21 @@ def load_s3( def geo_temporal_query( dataset_name: str, source: typing.Literal["s3"] = "s3", - bucket_name: str = None, - var_name: str = None, + bucket_name: typing.Optional[str] = None, + var_name: typing.Optional[str] = None, gateway_uri_stem: str | None = None, rpc_uri_stem: str | None = None, - forecast_reference_time: str = None, - point_kwargs: dict = None, - circle_kwargs: dict = None, - rectangle_kwargs: dict = None, - polygon_kwargs: dict = None, - multiple_points_kwargs: dict = None, + forecast_reference_time: typing.Optional[str] = None, + point_kwargs: typing.Optional[dict] = None, + circle_kwargs: typing.Optional[dict] = None, + rectangle_kwargs: typing.Optional[dict] = None, + polygon_kwargs: typing.Optional[dict] = None, + multiple_points_kwargs: typing.Optional[dict] = None, bounds=None, - bounds_options: dict = None, - spatial_agg_kwargs: dict = None, - temporal_agg_kwargs: dict = None, - rolling_agg_kwargs: dict = None, + bounds_options: typing.Optional[dict] = None, + spatial_agg_kwargs: typing.Optional[dict] = None, + temporal_agg_kwargs: typing.Optional[dict] = None, + rolling_agg_kwargs: typing.Optional[dict] = None, time_range: typing.Optional[typing.List[datetime.datetime]] = None, # as_of: typing.Optional[datetime.datetime] = None, # Removed as_of point_limit: int = DEFAULT_POINT_LIMIT, diff --git a/dclimate_client_py/dclimate_client.py b/dclimate_client_py/dclimate_client.py index 7ef41d1..909f608 100644 --- a/dclimate_client_py/dclimate_client.py +++ b/dclimate_client_py/dclimate_client.py @@ -12,7 +12,7 @@ if typing.TYPE_CHECKING: import pystac -import requests +import httpx import xarray as xr from py_hamt import KuboCAS @@ -24,6 +24,7 @@ from .datasets import DatasetMetadata from .dclimate_zarr_errors import InvalidSelectionError from .stac_server import ( + ResolvedDataset, resolve_cid_from_stac_server, list_available_datasets_from_stac_server, ) @@ -35,6 +36,8 @@ SirenRegion, ) +DEFAULT_PUBLIC_GATEWAY = "https://ipfs-gateway.dclimate.net" + class dClimateClient: """ @@ -47,10 +50,26 @@ class dClimateClient: ---------- gateway_base_url : str, optional IPFS HTTP Gateway base URL (e.g., "https://ipfs.io" or "http://localhost:8080"). - If None, uses KuboCAS defaults or environment variables. + If None, KuboCAS uses its own defaults while STAC-catalog fallback reads + use ``DEFAULT_PUBLIC_GATEWAY``. rpc_base_url : str, optional IPFS RPC API base URL (e.g., "http://localhost:5001"). If None, uses KuboCAS defaults or environment variables. + concurrency : int, optional + Maximum number of concurrent Kubo gateway and RPC requests. + headers : dict[str, str], optional + Default headers for the internally-created HTTP client. + auth : tuple[str, str], optional + Authentication tuple (username, password) for the internally-created client. + max_retries : int, optional + Maximum number of retries for retryable gateway requests. + initial_delay : float, optional + Initial retry delay in seconds. + backoff_factor : float, optional + Multiplier used for exponential retry backoff. + client_factory : Callable[[], httpx.AsyncClient], optional + Create a separate, fully configured HTTP client for each event loop. + Cannot be combined with ``headers`` or ``auth``. Examples -------- @@ -83,14 +102,35 @@ class dClimateClient: def __init__( self, - gateway_base_url: typing.Optional[str] = "https://ipfs-gateway.dclimate.net", - rpc_base_url: typing.Optional[str] = "https://ipfs-gateway.dclimate.net", + gateway_base_url: typing.Optional[str] = DEFAULT_PUBLIC_GATEWAY, + rpc_base_url: typing.Optional[str] = DEFAULT_PUBLIC_GATEWAY, stac_server_url: typing.Optional[str] = "https://api.stac.dclimate.net", siren: typing.Optional[SirenOptions] = None, - ): + *, + concurrency: typing.Optional[int] = None, + headers: typing.Optional[dict[str, str]] = None, + auth: typing.Optional[tuple[str, str]] = None, + max_retries: typing.Optional[int] = None, + initial_delay: typing.Optional[float] = None, + backoff_factor: typing.Optional[float] = None, + client_factory: typing.Optional[typing.Callable[[], httpx.AsyncClient]] = None, + ) -> None: + if client_factory is not None and (headers is not None or auth is not None): + raise ValueError("client_factory cannot be combined with headers or auth") + self._gateway_base_url = gateway_base_url + self._catalog_gateway_base_url = ( + gateway_base_url if gateway_base_url is not None else DEFAULT_PUBLIC_GATEWAY + ) self._rpc_base_url = rpc_base_url self._stac_server_url = stac_server_url + self._concurrency = concurrency + self._headers = headers + self._auth = auth + self._max_retries = max_retries + self._initial_delay = initial_delay + self._backoff_factor = backoff_factor + self._client_factory = client_factory self._stac_catalog: typing.Optional["pystac.Catalog"] = None self._stac_catalog_lock = asyncio.Lock() self._kubo_cas: typing.Optional[KuboCAS] = None @@ -104,10 +144,27 @@ def __init__( async def __aenter__(self) -> "dClimateClient": """Initialize KuboCAS when entering async context.""" # Create KuboCAS with configured endpoints - self._kubo_cas = KuboCAS( - gateway_base_url=self._gateway_base_url, - rpc_base_url=self._rpc_base_url, + kubo_kwargs: dict[str, typing.Any] = { + "gateway_base_url": self._gateway_base_url, + "rpc_base_url": self._rpc_base_url, + } + optional_kubo_kwargs = { + "concurrency": self._concurrency, + "headers": self._headers, + "auth": self._auth, + "max_retries": self._max_retries, + "initial_delay": self._initial_delay, + "backoff_factor": self._backoff_factor, + "client_factory": self._client_factory, + } + kubo_kwargs.update( + { + key: value + for key, value in optional_kubo_kwargs.items() + if value is not None + } ) + self._kubo_cas = KuboCAS(**kubo_kwargs) # Enter the KuboCAS context manager await self._kubo_cas.__aenter__() return self @@ -233,7 +290,7 @@ async def load_dataset( If dataset cannot be found in STAC catalog InvalidSelectionError If collection parameter is not provided (when not using direct CID) - requests.RequestException + httpx.HTTPError If connection to IPFS gateway fails Examples @@ -259,21 +316,22 @@ async def load_dataset( "Use 'async with dClimateClient() as client:'" ) - resolved_collection = collection - if ( - organization - and collection - and not collection.startswith(f"{organization}_") - ): - resolved_collection = f"{organization}_{collection}" - # Case 1: Direct CID provided - bypass catalog resolution + metadata: DatasetMetadata if cid: - slug_collection = resolved_collection or collection or "unknown" + direct_collection = collection + if ( + organization + and direct_collection + and not direct_collection.startswith(f"{organization}_") + ): + direct_collection = f"{organization}_{direct_collection}" + slug_collection = direct_collection or "unknown" + direct_variant = variant or "unknown" dataset_slug = ( - f"{organization}/{slug_collection}/{dataset}/{variant or 'default'}" + f"{organization}/{slug_collection}/{dataset}/{direct_variant}" if organization - else f"{slug_collection}/{dataset}/{variant or 'default'}" + else f"{slug_collection}/{dataset}/{direct_variant}" ) ds = await _load_dataset_from_ipfs_cid( ipfs_cid=cid, @@ -283,10 +341,10 @@ async def load_dataset( ) # Build metadata for direct CID case - metadata: DatasetMetadata = { - "collection": resolved_collection or "unknown", + metadata = { + "collection": direct_collection or "unknown", "dataset": dataset, - "variant": variant or "unknown", + "variant": direct_variant, "slug": dataset_slug, "cid": cid, "url": None, @@ -294,8 +352,8 @@ async def load_dataset( "source": "direct_cid", "organization": organization or ( - resolved_collection.split("_")[0] - if resolved_collection and "_" in resolved_collection + direct_collection.split("_")[0] + if direct_collection and "_" in direct_collection else None ), } @@ -312,24 +370,28 @@ async def load_dataset( "collection parameter is required. Use client.list_datasets() to see available collections." ) - final_cid = None + resolved_collection = collection + if organization and not collection.startswith(f"{organization}_"): + resolved_collection = f"{organization}_{collection}" + + resolved: typing.Optional[ResolvedDataset] = None # Try STAC server first (faster, avoids loading IPFS catalog) if self._stac_server_url: try: - final_cid = await asyncio.to_thread( + resolved = await asyncio.to_thread( resolve_cid_from_stac_server, collection=resolved_collection, dataset=dataset, variant=variant, server_url=self._stac_server_url, ) - except (requests.RequestException, ValueError): + except (httpx.HTTPError, ValueError): # Fall back when server lookup fails or returns no usable match. pass # Fallback: Resolve via STAC catalog from IPFS - if final_cid is None: + if resolved is None: from .stac_catalog import ( list_available_datasets, load_stac_catalog, @@ -342,7 +404,9 @@ async def load_dataset( if self._stac_catalog is None: self._stac_catalog = await asyncio.to_thread( load_stac_catalog, - gateway_url=self._gateway_base_url, + gateway_url=self._catalog_gateway_base_url, + headers=self._headers, + auth=self._auth, ) if not organization and resolved_collection: @@ -358,7 +422,7 @@ async def load_dataset( if len(prefixed_matches) == 1: resolved_collection = prefixed_matches[0] - final_cid = await asyncio.to_thread( + resolved = await asyncio.to_thread( resolve_dataset_cid_from_stac, catalog=self._stac_catalog, collection=resolved_collection, @@ -367,24 +431,26 @@ async def load_dataset( organization=organization, ) + assert resolved is not None + ds = await _load_dataset_from_ipfs_cid( - ipfs_cid=final_cid, + ipfs_cid=resolved.cid, kubo_cas=self._kubo_cas, zarr_group=zarr_group, shard_read_mode=shard_read_mode, ) # Build metadata for STAC case - metadata: DatasetMetadata = { + metadata = { "collection": resolved_collection, "dataset": dataset, - "variant": variant or "default", + "variant": resolved.variant, "slug": ( - f"{organization}/{resolved_collection or collection}/{dataset}/{variant or 'default'}" + f"{organization}/{resolved_collection or collection}/{dataset}/{resolved.variant}" if organization - else f"{resolved_collection or collection}/{dataset}/{variant or 'default'}" + else f"{resolved_collection or collection}/{dataset}/{resolved.variant}" ), - "cid": final_cid, + "cid": resolved.cid, "url": None, "timestamp": None, "source": "stac", @@ -490,14 +556,18 @@ def list_datasets(self) -> typing.Dict[str, typing.Dict[str, typing.Any]]: if self._stac_server_url: try: return list_available_datasets_from_stac_server(self._stac_server_url) - except (requests.RequestException, ValueError): + except (httpx.HTTPError, ValueError): pass # Fallback: walk the IPFS-hosted catalog. from .stac_catalog import load_stac_catalog, list_available_datasets if self._stac_catalog is None: - self._stac_catalog = load_stac_catalog(gateway_url=self._gateway_base_url) + self._stac_catalog = load_stac_catalog( + gateway_url=self._catalog_gateway_base_url, + headers=self._headers, + auth=self._auth, + ) return list_available_datasets(self._stac_catalog) @@ -513,7 +583,7 @@ async def alist_datasets(self) -> typing.Dict[str, typing.Dict[str, typing.Any]] return await asyncio.to_thread( list_available_datasets_from_stac_server, self._stac_server_url ) - except (requests.RequestException, ValueError): + except (httpx.HTTPError, ValueError): pass from .stac_catalog import load_stac_catalog, list_available_datasets @@ -522,7 +592,10 @@ async def alist_datasets(self) -> typing.Dict[str, typing.Dict[str, typing.Any]] async with self._stac_catalog_lock: if self._stac_catalog is None: self._stac_catalog = await asyncio.to_thread( - load_stac_catalog, gateway_url=self._gateway_base_url + load_stac_catalog, + gateway_url=self._catalog_gateway_base_url, + headers=self._headers, + auth=self._auth, ) return await asyncio.to_thread(list_available_datasets, self._stac_catalog) diff --git a/dclimate_client_py/encryption_codec.py b/dclimate_client_py/encryption_codec.py index 8f013ba..ea326cb 100644 --- a/dclimate_client_py/encryption_codec.py +++ b/dclimate_client_py/encryption_codec.py @@ -1,5 +1,5 @@ import asyncio -from typing import Self +from typing import Self, cast from zarr.abc.codec import BytesBytesCodec from zarr.core.buffer import Buffer from zarr.core.common import JSON @@ -43,8 +43,8 @@ def from_dict(cls, data: dict[str, JSON]) -> Self: Returns: Self: An instance of EncryptionCodec. """ - configuration = data.get("configuration", {}) - header = configuration.get("header", "dclimate-Zarr") + configuration = cast(dict[str, JSON], data.get("configuration", {})) + header = cast(str, configuration.get("header", "dclimate-Zarr")) return cls(header=header) def to_dict(self) -> dict[str, JSON]: diff --git a/dclimate_client_py/geotemporal_data.py b/dclimate_client_py/geotemporal_data.py index e671722..392fee4 100644 --- a/dclimate_client_py/geotemporal_data.py +++ b/dclimate_client_py/geotemporal_data.py @@ -40,7 +40,12 @@ class GeotemporalData: data variable. """ - def __init__(self, data: xr.Dataset, dataset_name: str, data_var: str = None): + def __init__( + self, + data: xr.Dataset, + dataset_name: str, + data_var: typing.Optional[str] = None, + ): self.data = data self.dataset_name = dataset_name self._data_var = data_var @@ -709,7 +714,8 @@ def as_dict(self) -> dict: """ vals = self.data_var.values ret_dict = {} - dimensions = [] + dimensions: list[typing.Any] = [] + missing_value: typing.Any = None ret_dict["units"] = self.data_var.attrs.get("units", "unknown") if "time" in self.data: ret_dict["times"] = ( @@ -724,13 +730,17 @@ def as_dict(self) -> dict: ) ret_dict["point_coords_order"] = ["latitude", "longitude"] dimensions.insert(0, "point") - ret_dict["data"] = np.where(~np.isfinite(vals), None, vals).T.tolist() + ret_dict["data"] = np.where( + ~np.isfinite(vals), missing_value, vals + ).T.tolist() else: for dim in self.data_var.dims: if dim != "time": ret_dict[f"{dim}s"] = self.data[dim].values.flatten().tolist() dimensions.append(dim) - ret_dict["data"] = np.where(~np.isfinite(vals), None, vals).tolist() + ret_dict["data"] = np.where( + ~np.isfinite(vals), missing_value, vals + ).tolist() ret_dict["dimensions_order"] = dimensions try: if self.data.update_in_progress and not self.data.update_is_append_only: @@ -742,19 +752,67 @@ def as_dict(self) -> dict: def query( self, forecast_reference_time: typing.Union[str, datetime.datetime, None] = None, - point_kwargs: dict = None, - circle_kwargs: dict = None, - rectangle_kwargs: dict = None, - polygon_kwargs: dict = None, - multiple_points_kwargs: dict = None, - bounds: BoundsSelection = None, - bounds_options: dict = None, - spatial_agg_kwargs: dict = None, - temporal_agg_kwargs: dict = None, - rolling_agg_kwargs: dict = None, + point_kwargs: typing.Optional[dict] = None, + circle_kwargs: typing.Optional[dict] = None, + rectangle_kwargs: typing.Optional[dict] = None, + polygon_kwargs: typing.Optional[dict] = None, + multiple_points_kwargs: typing.Optional[dict] = None, + bounds: typing.Optional[BoundsSelection] = None, + bounds_options: typing.Optional[dict] = None, + spatial_agg_kwargs: typing.Optional[dict] = None, + temporal_agg_kwargs: typing.Optional[dict] = None, + rolling_agg_kwargs: typing.Optional[dict] = None, time_range: typing.Optional[typing.List[datetime.datetime]] = None, point_limit: int = DEFAULT_POINT_LIMIT, ) -> "GeotemporalData": + if point_kwargs is not None: + missing = [ + key + for key in ("latitude", "longitude") + if key not in point_kwargs or point_kwargs[key] is None + ] + if missing: + raise errors.InvalidSelectionError( + f"point_kwargs missing required key(s): {', '.join(missing)}" + ) + + if circle_kwargs is not None: + missing = [] + if "lat" in circle_kwargs: + if circle_kwargs["lat"] is None: + missing.append("lat") + elif circle_kwargs.get("center_lat") is None: + missing.append("center_lat" if "center_lat" in circle_kwargs else "lat") + if "lon" in circle_kwargs: + if circle_kwargs["lon"] is None: + missing.append("lon") + elif circle_kwargs.get("center_lon") is None: + missing.append("center_lon" if "center_lon" in circle_kwargs else "lon") + if circle_kwargs.get("radius") is None: + missing.append("radius") + if missing: + raise errors.InvalidSelectionError( + f"circle_kwargs missing required key(s): {', '.join(missing)}" + ) + + if rectangle_kwargs is not None: + missing = [ + key + for key in ("min_lat", "min_lon", "max_lat", "max_lon") + if key not in rectangle_kwargs or rectangle_kwargs[key] is None + ] + if missing: + raise errors.InvalidSelectionError( + f"rectangle_kwargs missing required key(s): {', '.join(missing)}" + ) + + if polygon_kwargs == {}: + raise errors.InvalidSelectionError("polygon_kwargs must not be empty") + if multiple_points_kwargs == {}: + raise errors.InvalidSelectionError( + "multiple_points_kwargs must not be empty" + ) + # Filter data down temporally, then spatially, and check that the size of # resulting dataset fits within the limit. While a user can get the entire DS by # providing no filters, this will almost certainly cause the size checks to fail @@ -764,9 +822,17 @@ def query( if point_kwargs: data = data.point(**point_kwargs) elif circle_kwargs: - lat = circle_kwargs.get("lat", circle_kwargs.get("center_lat")) - lon = circle_kwargs.get("lon", circle_kwargs.get("center_lon")) - radius = circle_kwargs.get("radius") + lat = ( + circle_kwargs["lat"] + if "lat" in circle_kwargs + else circle_kwargs["center_lat"] + ) + lon = ( + circle_kwargs["lon"] + if "lon" in circle_kwargs + else circle_kwargs["center_lon"] + ) + radius = circle_kwargs["radius"] data = data.circle(lat=lat, lon=lon, radius=radius) elif rectangle_kwargs: data = data.rectangle(**rectangle_kwargs) @@ -1020,7 +1086,7 @@ def _circle_bounding_box( latitude_dimension = latitudes.dims[0] longitude_dimension = longitudes.dims[0] - coordinate_endpoints = ( + coordinate_endpoints: tuple[typing.Any, ...] = ( latitudes.isel({latitude_dimension: 0}).values.item(), latitudes.isel({latitude_dimension: -1}).values.item(), longitudes.isel({longitude_dimension: 0}).values.item(), @@ -1057,11 +1123,11 @@ def _circle_bounding_box( def _haversine( - lat1: typing.Union[np.ndarray, float], - lon1: typing.Union[np.ndarray, float], - lat2: typing.Union[np.ndarray, float], - lon2: typing.Union[np.ndarray, float], -) -> typing.Union[np.ndarray, float]: + lat1: typing.Union[np.ndarray, xr.DataArray, float], + lon1: typing.Union[np.ndarray, xr.DataArray, float], + lat2: typing.Union[np.ndarray, xr.DataArray, float], + lon2: typing.Union[np.ndarray, xr.DataArray, float], +) -> typing.Union[np.ndarray, xr.DataArray, float]: """Calculates arclength distance in km between coordinate pairs, assuming the earth is a perfect sphere diff --git a/dclimate_client_py/ipfs_retrieval.py b/dclimate_client_py/ipfs_retrieval.py index 88198fa..45fe5a8 100644 --- a/dclimate_client_py/ipfs_retrieval.py +++ b/dclimate_client_py/ipfs_retrieval.py @@ -12,8 +12,6 @@ import aiohttp import httpx -import requests -import urllib3 import xarray as xr from multiformats import CID from opentelemetry import metrics, trace @@ -152,29 +150,16 @@ def _is_connection_error(exc: Exception) -> bool: TimeoutError, socket.gaierror, socket.herror, - requests.exceptions.ConnectionError, - requests.exceptions.Timeout, - urllib3.exceptions.NewConnectionError, - urllib3.exceptions.ConnectTimeoutError, - urllib3.exceptions.ReadTimeoutError, - urllib3.exceptions.ProtocolError, httpx.TransportError, aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError, ) + # Note: exhausted HTTP-status retries (httpx.HTTPStatusError) are + # deliberately NOT connection errors — they take the HAMT fallback. current: BaseException | None = exc seen: set[int] = set() while current is not None and id(current) not in seen: seen.add(id(current)) - if isinstance(current, urllib3.exceptions.MaxRetryError): - # MaxRetryError also wraps exhausted HTTP-status retries. Those - # should take the normal HAMT fallback rather than masquerading - # as transport failures. - if isinstance(current.reason, urllib3.exceptions.ResponseError): - return False - if isinstance(current.reason, BaseException): - current = current.reason - continue if isinstance(current, connection_error_types): return True current = current.__cause__ or current.__context__ diff --git a/dclimate_client_py/py.typed b/dclimate_client_py/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/dclimate_client_py/siren/siren_client.py b/dclimate_client_py/siren/siren_client.py index d18c450..af603a8 100644 --- a/dclimate_client_py/siren/siren_client.py +++ b/dclimate_client_py/siren/siren_client.py @@ -7,6 +7,7 @@ import datetime import inspect import os +from collections.abc import Mapping from typing import Any, Optional, TypeGuard from urllib.parse import quote @@ -200,7 +201,9 @@ def _build_x402_client(x402_client_class: Any, facilitator_url: str | None) -> A return x402_client_class() try: - params = inspect.signature(x402_client_class).parameters + params: Mapping[str, inspect.Parameter] = inspect.signature( + x402_client_class + ).parameters except (TypeError, ValueError): params = {} @@ -401,14 +404,18 @@ async def _get_x402_fetch(self) -> Any: client = x402Client() for network in _network_preferences(self._auth.network): client.register_policy(prefer_network(network)) - register_exact_evm_client(client, self._auth.signer) + register_exact_evm_client( + client, + self._auth.signer, # type: ignore[arg-type] + ) - self._x402_http_client = x402HttpxClient(client, timeout=self._timeout) + http_client = x402HttpxClient(client, timeout=self._timeout) + self._x402_http_client = http_client async def wrapped_fetch( url: str, method: str = "GET", **kwargs: Any ) -> httpx.Response: - return await self._x402_http_client.request(method, url, **kwargs) + return await http_client.request(method, url, **kwargs) self._x402_fetch = wrapped_fetch return self._x402_fetch diff --git a/dclimate_client_py/stac_catalog.py b/dclimate_client_py/stac_catalog.py index 1b390dc..ab35a64 100644 --- a/dclimate_client_py/stac_catalog.py +++ b/dclimate_client_py/stac_catalog.py @@ -5,23 +5,45 @@ for discovering and accessing dClimate datasets stored on IPFS. """ -from typing import Optional, Dict, List, Set, Tuple, Any +from os import PathLike, fspath +from typing import Optional, Dict, List, Set, Tuple, Any, cast import logging import weakref -import requests +from threading import Lock +from urllib.parse import urlsplit + +import httpx import pystac from .datasets import SpatialExtent, TemporalExtent from .stac_server import ( + ResolvedDataset, _dataset_and_variant_from_item_id, _dataset_and_variant_from_known_datasets, ) logger = logging.getLogger(__name__) STAC_CATALOG_URL = "https://ipfs-gateway.dclimate.net/stac" +_HTTP_CLIENT: httpx.Client | None = None +_HTTP_CLIENT_LOCK = Lock() + +def _client() -> httpx.Client: + """Return the process-wide pooled client used for synchronous STAC reads.""" + global _HTTP_CLIENT + if _HTTP_CLIENT is None: + with _HTTP_CLIENT_LOCK: + if _HTTP_CLIENT is None: + _HTTP_CLIENT = httpx.Client(timeout=30, follow_redirects=True) + return _HTTP_CLIENT -def get_root_catalog_cid(catalog_url: str = STAC_CATALOG_URL) -> str: + +def get_root_catalog_cid( + catalog_url: str = STAC_CATALOG_URL, + *, + headers: Optional[Dict[str, str]] = None, + auth: Optional[Tuple[str, str]] = None, +) -> str: """ Get the root STAC catalog CID. @@ -29,15 +51,21 @@ def get_root_catalog_cid(catalog_url: str = STAC_CATALOG_URL) -> str: Args: catalog_url: URL of the dClimate STAC root-CID pointer endpoint. + headers: Optional default headers for the request. The pointer endpoint + lives on the IPFS gateway host, so authenticated gateways need the + same credentials here as for ``/ipfs`` reads. + auth: Optional ``(username, password)`` basic-auth pair for the request. Returns: str: The IPFS CID of the root STAC catalog Raises: - requests.HTTPError: If the API request fails + httpx.HTTPError: If the API request fails KeyError: If the response doesn't contain the expected 'cid' field """ - response = requests.get(catalog_url, timeout=30) + # The pooled client is process-wide and gateway-agnostic, so credentials are + # applied per-request rather than baked into the shared client. + response = _client().get(catalog_url, timeout=30, headers=headers, auth=auth) response.raise_for_status() data = response.json() return data["cid"] @@ -84,7 +112,9 @@ def _resolve_child_by_dclimate_id( """Resolve a catalog child by its dclimate:id extra field.""" for link in parent.get_child_links(): if link.extra_fields.get("dclimate:id") == child_id: - return link.resolve_stac_object(root=parent).target, link + return cast( + pystac.Catalog, link.resolve_stac_object(root=parent).target + ), link return None, None @@ -103,13 +133,16 @@ def _resolve_child_by_collection_slug( if collection_slug not in collections: continue - org_catalog = link.resolve_stac_object(root=parent).target + org_catalog = cast(pystac.Catalog, link.resolve_stac_object(root=parent).target) if org_catalog is None: continue for col_link in org_catalog.get_child_links(): if col_link.extra_fields.get("dclimate:id") == collection_slug: - return col_link.resolve_stac_object(root=org_catalog).target, col_link + return cast( + pystac.Catalog, + col_link.resolve_stac_object(root=org_catalog).target, + ), col_link return None, None @@ -122,25 +155,43 @@ class IPFSStacIO(pystac.StacIO): that are stored on IPFS and referenced using ipfs:// protocol URIs. """ - def __init__(self, gateway_url: str): + def __init__( + self, + gateway_url: str, + *, + headers: Optional[Dict[str, str]] = None, + auth: Optional[Tuple[str, str]] = None, + ): """ Initialize the IPFS STAC I/O handler. Args: gateway_url: Base URL of the IPFS HTTP gateway (e.g., 'https://ipfs-gateway.dclimate.net') + headers: Optional default headers applied to every gateway request. + Required for authenticated gateways so catalog fallback does not + fail with 401. + auth: Optional ``(username, password)`` basic-auth pair applied to + every gateway request. """ self.gateway_url = gateway_url.rstrip("/") - # Shared across asyncio.to_thread workers. urllib3's connection pool - # is thread-safe for stateless GETs; the cookie jar is not, but IPFS - # gateway reads never depend on cookies. - self.session = requests.Session() + # Per-instance client so each catalog owns its pool lifecycle + # (closed via weakref.finalize when the catalog is collected). + # httpx.Client is thread-safe across asyncio.to_thread workers. + # Credentials are baked into the client so every gateway read carries + # them, mirroring the KuboCAS data path. + self.client = httpx.Client( + timeout=30, + follow_redirects=True, + headers=headers, + auth=auth, + ) - def read_text(self, source: str, *args, **kwargs) -> str: + def read_text(self, source: str | PathLike[str], *args, **kwargs) -> str: """ Read text content from a source URI. If the source starts with 'ipfs://', resolves it via the HTTP gateway. - Otherwise, delegates to the default StacIO implementation. + HTTP(S) sources are fetched directly by the owned HTTP client. Args: source: URI to read from (e.g., 'ipfs://bafkrei...' or 'https://...') @@ -149,19 +200,28 @@ def read_text(self, source: str, *args, **kwargs) -> str: str: The text content Raises: - requests.HTTPError: If the HTTP request fails + httpx.HTTPError: If the HTTP request fails """ - if source.startswith("ipfs://"): - cid = source.replace("ipfs://", "") + source_text = fspath(source) + if source_text.startswith("ipfs://"): + cid = source_text.replace("ipfs://", "") url = f"{self.gateway_url}/ipfs/{cid}" - response = self.session.get(url, timeout=30) + response = self.client.get(url, timeout=30) + response.raise_for_status() + return response.text + + scheme = urlsplit(source_text).scheme.lower() + if scheme in {"http", "https"}: + response = self.client.get(source_text) response.raise_for_status() return response.text - # Fall back to default behavior for HTTP/HTTPS URLs - return super().read_text(source, *args, **kwargs) + unsupported_scheme = scheme or "" + raise ValueError( + f"Unsupported STAC source scheme '{unsupported_scheme}': {source_text}" + ) - def write_text(self, dest: str, txt: str, *args, **kwargs) -> None: + def write_text(self, dest: str | PathLike[str], txt: str, *args, **kwargs) -> None: """ Write text content is not supported for IPFS. @@ -171,14 +231,17 @@ def write_text(self, dest: str, txt: str, *args, **kwargs) -> None: raise NotImplementedError("Writing to IPFS is not supported via StacIO") def close(self) -> None: - """Close the pooled HTTP session owned by this I/O handler.""" - self.session.close() + """Close the pooled HTTP client owned by this I/O handler.""" + self.client.close() def load_stac_catalog( gateway_url: str, root_cid: Optional[str] = None, catalog_url: str = STAC_CATALOG_URL, + *, + headers: Optional[Dict[str, str]] = None, + auth: Optional[Tuple[str, str]] = None, ) -> pystac.Catalog: """ Load the dClimate STAC catalog from IPFS. @@ -187,20 +250,25 @@ def load_stac_catalog( gateway_url: Base URL of the IPFS HTTP gateway root_cid: Optional IPFS CID of the root catalog. If None, fetches via get_root_catalog_cid() catalog_url: Root-CID pointer endpoint used when ``root_cid`` is omitted. + headers: Optional default headers for every gateway/pointer request. + Pass the same credentials used for the KuboCAS data path so catalog + fallback works against authenticated gateways. + auth: Optional ``(username, password)`` basic-auth pair for every + gateway/pointer request. Returns: pystac.Catalog: The loaded STAC catalog with all links and references Raises: - requests.HTTPError: If fetching from IPFS fails + httpx.HTTPError: If fetching from IPFS fails pystac.STACError: If the catalog structure is invalid """ if root_cid is None: - root_cid = get_root_catalog_cid(catalog_url) + root_cid = get_root_catalog_cid(catalog_url, headers=headers, auth=auth) # Bind the I/O handler to this catalog. Avoid pystac's process-global # default, because concurrent clients may use different gateways. - stac_io = IPFSStacIO(gateway_url) + stac_io = IPFSStacIO(gateway_url, headers=headers, auth=auth) # Load the root catalog catalog_uri = f"ipfs://{root_cid}" @@ -223,10 +291,12 @@ def resolve_dataset_cid_from_stac( dataset: str, variant: Optional[str] = None, organization: Optional[str] = None, -) -> str: +) -> ResolvedDataset: """ Resolve a dataset to its IPFS CID by querying the STAC catalog. + Changed in 0.6: returns ResolvedDataset. + This function navigates the STAC catalog structure to find the specific dataset variant and extracts the Zarr data CID from the STAC Item's assets. @@ -243,7 +313,7 @@ def resolve_dataset_cid_from_stac( catalog metadata. Returns: - str: The IPFS CID of the Zarr dataset (without 'ipfs://' prefix) + ResolvedDataset: The IPFS CID and selected variant Raises: ValueError: If collection, dataset, or variant is not found in the catalog @@ -314,6 +384,7 @@ def resolve_dataset_cid_from_stac( candidates = [] selected_item = None + selected_variant = None for item in items: # Item IDs follow pattern: "{collection_id}-{dataset}" or "-{variant}" properties = item.properties or {} @@ -351,6 +422,7 @@ def resolve_dataset_cid_from_stac( if variant is not None and item_variant == variant: selected_item = item + selected_variant = variant break if variant is not None: if not selected_item: @@ -364,21 +436,23 @@ def resolve_dataset_cid_from_stac( ) # If multiple variants exist and none specified, pick a sensible default preferred_order = ["default", "final", "finalized", "latest"] - selected_item = candidates[0][1] + selected_variant, selected_item = candidates[0] for preferred in preferred_order: for cand_variant, cand_item in candidates: if cand_variant == preferred: selected_item = cand_item + selected_variant = cand_variant break else: continue break + assert selected_variant is not None if "data" in selected_item.assets: href = selected_item.assets["data"].href if href.startswith("ipfs://"): - return href.replace("ipfs://", "") - return href + href = href.replace("ipfs://", "") + return ResolvedDataset(href, selected_variant) raise ValueError(f"Item '{selected_item.id}' does not have a 'data' asset") @@ -444,7 +518,9 @@ def list_available_datasets(catalog: pystac.Catalog) -> Dict[str, Dict[str, Any] if is_org: org_id = child_id org_title = link.title or org_id - org_catalog = link.resolve_stac_object(root=catalog).target + org_catalog = cast( + pystac.Catalog, link.resolve_stac_object(root=catalog).target + ) # Map collection -> category (historical/forecast/etc.) collection_categories: Dict[str, str] = {} @@ -489,7 +565,10 @@ def list_available_datasets(catalog: pystac.Catalog) -> Dict[str, Dict[str, Any] # Resolve items to extract per-variant extents try: - col_catalog = col_link.resolve_stac_object(root=org_catalog).target + col_catalog = cast( + pystac.Catalog, + col_link.resolve_stac_object(root=org_catalog).target, + ) if col_catalog is not None: items = list(col_catalog.get_items()) known_datasets = set(types) diff --git a/dclimate_client_py/stac_server.py b/dclimate_client_py/stac_server.py index fd7b03f..dd4748e 100644 --- a/dclimate_client_py/stac_server.py +++ b/dclimate_client_py/stac_server.py @@ -7,10 +7,11 @@ from collections.abc import Iterator from json import dumps -from typing import Any, Dict, Iterable, Optional, Set +from threading import Lock +from typing import Any, Dict, Iterable, NamedTuple, Optional, Set from urllib.parse import urljoin -import requests +import httpx from .datasets import SpatialExtent, TemporalExtent @@ -18,6 +19,23 @@ _MAX_SEARCH_PAGES = 50 +_HTTP_CLIENT: httpx.Client | None = None +_HTTP_CLIENT_LOCK = Lock() + + +def _client() -> httpx.Client: + """Return the process-wide pooled client used for synchronous STAC calls.""" + global _HTTP_CLIENT + if _HTTP_CLIENT is None: + with _HTTP_CLIENT_LOCK: + if _HTTP_CLIENT is None: + _HTTP_CLIENT = httpx.Client(timeout=30, follow_redirects=True) + return _HTTP_CLIENT + + +class ResolvedDataset(NamedTuple): + cid: str + variant: str def _dataset_and_variant_from_item_id( @@ -166,12 +184,12 @@ def _search_pages( } if request_headers: request_kwargs["headers"] = request_headers - response = requests.post(url, **request_kwargs) + response = _client().post(url, **request_kwargs) else: request_kwargs = {"params": request_body or None, "timeout": timeout} if request_headers: request_kwargs["headers"] = request_headers - response = requests.get(url, **request_kwargs) + response = _client().get(url, **request_kwargs) response.raise_for_status() page = response.json() yield page @@ -217,10 +235,13 @@ def resolve_cid_from_stac_server( dataset: str, variant: Optional[str] = None, server_url: str = STAC_SERVER_URL, -) -> str: +) -> ResolvedDataset: """ Resolve dataset CID via STAC server /search API. + Changed in 0.6: returns ResolvedDataset; variant='' is treated as an + explicit (unresolvable) variant rather than no-variant. + Uses the same API format as the frontend (POST /search with collections filter). Args: @@ -230,11 +251,11 @@ def resolve_cid_from_stac_server( server_url: STAC server base URL Returns: - str: The IPFS CID of the Zarr dataset (without 'ipfs://' prefix) + ResolvedDataset: The IPFS CID and selected variant Raises: ValueError: If dataset or variant is not found - requests.HTTPError: If the server request fails + httpx.HTTPError: If the server request fails """ # Search by collection body = { @@ -272,7 +293,7 @@ def _effective_variant(feature: Dict[str, Any]) -> str: raise ValueError(f"No items found for {collection}/{dataset}") # Select by variant or use default preference - if variant: + if variant is not None: item = next( (f for f in matches if _effective_variant(f) == variant), None, @@ -294,11 +315,12 @@ def _effective_variant(feature: Dict[str, Any]) -> str: break # Extract CID from asset + selected_variant = variant if variant is not None else _effective_variant(item) href = item.get("assets", {}).get("data", {}).get("href", "") if href.startswith("ipfs://"): - return href.replace("ipfs://", "") + return ResolvedDataset(href.replace("ipfs://", ""), selected_variant) if href: - return href + return ResolvedDataset(href, selected_variant) raise ValueError(f"Item '{item['id']}' has no data asset") @@ -336,7 +358,7 @@ def list_available_datasets_from_stac_server( disagree. - Search pagination is bounded to avoid looping on malformed ``next`` links. """ - collections_resp = requests.get(f"{server_url}/collections", timeout=10) + collections_resp = _client().get(f"{server_url}/collections", timeout=10) collections_resp.raise_for_status() collections_body = collections_resp.json() diff --git a/pyproject.toml b/pyproject.toml index d0de868..32cba39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "pdm.backend" [project] name = "dclimate-client-py" -version = "0.5.11" # Set a static version or handle it in versioning strategy +version = "0.6.0" # Set a static version or handle it in versioning strategy description = "Python client library for accessing dClimate weather and climate data" readme = "README.md" license = {text = "MIT"} @@ -38,10 +38,8 @@ dependencies = [ # while py-hamt 3.x currently constrains zarr below 3.1. "numcodecs[crc32c]>=0.14,<0.16", "numpy>=2.1.3", - "py_hamt>=3.4.1", + "py_hamt>=3.5.0", "multiformats>=0.3.1", - "requests>=2.31.0", - "urllib3>=2.0.0", "geopandas>=1.0.0", "pandas>=2.2.0", "s3fs>=2024.6.0", @@ -60,15 +58,16 @@ dependencies = [ Homepage = "https://dclimate.net/" [project.optional-dependencies] -testing = ["pytest", "pytest-cov", "pytest-mock", "pytest-asyncio>=1.3.0"] +testing = [ + "mypy>=1.14", + "pytest", + "pytest-cov", + "pytest-mock", + "pytest-asyncio>=1.3.0", +] dev = ["pre-commit>=4.1.0", "ruff>=0.9.5"] examples = ["python-dotenv>=1.0.0"] -[tool.uv.sources] -# Development integration pin for py-hamt PR #88. Published artifacts retain -# the released runtime floor because PyPI rejects direct-URL requirements. -py_hamt = { git = "https://github.com/dClimate/py-hamt", rev = "942580df089be13d6b8b803fc942932d65f2fc7f" } - [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" @@ -80,3 +79,7 @@ markers = [ "integration: marks integration tests (often slower, may require external services like IPFS)", # Add other custom marks here if you create more ] + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true diff --git a/scripts/benchmark_gateway.py b/scripts/benchmark_gateway.py new file mode 100644 index 0000000..24a8e85 --- /dev/null +++ b/scripts/benchmark_gateway.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Benchmark bounded dClimate gateway reads using the py-hamt #58 methodology. + +The benchmark compares HTTP/2 with HTTP/1.1 while holding Kubo request +concurrency and the dataset sample constant. The companion infrastructure +change is to raise nginx's ``keepalive_requests`` from its default of 1000 on +the dClimate gateway: large dataset opens otherwise cause GOAWAY churn. +Benchmark HTTP/2 on and off both before and after that gateway change. +""" + +import argparse +import json +import statistics +import sys +import time +from typing import Any + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--http2", + action=argparse.BooleanOptionalAction, + default=True, + help="enable HTTP/2 for gateway requests (default: enabled)", + ) + parser.add_argument( + "--concurrency", + type=int, + default=32, + help="maximum concurrent Kubo gateway requests (default: 32)", + ) + parser.add_argument( + "--repetitions", + type=int, + default=3, + help="number of timed dataset opens (default: 3)", + ) + parser.add_argument("--collection", default="cpc-precip-conus") + parser.add_argument("--dataset", default="precip") + parser.add_argument("--variant", default=None) + parser.add_argument("--gateway", default="https://ipfs-gateway.dclimate.net") + parser.add_argument("--stac-server", default="https://api.stac.dclimate.net") + return parser + + +async def _benchmark(args: argparse.Namespace) -> dict[str, Any]: + import httpx + + from dclimate_client_py import dClimateClient + + elapsed_times: list[float] = [] + bytes_read: list[int] = [] + + for repetition in range(1, args.repetitions + 1): + started = time.perf_counter() + async with dClimateClient( + gateway_base_url=args.gateway, + rpc_base_url=args.gateway, + stac_server_url=args.stac_server, + concurrency=args.concurrency, + client_factory=lambda: httpx.AsyncClient( + http2=args.http2, + timeout=60.0, + follow_redirects=True, + ), + ) as client: + dataset, _metadata = await client.load_dataset( + collection=args.collection, + dataset=args.dataset, + variant=args.variant, + return_xarray=True, + ) + bounded_indexers = { + dimension: slice(0, min(size, 8)) + for dimension, size in dataset.sizes.items() + } + sample = dataset.isel(bounded_indexers).load() + sample_bytes = sample.nbytes + elapsed = time.perf_counter() - started + elapsed_times.append(elapsed) + bytes_read.append(sample_bytes) + print( + f"repetition {repetition}/{args.repetitions}: " + f"{elapsed:.3f} s ({sample_bytes} sample bytes)", + file=sys.stderr, + ) + + median = statistics.median(elapsed_times) + print(f"median: {median:.3f} s", file=sys.stderr) + return { + "http2": args.http2, + "concurrency": args.concurrency, + "repetitions": args.repetitions, + "collection": args.collection, + "dataset": args.dataset, + "variant": args.variant, + "gateway": args.gateway, + "stac_server": args.stac_server, + "times_seconds": elapsed_times, + "median_seconds": median, + "sample_bytes": bytes_read, + } + + +def main() -> None: + parser = _parser() + args = parser.parse_args() + if args.concurrency <= 0: + parser.error("--concurrency must be positive") + if args.repetitions <= 0: + parser.error("--repetitions must be positive") + + import asyncio + + try: + result = asyncio.run(_benchmark(args)) + except Exception as exc: # noqa: BLE001 - CLI failures should be one concise line + print(f"failure: {type(exc).__name__}: {exc}", file=sys.stderr) + raise SystemExit(1) from None + else: + print(json.dumps(result, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index e7c7e84..c91e9de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,13 +6,34 @@ import numpy as np import pytest import xarray as xr -import requests # Import requests here for the check +import httpx import zarr import zarr.storage from tests.ipfs_config import IPFS_GATEWAY_URL, IPFS_RPC_URL, STAC_CATALOG_URL +@pytest.fixture +def install_httpx_mock(monkeypatch): + """Inject a pooled MockTransport client through a module's client accessor.""" + clients: list[httpx.Client] = [] + + def install(module, handler): + client = httpx.Client( + transport=httpx.MockTransport(handler), + timeout=30, + follow_redirects=True, + ) + clients.append(client) + monkeypatch.setattr(module, "_client", lambda: client) + return client + + yield install + + for client in clients: + client.close() + + def pytest_addoption(parser): """Add custom pytest command line options.""" parser.addoption( @@ -170,8 +191,10 @@ def is_ipfs_running(gateway_url: str) -> bool: # Use a known immutable CID (e.g., the empty directory CID) # Let's try a known immutable path: "Hello from IPFS Gateway Checker" known_cid = "bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m" # Example file - response = requests.head( - f"{gateway_url.rstrip('/')}/ipfs/{known_cid}", timeout=5 + response = httpx.head( + f"{gateway_url.rstrip('/')}/ipfs/{known_cid}", + timeout=5, + follow_redirects=True, ) # Allow 200 OK or 404 Not Found (if CID isn't locally available but gateway is up) # Avoid checking strict 200 as CID might not be pinned locally but gateway is running @@ -185,13 +208,13 @@ def is_ipfs_running(gateway_url: str) -> bool: f"IPFS Gateway check failed (Status: {response.status_code}) at {gateway_url}" ) return False - except requests.exceptions.ConnectionError: + except httpx.ConnectError: print(f"IPFS Gateway connection failed at {gateway_url}") return False - except requests.exceptions.Timeout: + except httpx.TimeoutException: print(f"IPFS Gateway check timed out at {gateway_url}") return False - except requests.exceptions.RequestException as e: + except httpx.HTTPError as e: print(f"IPFS Gateway check failed with unexpected error: {e}") return False @@ -199,22 +222,22 @@ def is_ipfs_running(gateway_url: str) -> bool: def is_ipfs_rpc_running(rpc_url: str) -> bool: """Check whether the writable Kubo RPC API is responsive.""" try: - response = requests.post(f"{rpc_url}/api/v0/id", timeout=5) + response = httpx.post(f"{rpc_url}/api/v0/id", timeout=5, follow_redirects=True) response.raise_for_status() payload = response.json() return isinstance(payload, dict) and bool(payload.get("ID")) - except (requests.exceptions.RequestException, ValueError): + except (httpx.HTTPError, ValueError): return False def is_stac_pointer_running(catalog_url: str) -> bool: """Check whether the STAC pointer returns a non-empty root CID.""" try: - response = requests.get(catalog_url, timeout=5) + response = httpx.get(catalog_url, timeout=5, follow_redirects=True) response.raise_for_status() payload = response.json() return isinstance(payload, dict) and bool(payload.get("cid")) - except (requests.exceptions.RequestException, ValueError): + except (httpx.HTTPError, ValueError): return False diff --git a/tests/test_ipfs_retrieval.py b/tests/test_ipfs_retrieval.py index c9b9a28..8b59c3c 100644 --- a/tests/test_ipfs_retrieval.py +++ b/tests/test_ipfs_retrieval.py @@ -2,8 +2,6 @@ import httpx import pytest -import requests -import urllib3 import xarray as xr import dclimate_client_py.dclimate_client as dclimate_client_module @@ -41,14 +39,10 @@ def _chained(wrapper: Exception, cause: Exception) -> Exception: [ ConnectionError("connection refused"), TimeoutError("timed out opening sharded store"), - requests.ConnectionError("max retries exceeded"), - requests.Timeout("gateway timed out"), + httpx.ConnectTimeout("connect timed out"), + httpx.ReadTimeout("gateway timed out"), httpx.ConnectError("connection refused"), - urllib3.exceptions.MaxRetryError( - None, - "http://gateway", - urllib3.exceptions.NewConnectionError(None, "connection refused"), - ), + httpx.ConnectError("max retries exceeded"), _chained(RuntimeError("wrapped"), httpx.ReadTimeout("gateway timed out")), ], ) @@ -63,11 +57,18 @@ def test_is_connection_error_classifies_gateway_failures(error): PermissionError("permission denied"), IsADirectoryError("is a directory"), ValueError("not a sharded zarr store"), - requests.HTTPError("500 Server Error: Internal Server Error"), - urllib3.exceptions.MaxRetryError( - None, - "http://gateway", - urllib3.exceptions.ResponseError("too many 500 responses"), + httpx.HTTPStatusError( + "500 Server Error: Internal Server Error", + request=httpx.Request("GET", "https://gateway.example"), + response=httpx.Response(500), + ), + _chained( + RuntimeError("retries exhausted on 500s"), + httpx.HTTPStatusError( + "too many 500 responses", + request=httpx.Request("GET", "https://gateway.example"), + response=httpx.Response(500), + ), ), _chained(RuntimeError("wrapped"), FileNotFoundError("missing metadata")), ], diff --git a/tests/test_list_datasets_parity.py b/tests/test_list_datasets_parity.py index 2d197aa..6603474 100644 --- a/tests/test_list_datasets_parity.py +++ b/tests/test_list_datasets_parity.py @@ -30,8 +30,8 @@ import os from typing import Any, Dict +import httpx import pytest -import requests from dclimate_client_py.stac_catalog import ( load_stac_catalog, @@ -58,11 +58,16 @@ def _probe(url: str, *, post: bool = False, timeout: float = 10.0) -> bool: try: if post: - resp = requests.post(url, json={"limit": 1}, timeout=timeout) + resp = httpx.post( + url, + json={"limit": 1}, + timeout=timeout, + follow_redirects=True, + ) else: - resp = requests.get(url, timeout=timeout) - return resp.ok - except (requests.ConnectionError, requests.Timeout, requests.RequestException): + resp = httpx.get(url, timeout=timeout, follow_redirects=True) + return resp.is_success + except httpx.HTTPError: return False diff --git a/tests/test_pytest_infra.py b/tests/test_pytest_infra.py index 88d5e6b..5544cd3 100644 --- a/tests/test_pytest_infra.py +++ b/tests/test_pytest_infra.py @@ -134,7 +134,7 @@ def test_ipfs_rpc_probe_requires_successful_kubo_identity( class Response: def raise_for_status(self): if status >= 400: - raise suite_conftest.requests.HTTPError(f"HTTP {status}") + raise suite_conftest.httpx.HTTPError(f"HTTP {status}") def json(self): if isinstance(payload, Exception): @@ -142,7 +142,7 @@ def json(self): return payload monkeypatch.setattr( - suite_conftest.requests, "post", lambda *args, **kwargs: Response() + suite_conftest.httpx, "post", lambda *args, **kwargs: Response() ) assert suite_conftest.is_ipfs_rpc_running("https://rpc.example") is expected @@ -163,15 +163,13 @@ def test_stac_pointer_probe_requires_successful_root_cid( class Response: def raise_for_status(self): if status >= 400: - raise suite_conftest.requests.HTTPError(f"HTTP {status}") + raise suite_conftest.httpx.HTTPError(f"HTTP {status}") def json(self): if isinstance(payload, Exception): raise payload return payload - monkeypatch.setattr( - suite_conftest.requests, "get", lambda *args, **kwargs: Response() - ) + monkeypatch.setattr(suite_conftest.httpx, "get", lambda *args, **kwargs: Response()) assert suite_conftest.is_stac_pointer_running("https://catalog.example") is expected diff --git a/tests/test_review_bugs_stac_server.py b/tests/test_review_bugs_stac_server.py index 332280f..844a6e0 100644 --- a/tests/test_review_bugs_stac_server.py +++ b/tests/test_review_bugs_stac_server.py @@ -1,28 +1,33 @@ -from typing import Any - +import json +import httpx +import pytest import dclimate_client_py.stac_server as stac_server -class _Response: - def __init__(self, payload: dict[str, Any]): - self._payload = payload +_install_mock_client = None - def json(self): - return self._payload - def raise_for_status(self): - pass +@pytest.fixture(autouse=True) +def _use_managed_httpx_clients(install_httpx_mock): + global _install_mock_client + _install_mock_client = install_httpx_mock + yield + _install_mock_client = None def _mock_search(monkeypatch, features): - def post(url, *, json, timeout): - assert url == "https://stac.example/search" - assert json == {"limit": 100, "collections": ["ecmwf_era5"]} - assert timeout == 10 - return _Response({"features": features}) + def handler(request: httpx.Request) -> httpx.Response: + assert request.url == "https://stac.example/search" + assert json.loads(request.content) == { + "limit": 100, + "collections": ["ecmwf_era5"], + } + assert set(request.extensions["timeout"].values()) == {10} + return httpx.Response(200, json={"features": features}, request=request) - monkeypatch.setattr(stac_server.requests, "post", post) + assert _install_mock_client is not None + _install_mock_client(stac_server, handler) def test_resolve_variant_falls_back_to_variant_encoded_in_item_id(monkeypatch): @@ -38,14 +43,15 @@ def test_resolve_variant_falls_back_to_variant_encoded_in_item_id(monkeypatch): ], ) - cid = stac_server.resolve_cid_from_stac_server( + resolved = stac_server.resolve_cid_from_stac_server( "ecmwf_era5", "temperature", variant="finalized", server_url="https://stac.example", ) - assert cid == "bafy-temperature-finalized" + assert resolved.cid == "bafy-temperature-finalized" + assert resolved.variant == "finalized" def test_resolve_feature_without_properties(monkeypatch): @@ -60,13 +66,14 @@ def test_resolve_feature_without_properties(monkeypatch): ], ) - cid = stac_server.resolve_cid_from_stac_server( + resolved = stac_server.resolve_cid_from_stac_server( "ecmwf_era5", "temperature", server_url="https://stac.example", ) - assert cid == "bafy-temperature" + assert resolved.cid == "bafy-temperature" + assert resolved.variant == "default" def test_resolve_default_variant_matches_bare_item_id(monkeypatch): @@ -84,14 +91,15 @@ def test_resolve_default_variant_matches_bare_item_id(monkeypatch): ], ) - cid = stac_server.resolve_cid_from_stac_server( + resolved = stac_server.resolve_cid_from_stac_server( "ecmwf_era5", "temperature", variant="default", server_url="https://stac.example", ) - assert cid == "bafy-temperature" + assert resolved.cid == "bafy-temperature" + assert resolved.variant == "default" def test_resolve_without_variant_prefers_unnamed_item_over_latest(monkeypatch): @@ -111,10 +119,11 @@ def test_resolve_without_variant_prefers_unnamed_item_over_latest(monkeypatch): ], ) - cid = stac_server.resolve_cid_from_stac_server( + resolved = stac_server.resolve_cid_from_stac_server( "ecmwf_era5", "temperature", server_url="https://stac.example", ) - assert cid == "bafy-temperature" + assert resolved.cid == "bafy-temperature" + assert resolved.variant == "default" diff --git a/tests/test_review_fu_gateway_none.py b/tests/test_review_fu_gateway_none.py new file mode 100644 index 0000000..4685614 --- /dev/null +++ b/tests/test_review_fu_gateway_none.py @@ -0,0 +1,65 @@ +"""Regression coverage for catalog fallback with a defaulted IPFS gateway.""" + +from __future__ import annotations + +from typing import Any + +import pystac + +from dclimate_client_py import stac_catalog +from dclimate_client_py.dclimate_client import dClimateClient + + +DEFAULT_PUBLIC_GATEWAY = "https://ipfs-gateway.dclimate.net" + + +async def test_alist_datasets_uses_public_gateway_when_gateway_is_none( + monkeypatch, +) -> None: + catalog = pystac.Catalog(id="root", description="Minimal root catalog") + loaded_hrefs: list[str] = [] + constructed_gateways: list[Any] = [] + + monkeypatch.setattr( + stac_catalog, + "get_root_catalog_cid", + lambda catalog_url=stac_catalog.STAC_CATALOG_URL, **_: "bafy-review-root", + ) + + def fake_from_file( + cls: type[pystac.Catalog], + href: str, + stac_io: pystac.StacIO | None = None, + ) -> pystac.Catalog: + loaded_hrefs.append(href) + return catalog + + monkeypatch.setattr( + pystac.Catalog, + "from_file", + classmethod(fake_from_file), + ) + + original_init = stac_catalog.IPFSStacIO.__init__ + + def recording_init( + self: stac_catalog.IPFSStacIO, + gateway_url: str, + **kwargs: Any, + ) -> None: + constructed_gateways.append(gateway_url) + original_init(self, gateway_url, **kwargs) + + monkeypatch.setattr(stac_catalog.IPFSStacIO, "__init__", recording_init) + + client = dClimateClient(gateway_base_url=None, stac_server_url=None) + client._kubo_cas = object() + previous_default_io = pystac.StacIO._default_io + try: + datasets = await client.alist_datasets() + finally: + pystac.StacIO.set_default(previous_default_io) + + assert datasets == {} + assert loaded_hrefs == ["ipfs://bafy-review-root"] + assert constructed_gateways == [DEFAULT_PUBLIC_GATEWAY] diff --git a/tests/test_review_fu_httpx.py b/tests/test_review_fu_httpx.py new file mode 100644 index 0000000..5937336 --- /dev/null +++ b/tests/test_review_fu_httpx.py @@ -0,0 +1,150 @@ +"""Migration pins for consolidating package HTTP on httpx.""" + +from __future__ import annotations + +import ast +import re +import tomllib +from pathlib import Path +from typing import Any + +import httpx +import pystac +import xarray as xr + +import dclimate_client_py.dclimate_client as dclimate_client_module +from dclimate_client_py import stac_catalog +from dclimate_client_py.dclimate_client import dClimateClient +from dclimate_client_py.stac_catalog import IPFSStacIO +from dclimate_client_py.stac_server import ResolvedDataset + + +REPO_ROOT = Path(__file__).resolve().parents[1] +PACKAGE_ROOT = REPO_ROOT / "dclimate_client_py" +FORBIDDEN_HTTP_PACKAGES = {"requests", "urllib3"} + + +def test_package_sources_do_not_import_requests_or_urllib3() -> None: + offenders: list[str] = [] + + for source_path in sorted(PACKAGE_ROOT.rglob("*.py")): + tree = ast.parse(source_path.read_text(), filename=str(source_path)) + imports_forbidden_package = any( + ( + isinstance(node, ast.Import) + and any( + alias.name.split(".", 1)[0] in FORBIDDEN_HTTP_PACKAGES + for alias in node.names + ) + ) + or ( + isinstance(node, ast.ImportFrom) + and node.module is not None + and node.module.split(".", 1)[0] in FORBIDDEN_HTTP_PACKAGES + ) + for node in ast.walk(tree) + ) + if imports_forbidden_package: + offenders.append(str(source_path.relative_to(REPO_ROOT))) + + assert not offenders, ( + "package sources still use legacy HTTP packages:\n" + "\n".join(offenders) + ) + + +def test_project_dependencies_do_not_include_requests_or_urllib3() -> None: + with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) + + dependencies = pyproject["project"]["dependencies"] + forbidden_dependencies = [ + dependency + for dependency in dependencies + if re.split(r"[<>=!~;\s\[]", dependency, maxsplit=1)[0] + .lower() + .replace("_", "-") + in FORBIDDEN_HTTP_PACKAGES + ] + + assert not forbidden_dependencies, ( + "[project] dependencies still include legacy HTTP packages: " + + ", ".join(forbidden_dependencies) + ) + + +async def test_load_dataset_falls_back_when_stac_transport_is_unreachable( + monkeypatch, +) -> None: + """Non-regression pin: transport errors must still enter catalog fallback.""" + catalog = pystac.Catalog(id="root", description="Fallback catalog") + loaded_hrefs: list[str] = [] + catalog_resolutions: list[dict[str, Any]] = [] + + monkeypatch.setattr( + stac_catalog, + "get_root_catalog_cid", + lambda catalog_url=stac_catalog.STAC_CATALOG_URL, **_: "bafy-review-root", + ) + + def fake_from_file( + cls: type[pystac.Catalog], + href: str, + stac_io: pystac.StacIO | None = None, + ) -> pystac.Catalog: + loaded_hrefs.append(href) + return catalog + + monkeypatch.setattr( + pystac.Catalog, + "from_file", + classmethod(fake_from_file), + ) + monkeypatch.setattr( + stac_catalog, + "list_available_datasets", + lambda loaded_catalog: {"review_collection": {"types": ["temperature"]}}, + ) + + def resolve_from_catalog(**kwargs: Any) -> ResolvedDataset: + catalog_resolutions.append(kwargs) + return ResolvedDataset("bafy-fallback-dataset", "default") + + monkeypatch.setattr( + stac_catalog, + "resolve_dataset_cid_from_stac", + resolve_from_catalog, + ) + + async def fake_load_dataset(**kwargs: Any) -> xr.Dataset: + return xr.Dataset({"temperature": ("x", [1.0])}) + + monkeypatch.setattr( + dclimate_client_module, + "_load_dataset_from_ipfs_cid", + fake_load_dataset, + ) + + client = dClimateClient( + gateway_base_url="https://gateway.example", + stac_server_url="http://127.0.0.1:9", + ) + client._kubo_cas = object() + previous_default_io = pystac.StacIO._default_io + try: + _, metadata = await client.load_dataset( + dataset="temperature", + collection="review_collection", + return_xarray=True, + ) + finally: + pystac.StacIO.set_default(previous_default_io) + + assert loaded_hrefs == ["ipfs://bafy-review-root"] + assert len(catalog_resolutions) == 1 + assert metadata["cid"] == "bafy-fallback-dataset" + + +def test_ipfs_stac_io_owns_httpx_client() -> None: + stac_io = IPFSStacIO("https://gateway.example") + + assert isinstance(getattr(stac_io, "client", None), httpx.Client) diff --git a/tests/test_review_fu_kubo_knobs.py b/tests/test_review_fu_kubo_knobs.py new file mode 100644 index 0000000..19981ab --- /dev/null +++ b/tests/test_review_fu_kubo_knobs.py @@ -0,0 +1,162 @@ +"""Regression tests for exposing KuboCAS connection configuration.""" + +from __future__ import annotations + +import importlib +import inspect +from pathlib import Path +import subprocess +from typing import Any + +import pytest + +from dclimate_client_py.dclimate_client import dClimateClient + + +dclimate_client_module = importlib.import_module("dclimate_client_py.dclimate_client") +PROJECT_ROOT = Path(__file__).resolve().parents[1] +KUBO_OPTION_NAMES = ( + "concurrency", + "headers", + "auth", + "max_retries", + "initial_delay", + "backoff_factor", + "client_factory", +) + + +def _install_recording_kubo( + monkeypatch: pytest.MonkeyPatch, +) -> list[dict[str, Any]]: + recorded_calls: list[dict[str, Any]] = [] + + class RecordingKuboCAS: + def __init__(self, **kwargs: Any) -> None: + recorded_calls.append(kwargs) + + async def __aenter__(self) -> "RecordingKuboCAS": + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + return None + + monkeypatch.setattr(dclimate_client_module, "KuboCAS", RecordingKuboCAS) + return recorded_calls + + +async def test_kubo_connection_options_are_forwarded_exactly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recorded_calls = _install_recording_kubo(monkeypatch) + + async with dClimateClient( + concurrency=64, + headers={"Authorization": "Bearer x"}, + auth=("user", "pass"), + max_retries=5, + initial_delay=0.5, + backoff_factor=3.0, + ): + pass + + assert recorded_calls == [ + { + "gateway_base_url": "https://ipfs-gateway.dclimate.net", + "rpc_base_url": "https://ipfs-gateway.dclimate.net", + "concurrency": 64, + "headers": {"Authorization": "Bearer x"}, + "auth": ("user", "pass"), + "max_retries": 5, + "initial_delay": 0.5, + "backoff_factor": 3.0, + } + ] + + +async def test_default_construction_preserves_kubo_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + signature = inspect.signature(dClimateClient.__init__) + missing_options = set(KUBO_OPTION_NAMES) - set(signature.parameters) + assert not missing_options, f"missing Kubo options: {sorted(missing_options)}" + for option_name in KUBO_OPTION_NAMES: + assert signature.parameters[option_name].default is None + + recorded_calls = _install_recording_kubo(monkeypatch) + async with dClimateClient(): + pass + + assert recorded_calls == [ + { + "gateway_base_url": "https://ipfs-gateway.dclimate.net", + "rpc_base_url": "https://ipfs-gateway.dclimate.net", + } + ] + + +async def test_client_factory_is_forwarded_untouched( + monkeypatch: pytest.MonkeyPatch, +) -> None: + recorded_calls = _install_recording_kubo(monkeypatch) + + def client_factory() -> object: + return object() + + async with dClimateClient(client_factory=client_factory): + pass + + assert recorded_calls == [ + { + "gateway_base_url": "https://ipfs-gateway.dclimate.net", + "rpc_base_url": "https://ipfs-gateway.dclimate.net", + "client_factory": client_factory, + } + ] + + +@pytest.mark.parametrize( + "conflicting_option", + [ + {"headers": {"Authorization": "Bearer x"}}, + {"auth": ("user", "pass")}, + ], +) +def test_client_factory_rejects_headers_and_auth_at_construction( + conflicting_option: dict[str, Any], +) -> None: + def client_factory() -> object: + return object() + + with pytest.raises(ValueError, match="client_factory"): + dClimateClient(client_factory=client_factory, **conflicting_option) + + +@pytest.mark.parametrize( + "standalone_option", + [ + {"client_factory": lambda: object()}, + {"headers": {"Authorization": "Bearer x"}}, + {"auth": ("user", "pass")}, + ], +) +def test_client_factory_headers_and_auth_are_valid_alone( + standalone_option: dict[str, Any], +) -> None: + dClimateClient(**standalone_option) + + +def test_gateway_benchmark_script_exposes_tuning_flags() -> None: + completed = subprocess.run( + ["uv", "run", "python", "scripts/benchmark_gateway.py", "--help"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + output = completed.stdout + completed.stderr + assert completed.returncode == 0, output + for flag in ("--http2", "--no-http2", "--concurrency", "--repetitions"): + assert flag in output diff --git a/tests/test_review_fu_kwargs_validation.py b/tests/test_review_fu_kwargs_validation.py new file mode 100644 index 0000000..7969c16 --- /dev/null +++ b/tests/test_review_fu_kwargs_validation.py @@ -0,0 +1,83 @@ +"""Regression tests for query keyword validation at the public boundary.""" + +from __future__ import annotations + +import numpy as np +import pytest +import xarray as xr + +from dclimate_client_py import dclimate_zarr_errors as errors +from dclimate_client_py.geotemporal_data import GeotemporalData + + +@pytest.fixture +def geotemporal_data() -> GeotemporalData: + dataset = xr.Dataset( + { + "temperature": ( + ("latitude", "longitude"), + np.arange(9, dtype=float).reshape(3, 3), + ) + }, + coords={ + "latitude": [39.0, 40.0, 41.0], + "longitude": [-75.0, -74.0, -73.0], + }, + ) + return GeotemporalData(dataset, dataset_name="synthetic") + + +def test_query_rejects_circle_kwargs_missing_lon( + geotemporal_data: GeotemporalData, +) -> None: + with pytest.raises(errors.InvalidSelectionError, match="lon"): + geotemporal_data.query(circle_kwargs={"lat": 40.0}) + + +def test_query_rejects_none_required_circle_value( + geotemporal_data: GeotemporalData, +) -> None: + with pytest.raises(errors.InvalidSelectionError, match="lat"): + geotemporal_data.query( + circle_kwargs={"lat": None, "lon": -74.0, "radius": 10.0} + ) + + +def test_query_rejects_empty_point_kwargs( + geotemporal_data: GeotemporalData, +) -> None: + with pytest.raises(errors.InvalidSelectionError, match="latitude"): + geotemporal_data.query(point_kwargs={}) + + +def test_query_rejects_rectangle_kwargs_missing_max_lon( + geotemporal_data: GeotemporalData, +) -> None: + with pytest.raises(errors.InvalidSelectionError, match="max_lon"): + geotemporal_data.query( + rectangle_kwargs={ + "min_lat": 39.0, + "min_lon": -75.0, + "max_lat": 41.0, + } + ) + + +@pytest.mark.parametrize("keyword", ["polygon_kwargs", "multiple_points_kwargs"]) +def test_query_rejects_empty_selection_kwargs( + geotemporal_data: GeotemporalData, + keyword: str, +) -> None: + with pytest.raises(errors.InvalidSelectionError, match=keyword): + geotemporal_data.query(**{keyword: {}}) + + +def test_query_accepts_complete_circle_kwargs( + geotemporal_data: GeotemporalData, +) -> None: + selected = geotemporal_data.query( + circle_kwargs={"lat": 40.0, "lon": -74.0, "radius": 10.0} + ) + + assert selected.data.sizes == {"latitude": 1, "longitude": 1} + assert selected.data["temperature"].item() == 4.0 diff --git a/tests/test_review_fu_typing.py b/tests/test_review_fu_typing.py new file mode 100644 index 0000000..2b90119 --- /dev/null +++ b/tests/test_review_fu_typing.py @@ -0,0 +1,88 @@ +"""Regression tests for the package's PEP 561 typing contract.""" + +from __future__ import annotations + +import os +from pathlib import Path +import re +import subprocess +import zipfile + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +TYPECHECK_META_ENV = "DCLIMATE_TYPECHECK_META_TEST" + +pytestmark = pytest.mark.skipif( + os.environ.get(TYPECHECK_META_ENV) == "1", + reason="typing meta-tests do not run recursively", +) + + +def test_package_passes_mypy() -> None: + env = os.environ.copy() + env[TYPECHECK_META_ENV] = "1" + env["UV_CACHE_DIR"] = str(PROJECT_ROOT / ".uv-cache") + result = subprocess.run( + [ + "uv", + "run", + "mypy", + "dclimate_client_py", + ], + cwd=PROJECT_ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=180, + check=False, + ) + + assert result.returncode == 0, ( + f"mypy exited with status {result.returncode}:\n{result.stdout}" + ) + source_count_match = re.search( + r"no issues found in (\d+) source files", result.stdout, re.IGNORECASE + ) + assert source_count_match is not None, ( + f"mypy did not report its checked source-file count:\n{result.stdout}" + ) + assert int(source_count_match.group(1)) >= 16, ( + "mypy checked fewer than the expected 16 package source files:\n" + f"{result.stdout}" + ) + + +def test_package_has_pep561_marker() -> None: + marker = PROJECT_ROOT / "dclimate_client_py" / "py.typed" + + assert marker.is_file(), ( + "dclimate_client_py/py.typed must exist so the package ships its type " + "information in built distributions" + ) + + +def test_built_wheel_contains_pep561_marker(tmp_path: Path) -> None: + env = os.environ.copy() + env[TYPECHECK_META_ENV] = "1" + env["UV_CACHE_DIR"] = str(PROJECT_ROOT / ".uv-cache") + result = subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(tmp_path)], + cwd=PROJECT_ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=300, + check=False, + ) + + assert result.returncode == 0, ( + f"uv build exited with status {result.returncode}:\n{result.stdout}" + ) + wheels = list(tmp_path.glob("*.whl")) + assert len(wheels) == 1, f"expected one wheel, found {wheels!r}" + with zipfile.ZipFile(wheels[0]) as wheel: + assert "dclimate_client_py/py.typed" in wheel.namelist() diff --git a/tests/test_review_fu_variant.py b/tests/test_review_fu_variant.py new file mode 100644 index 0000000..761405d --- /dev/null +++ b/tests/test_review_fu_variant.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import Any + +import httpx +import pystac +import pytest +import xarray as xr + +import dclimate_client_py.dclimate_client as dclimate_client_module +from dclimate_client_py import stac_catalog, stac_server +from dclimate_client_py.dclimate_client import dClimateClient + + +COLLECTION = "example_collection" +DATASET = "temperature" + + +_install_mock_client = None + + +@pytest.fixture(autouse=True) +def _use_managed_httpx_clients(install_httpx_mock): + global _install_mock_client + _install_mock_client = install_httpx_mock + yield + _install_mock_client = None + + +class _Response: + def __init__(self, payload: dict[str, Any]) -> None: + self._payload = payload + + def json(self) -> dict[str, Any]: + return self._payload + + def raise_for_status(self) -> None: + return None + + +def _install_post(monkeypatch, post) -> None: + def handler(request: httpx.Request) -> httpx.Response: + response = post( + str(request.url), + json=json.loads(request.content), + timeout=request.extensions["timeout"]["read"], + ) + return httpx.Response(200, json=response._payload, request=request) + + assert _install_mock_client is not None + _install_mock_client(stac_server, handler) + + +def _feature(variant: str, cid: str) -> dict[str, Any]: + return { + "id": f"{COLLECTION}-{DATASET}-{variant}", + "collection": COLLECTION, + "properties": { + "dclimate:dataset_id": DATASET, + "dclimate:variant": variant, + }, + "assets": {"data": {"href": f"ipfs://{cid}"}}, + } + + +def _item(variant: str, cid: str) -> pystac.Item: + item = pystac.Item( + id=f"{COLLECTION}-{DATASET}-{variant}", + geometry=None, + bbox=None, + datetime=datetime(2024, 1, 1, tzinfo=timezone.utc), + properties={ + "dclimate:dataset_id": DATASET, + "dclimate:variant": variant, + }, + ) + item.add_asset("data", pystac.Asset(href=f"ipfs://{cid}")) + return item + + +def _catalog_with_items(*items: pystac.Item) -> pystac.Catalog: + root = pystac.Catalog(id="root", description="Root") + organization = pystac.Catalog(id="example", description="Organization") + collection = pystac.Collection( + id=COLLECTION, + description="Example collection", + extent=pystac.Extent( + pystac.SpatialExtent([[-180.0, -90.0, 180.0, 90.0]]), + pystac.TemporalExtent([[items[0].datetime, None]]), + ), + ) + for item in items: + collection.add_item(item) + organization.add_child(collection) + root.add_child(organization) + + root.get_child_links()[0].extra_fields["dclimate:id"] = "example" + organization.get_child_links()[0].extra_fields["dclimate:id"] = COLLECTION + return root + + +async def _stub_dataset_loader(**kwargs: Any) -> xr.Dataset: + return xr.Dataset({"temperature": ("x", [1.0])}) + + +def _cid(result: Any) -> str: + """Accept today's str and the post-fix ResolvedDataset in behavior tests.""" + return getattr(result, "cid", result) + + +async def test_load_dataset_reports_variant_selected_by_stac_server(monkeypatch): + _install_post( + monkeypatch, + lambda *args, **kwargs: _Response( + { + "features": [ + _feature("latest", "bafy-latest"), + _feature("final", "bafy-final"), + ] + } + ), + ) + monkeypatch.setattr( + dclimate_client_module, + "_load_dataset_from_ipfs_cid", + _stub_dataset_loader, + ) + client = dClimateClient(stac_server_url="https://stac.example") + client._kubo_cas = object() + + _, metadata = await client.load_dataset( + dataset=DATASET, + collection=COLLECTION, + variant=None, + return_xarray=True, + ) + + assert metadata["variant"] == "final" + assert metadata["slug"].endswith("/final") + + +async def test_load_dataset_reports_variant_selected_by_stac_catalog(monkeypatch): + monkeypatch.setattr( + dclimate_client_module, + "_load_dataset_from_ipfs_cid", + _stub_dataset_loader, + ) + client = dClimateClient(stac_server_url=None) + client._kubo_cas = object() + client._stac_catalog = _catalog_with_items( + _item("latest", "bafy-latest"), + _item("final", "bafy-final"), + ) + + _, metadata = await client.load_dataset( + dataset=DATASET, + collection=COLLECTION, + organization="example", + variant=None, + return_xarray=True, + ) + + assert metadata["variant"] == "final" + assert metadata["slug"].endswith("/final") + + +async def test_direct_cid_without_variant_uses_unknown_consistently(monkeypatch): + monkeypatch.setattr( + dclimate_client_module, + "_load_dataset_from_ipfs_cid", + _stub_dataset_loader, + ) + client = dClimateClient(stac_server_url=None) + client._kubo_cas = object() + + _, metadata = await client.load_dataset( + dataset=DATASET, + collection=COLLECTION, + cid="bafy-direct", + variant=None, + return_xarray=True, + ) + + assert metadata["variant"] == "unknown" + assert metadata["slug"].endswith("/unknown") + + +async def test_explicit_variant_is_preserved_in_loaded_metadata(monkeypatch): + _install_post( + monkeypatch, + lambda *args, **kwargs: _Response( + {"features": [_feature("latest", "bafy-latest")]} + ), + ) + monkeypatch.setattr( + dclimate_client_module, + "_load_dataset_from_ipfs_cid", + _stub_dataset_loader, + ) + client = dClimateClient(stac_server_url="https://stac.example") + client._kubo_cas = object() + + _, metadata = await client.load_dataset( + dataset=DATASET, + collection=COLLECTION, + variant="latest", + return_xarray=True, + ) + + assert metadata["variant"] == "latest" + assert metadata["slug"].endswith("/latest") + + +def test_no_variant_search_paginates_to_preferred_default(monkeypatch): + first_page_with_default = { + "features": [ + { + "id": f"{COLLECTION}-{DATASET}", + "collection": COLLECTION, + "properties": {"dclimate:dataset_id": DATASET}, + "assets": {"data": {"href": "ipfs://bafy-default"}}, + } + ], + "links": [ + { + "rel": "next", + "href": "https://stac.example/search", + "method": "POST", + "body": {"token": "page-2"}, + } + ], + } + default_pages = [first_page_with_default, {"features": []}] + default_page_calls = 0 + + def post_default_page(url, *, json, timeout): + nonlocal default_page_calls + default_page_calls += 1 + return _Response(default_pages[default_page_calls - 1]) + + _install_post(monkeypatch, post_default_page) + page_one_default = stac_server.resolve_cid_from_stac_server( + COLLECTION, + DATASET, + server_url="https://stac.example", + ) + assert _cid(page_one_default) == "bafy-default" + assert page_one_default.variant == "default" + assert default_page_calls == 2 + + pages = [ + { + "features": [_feature("latest", "bafy-latest")], + "links": [ + { + "rel": "next", + "href": "https://stac.example/search", + "method": "POST", + "body": {"token": "page-2"}, + } + ], + }, + { + "features": [ + { + "id": f"{COLLECTION}-{DATASET}", + "collection": COLLECTION, + "properties": {"dclimate:dataset_id": DATASET}, + "assets": {"data": {"href": "ipfs://bafy-default"}}, + } + ] + }, + ] + calls: list[dict[str, Any] | None] = [] + + def post(url, *, json, timeout): + calls.append(json) + return _Response(pages[len(calls) - 1]) + + _install_post(monkeypatch, post) + + resolved = stac_server.resolve_cid_from_stac_server( + COLLECTION, + DATASET, + server_url="https://stac.example", + ) + + assert _cid(resolved) == "bafy-default" + assert resolved.variant == "default" + assert len(calls) == 2 + + +def test_resolvers_return_cid_and_selected_variant(monkeypatch): + _install_post( + monkeypatch, + lambda *args, **kwargs: _Response( + { + "features": [ + _feature("latest", "bafy-latest"), + _feature("final", "bafy-final"), + ] + } + ), + ) + + server_resolved = stac_server.resolve_cid_from_stac_server( + COLLECTION, + DATASET, + server_url="https://stac.example", + ) + catalog = _catalog_with_items( + _item("latest", "bafy-latest"), + _item("final", "bafy-final"), + ) + catalog_resolved = stac_catalog.resolve_dataset_cid_from_stac( + catalog, + collection=COLLECTION, + dataset=DATASET, + organization="example", + ) + + assert all( + isinstance(resolved, stac_server.ResolvedDataset) + for resolved in (server_resolved, catalog_resolved) + ) + assert server_resolved.cid == catalog_resolved.cid == "bafy-final" + assert server_resolved.variant == catalog_resolved.variant == "final" + + +def test_no_variant_prefers_default_over_final(monkeypatch): + # Mutation-survivor pin: swapping "default"/"final" in the preference + # cascade must fail this test. + features = [ + _feature("final", "bafy-final"), + _feature("default", "bafy-default"), + ] + _install_post( + monkeypatch, + lambda *args, **kwargs: _Response({"features": features}), + ) + + resolved = stac_server.resolve_cid_from_stac_server( + COLLECTION, + DATASET, + server_url="https://stac.example", + ) + + assert resolved.cid == "bafy-default" + assert resolved.variant == "default" + + +def test_no_variant_first_match_fallback_reports_actual_variant(monkeypatch): + # No preferred variant exists: the first match wins and its OWN + # effective variant is reported, never a fabricated "default". + features = [ + _feature("raw", "bafy-raw"), + _feature("experimental", "bafy-experimental"), + ] + _install_post( + monkeypatch, + lambda *args, **kwargs: _Response({"features": features}), + ) + + resolved = stac_server.resolve_cid_from_stac_server( + COLLECTION, + DATASET, + server_url="https://stac.example", + ) + + assert resolved.cid == "bafy-raw" + assert resolved.variant == "raw" + + +def test_empty_string_variant_is_treated_as_explicit(monkeypatch): + # Deliberate edge: variant="" is an explicit (unresolvable) variant, + # not "no variant"; it must raise rather than run the cascade. + _install_post( + monkeypatch, + lambda *args, **kwargs: _Response( + {"features": [_feature("latest", "bafy-latest")]} + ), + ) + + with pytest.raises(ValueError, match="Variant ''"): + stac_server.resolve_cid_from_stac_server( + COLLECTION, + DATASET, + variant="", + server_url="https://stac.example", + ) diff --git a/tests/test_review_perf_async.py b/tests/test_review_perf_async.py index bc826f1..df38ee0 100644 --- a/tests/test_review_perf_async.py +++ b/tests/test_review_perf_async.py @@ -2,30 +2,20 @@ import time from unittest.mock import AsyncMock +import httpx import xarray as xr from dclimate_client_py import dclimate_client, stac_catalog, stac_server -class _Response: - def __init__(self, *, payload=None, text=""): - self._payload = payload - self.text = text - - def raise_for_status(self): - return None - - def json(self): - return self._payload - - -async def test_load_dataset_does_not_stall_event_loop(monkeypatch): +async def test_load_dataset_does_not_stall_event_loop(monkeypatch, install_httpx_mock): fake_cid = "bafy-fake-dataset-cid" - def slow_stac_search(url, *, json, timeout): + def slow_stac_search(request: httpx.Request) -> httpx.Response: time.sleep(0.25) - return _Response( - payload={ + return httpx.Response( + 200, + json={ "features": [ { "id": "example_temperature_default", @@ -37,14 +27,15 @@ def slow_stac_search(url, *, json, timeout): "assets": {"data": {"href": f"ipfs://{fake_cid}"}}, } ] - } + }, + request=request, ) async def load_from_ipfs(**kwargs): assert kwargs["ipfs_cid"] == fake_cid return xr.Dataset({"temperature": ("time", [21.0])}, coords={"time": [0]}) - monkeypatch.setattr(stac_server.requests, "post", slow_stac_search) + install_httpx_mock(stac_server, slow_stac_search) monkeypatch.setattr(dclimate_client, "_load_dataset_from_ipfs_cid", load_from_ipfs) client = dclimate_client.dClimateClient(stac_server_url="https://stac.invalid") @@ -78,32 +69,33 @@ async def heartbeat(): assert max_tick_gap < 0.15, f"event loop stalled for {max_tick_gap:.3f}s" -def test_ipfs_stac_io_reuses_session(monkeypatch): - bare_get_calls = [] - sessions = [] +def test_ipfs_stac_io_reuses_client(monkeypatch): + get_calls: list[str] = [] + clients: list[httpx.Client] = [] + httpx_client = httpx.Client - def bare_get(url, *args, **kwargs): - bare_get_calls.append(url) - return _Response(text="{}") + def handler(request: httpx.Request) -> httpx.Response: + get_calls.append(str(request.url)) + return httpx.Response(200, text="{}", request=request) - class RecordingSession: - def __init__(self): - self.get_calls = [] - sessions.append(self) - - def get(self, url, *args, **kwargs): - self.get_calls.append(url) - return _Response(text="{}") + def client_factory(*args, **kwargs): + client = httpx_client( + *args, + **kwargs, + transport=httpx.MockTransport(handler), + ) + clients.append(client) + return client - monkeypatch.setattr(stac_catalog.requests, "get", bare_get) - monkeypatch.setattr(stac_catalog.requests, "Session", RecordingSession) + monkeypatch.setattr(stac_catalog.httpx, "Client", client_factory) stac_io = stac_catalog.IPFSStacIO("https://gateway.invalid") - for index in range(5): - assert stac_io.read_text(f"ipfs://fake-cid-{index}") == "{}" - - assert bare_get_calls == [], ( - f"expected pooled requests, but requests.get was called {len(bare_get_calls)} times" - ) - assert len(sessions) == 1 - assert len(sessions[0].get_calls) == 5 + try: + for index in range(5): + assert stac_io.read_text(f"ipfs://fake-cid-{index}") == "{}" + finally: + stac_io.close() + + assert len(clients) == 1 + assert stac_io.client is clients[0] + assert len(get_calls) == 5 diff --git a/tests/test_review_suspects.py b/tests/test_review_suspects.py index 84f6029..977c2de 100644 --- a/tests/test_review_suspects.py +++ b/tests/test_review_suspects.py @@ -2,15 +2,28 @@ import gc from datetime import datetime, timezone +import json from typing import Any from unittest.mock import Mock +import httpx import pystac import pytest from dclimate_client_py import ipfs_retrieval, stac_catalog, stac_server +_install_mock_client = None + + +@pytest.fixture(autouse=True) +def _use_managed_httpx_clients(install_httpx_mock): + global _install_mock_client + _install_mock_client = install_httpx_mock + yield + _install_mock_client = None + + class _Response: def __init__(self, payload: dict[str, Any]) -> None: self._payload = payload @@ -22,6 +35,23 @@ def raise_for_status(self) -> None: return None +def _install_post(monkeypatch, post) -> None: + def handler(request: httpx.Request) -> httpx.Response: + kwargs = { + "json": json.loads(request.content), + "timeout": request.extensions["timeout"]["read"], + } + if "authorization" in request.headers: + kwargs["headers"] = { + "Authorization": request.headers["authorization"], + } + response = post(str(request.url), **kwargs) + return httpx.Response(200, json=response._payload, request=request) + + assert _install_mock_client is not None + _install_mock_client(stac_server, handler) + + def _catalog_with_item(item: pystac.Item) -> pystac.Catalog: root = pystac.Catalog(id="root", description="Root") organization = pystac.Catalog(id="org", description="Organization") @@ -63,9 +93,8 @@ def test_stac_resolvers_honor_hyphenated_dataset_and_variant(monkeypatch): "properties": properties, "assets": {"data": {"href": f"ipfs://{cid}"}}, } - monkeypatch.setattr( - stac_server.requests, - "post", + _install_post( + monkeypatch, lambda *args, **kwargs: _Response({"features": [feature]}), ) @@ -85,7 +114,7 @@ def test_stac_resolvers_honor_hyphenated_dataset_and_variant(monkeypatch): dataset="precip-daily", variant="final-p05", server_url="https://example.test", - ) + ).cid == cid ) assert ( @@ -95,7 +124,7 @@ def test_stac_resolvers_honor_hyphenated_dataset_and_variant(monkeypatch): dataset="precip-daily", variant="final-p05", organization="org", - ) + ).cid == cid ) @@ -143,16 +172,16 @@ def post(url, json=None, **kwargs): calls.append((url, json)) return _Response(pages[len(calls) - 1]) - monkeypatch.setattr(stac_server.requests, "post", post) + _install_post(monkeypatch, post) - cid = stac_server.resolve_cid_from_stac_server( + resolved = stac_server.resolve_cid_from_stac_server( collection=collection, dataset="target", variant="finalized", server_url="https://example.test", ) - assert cid == "bafy-page-two-target" + assert resolved.cid == "bafy-page-two-target" assert len(calls) == 2 @@ -174,9 +203,8 @@ def test_stac_resolvers_agree_on_default_variant_for_bare_items(monkeypatch): "collection": "chirps", "assets": {"data": {"href": f"ipfs://{cid}"}}, } - monkeypatch.setattr( - stac_server.requests, - "post", + _install_post( + monkeypatch, lambda *args, **kwargs: _Response({"features": [feature]}), ) @@ -196,7 +224,7 @@ def test_stac_resolvers_agree_on_default_variant_for_bare_items(monkeypatch): dataset="temp", variant="default", server_url="https://example.test", - ) + ).cid == cid ) assert ( @@ -206,7 +234,7 @@ def test_stac_resolvers_agree_on_default_variant_for_bare_items(monkeypatch): dataset="temp", variant="default", organization="org", - ) + ).cid == cid ) @@ -220,9 +248,8 @@ def test_stac_server_resolves_hyphenated_variant_without_properties(monkeypatch) "collection": "chirps", "assets": {"data": {"href": f"ipfs://{cid}"}}, } - monkeypatch.setattr( - stac_server.requests, - "post", + _install_post( + monkeypatch, lambda *args, **kwargs: _Response({"features": [feature]}), ) @@ -232,7 +259,7 @@ def test_stac_server_resolves_hyphenated_variant_without_properties(monkeypatch) dataset="precip-daily", variant="final-p05", server_url="https://example.test", - ) + ).cid == cid ) @@ -279,7 +306,7 @@ def post(url, json=None, timeout=None): } ) - monkeypatch.setattr(stac_server.requests, "post", post) + _install_post(monkeypatch, post) assert ( stac_server.resolve_cid_from_stac_server( @@ -287,7 +314,7 @@ def post(url, json=None, timeout=None): dataset="temp", variant="final", server_url="https://example.test", - ) + ).cid == cid ) assert bodies[1]["token"] == "page-2" @@ -318,7 +345,7 @@ def post(url, json=None, timeout=None, headers=None): ) return _Response({"features": []}) - monkeypatch.setattr(stac_server.requests, "post", post) + _install_post(monkeypatch, post) list( stac_server._search_pages( @@ -367,13 +394,13 @@ def post(*args, **kwargs): calls += 1 return response - monkeypatch.setattr(stac_server.requests, "post", post) + _install_post(monkeypatch, post) - cid = stac_server.resolve_cid_from_stac_server( + resolved = stac_server.resolve_cid_from_stac_server( "chirps", "temp", server_url="https://example.test" ) - assert cid == "bafy-default" + assert resolved.cid == "bafy-default" assert calls == 2 @@ -392,9 +419,8 @@ def test_known_hyphenated_dataset_does_not_match_shorter_prefix(monkeypatch): }, "assets": {"data": {"href": "ipfs://bafy-explicit-sibling"}}, } - monkeypatch.setattr( - stac_server.requests, - "post", + _install_post( + monkeypatch, lambda *args, **kwargs: _Response( {"features": [legacy_feature, explicit_sibling]} ), @@ -442,20 +468,19 @@ def test_requested_hyphenated_dataset_is_a_disambiguation_candidate(monkeypatch) "assets": {"data": {"href": "ipfs://bafy-precip-default"}}, }, ] - monkeypatch.setattr( - stac_server.requests, - "post", + _install_post( + monkeypatch, lambda *args, **kwargs: _Response({"features": features}), ) - cid = stac_server.resolve_cid_from_stac_server( + resolved = stac_server.resolve_cid_from_stac_server( "chirps", "precip-daily", variant="final", server_url="https://example.test", ) - assert cid == "bafy-precip-daily-final" + assert resolved.cid == "bafy-precip-daily-final" def test_load_stac_catalog_binds_io_without_mutating_pystac_default(monkeypatch): @@ -480,9 +505,9 @@ def from_file(cls, href, stac_io=None): assert observed["stac_io"].gateway_url == "https://gateway-a.test" -def test_load_stac_catalog_closes_session_when_parsing_fails(monkeypatch): - session = Mock() - monkeypatch.setattr(stac_catalog.requests, "Session", lambda: session) +def test_load_stac_catalog_closes_client_when_parsing_fails(monkeypatch): + client = httpx.Client(transport=httpx.MockTransport(lambda request: None)) + monkeypatch.setattr(stac_catalog.httpx, "Client", lambda *args, **kwargs: client) def fail_from_file(cls, href, stac_io=None): raise RuntimeError("invalid catalog") @@ -492,12 +517,12 @@ def fail_from_file(cls, href, stac_io=None): with pytest.raises(RuntimeError, match="invalid catalog"): stac_catalog.load_stac_catalog("https://gateway.test", root_cid="bafy-invalid") - session.close.assert_called_once_with() + assert client.is_closed -def test_load_stac_catalog_closes_session_when_catalog_is_released(monkeypatch): - session = Mock() - monkeypatch.setattr(stac_catalog.requests, "Session", lambda: session) +def test_load_stac_catalog_closes_client_when_catalog_is_released(monkeypatch): + client = httpx.Client(transport=httpx.MockTransport(lambda request: None)) + monkeypatch.setattr(stac_catalog.httpx, "Client", lambda *args, **kwargs: client) monkeypatch.setattr( pystac.Catalog, "from_file", @@ -511,18 +536,20 @@ def test_load_stac_catalog_closes_session_when_catalog_is_released(monkeypatch): catalog = stac_catalog.load_stac_catalog( "https://gateway.test", root_cid="bafy-root" ) - session.close.assert_not_called() + assert not client.is_closed del catalog gc.collect() - session.close.assert_called_once_with() + assert client.is_closed def test_load_stac_catalog_uses_configured_pointer_endpoint(monkeypatch): response = Mock() response.json.return_value = {"cid": "bafy-configured-root"} - monkeypatch.setattr(stac_catalog.requests, "get", Mock(return_value=response)) + pointer_client = Mock() + pointer_client.get.return_value = response + monkeypatch.setattr(stac_catalog, "_client", lambda: pointer_client) observed = {} def from_file(cls, href, stac_io=None): @@ -535,12 +562,63 @@ def from_file(cls, href, stac_io=None): "https://gateway.test", catalog_url="https://control.test/catalog-root" ) - stac_catalog.requests.get.assert_called_once_with( - "https://control.test/catalog-root", timeout=30 + pointer_client.get.assert_called_once_with( + "https://control.test/catalog-root", timeout=30, headers=None, auth=None ) assert observed["href"] == "ipfs://bafy-configured-root" +def test_load_stac_catalog_threads_gateway_credentials(monkeypatch): + """Auth/headers must reach both the pointer fetch and gateway I/O. + + Regression guard: authenticated gateways previously 401'd on catalog + fallback because credentials were dropped between the KuboCAS data path + and the STAC catalog reads. + """ + headers = {"Authorization": "Bearer token"} + auth = ("user", "secret") + + response = Mock() + response.json.return_value = {"cid": "bafy-auth-root"} + pointer_client = Mock() + pointer_client.get.return_value = response + monkeypatch.setattr(stac_catalog, "_client", lambda: pointer_client) + + captured: dict[str, Any] = {} + + def recording_init(self, gateway_url, *, headers=None, auth=None): + captured["gateway_url"] = gateway_url + captured["headers"] = headers + captured["auth"] = auth + self.gateway_url = gateway_url.rstrip("/") + self.client = Mock() + + monkeypatch.setattr(stac_catalog.IPFSStacIO, "__init__", recording_init) + + def from_file(cls, href, stac_io=None): + return pystac.Catalog(id="root", description="Root") + + monkeypatch.setattr(pystac.Catalog, "from_file", classmethod(from_file)) + + stac_catalog.load_stac_catalog( + "https://gateway.test", + catalog_url="https://control.test/catalog-root", + headers=headers, + auth=auth, + ) + + # Pointer endpoint (same host as the gateway) carries the credentials. + pointer_client.get.assert_called_once_with( + "https://control.test/catalog-root", + timeout=30, + headers=headers, + auth=auth, + ) + # Gateway I/O handler is constructed with the credentials too. + assert captured["headers"] == headers + assert captured["auth"] == auth + + def test_catalog_lister_uses_dataset_metadata_for_partial_item_properties(): item = pystac.Item( id="chirps-precip-daily-final-p05", diff --git a/tests/test_stac_catalog.py b/tests/test_stac_catalog.py index 9afa1ed..7cc2a18 100644 --- a/tests/test_stac_catalog.py +++ b/tests/test_stac_catalog.py @@ -1,10 +1,11 @@ """ Comprehensive tests for STAC catalog integration module. -Tests all functions and classes in stac_catalog.py using real data from the dClimate IPFS gateway. -No mocking is used - all tests interact with actual STAC catalog data. +Tests all functions and classes in stac_catalog.py. Integration cases use the +real dClimate IPFS gateway; isolated protocol cases use httpx MockTransport. """ +import httpx import pytest import pystac from dclimate_client_py import stac_catalog @@ -124,6 +125,33 @@ def test_read_text_multiple_cids(self): assert isinstance(content2, str) assert content1 == content2 + @pytest.mark.parametrize("scheme", ["http", "https"]) + def test_read_text_fetches_http_urls(self, scheme): + requested_url = f"{scheme}://catalog.example/root.json" + + def handler(request: httpx.Request) -> httpx.Response: + assert str(request.url) == requested_url + return httpx.Response(200, text='{"type": "Catalog"}', request=request) + + stac_io = stac_catalog.IPFSStacIO("https://gateway.example") + stac_io.client.close() + stac_io.client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + assert stac_io.read_text(requested_url) == '{"type": "Catalog"}' + finally: + stac_io.close() + + @pytest.mark.parametrize( + "source", ["file:///tmp/catalog.json", "s3://bucket/catalog.json"] + ) + def test_read_text_rejects_unsupported_schemes(self, source): + stac_io = stac_catalog.IPFSStacIO("https://gateway.example") + try: + with pytest.raises(ValueError, match=source.split(":", 1)[0]): + stac_io.read_text(source) + finally: + stac_io.close() + def test_write_text_raises_not_implemented(self): """Test that write_text raises NotImplementedError.""" gateway_url = "https://ipfs-gateway.dclimate.net" @@ -228,16 +256,16 @@ def test_resolve_dataset_cid_basic(self, loaded_catalog): pytest.skip("No datasets available in catalog") # Try to resolve the CID - cid = stac_catalog.resolve_dataset_cid_from_stac( + resolved = stac_catalog.resolve_dataset_cid_from_stac( loaded_catalog, collection=collection_id, dataset=dataset_type ) - assert isinstance(cid, str) - assert len(cid) > 0 + assert isinstance(resolved.cid, str) + assert len(resolved.cid) > 0 # Should not have ipfs:// prefix - assert not cid.startswith("ipfs://") + assert not resolved.cid.startswith("ipfs://") # Should be a valid CID format - assert cid.startswith(("Qm", "bafy", "bafk", "bafz")) + assert resolved.cid.startswith(("Qm", "bafy", "bafk", "bafz")) def test_resolve_dataset_cid_with_variant(self, loaded_catalog): """Test resolving a dataset CID with a specific variant.""" @@ -278,7 +306,7 @@ def test_resolve_dataset_cid_with_variant(self, loaded_catalog): item_dataset = parts[1] item_variant = parts[2] - cid = stac_catalog.resolve_dataset_cid_from_stac( + resolved = stac_catalog.resolve_dataset_cid_from_stac( loaded_catalog, collection=item_collection, dataset=item_dataset, @@ -286,9 +314,9 @@ def test_resolve_dataset_cid_with_variant(self, loaded_catalog): organization=organization_id, ) - assert isinstance(cid, str) - assert len(cid) > 0 - assert not cid.startswith("ipfs://") + assert isinstance(resolved.cid, str) + assert len(resolved.cid) > 0 + assert not resolved.cid.startswith("ipfs://") def test_resolve_dataset_cid_invalid_collection(self, loaded_catalog): """Test that invalid collection raises ValueError.""" @@ -470,11 +498,11 @@ def test_full_workflow_load_and_resolve(self): break if collection_id and dataset_type: - cid = stac_catalog.resolve_dataset_cid_from_stac( + resolved = stac_catalog.resolve_dataset_cid_from_stac( catalog, collection=collection_id, dataset=dataset_type ) - assert isinstance(cid, str) - assert len(cid) > 0 + assert isinstance(resolved.cid, str) + assert len(resolved.cid) > 0 def test_multiple_catalog_loads_work(self): """Test that multiple catalog loads don't interfere with each other.""" diff --git a/tests/test_stac_server.py b/tests/test_stac_server.py index a976452..bc367d0 100644 --- a/tests/test_stac_server.py +++ b/tests/test_stac_server.py @@ -6,9 +6,10 @@ """ import os +import httpx import pytest -import requests from dclimate_client_py.stac_server import ( + ResolvedDataset, resolve_cid_from_stac_server, STAC_SERVER_URL, ) @@ -27,23 +28,25 @@ def stac_server_url(): def check_stac_server(stac_server_url): """Check if STAC server is available, skip tests if not.""" try: - response = requests.post( + response = httpx.post( f"{stac_server_url}/search", json={"limit": 1}, timeout=5, + follow_redirects=True, ) response.raise_for_status() - except (requests.ConnectionError, requests.Timeout, requests.HTTPError): + except httpx.HTTPError: pytest.skip(f"STAC server not available at {stac_server_url}") @pytest.fixture(scope="module") def available_dataset(stac_server_url, check_stac_server): """Get an available dataset from the STAC server for testing.""" - response = requests.post( + response = httpx.post( f"{stac_server_url}/search", json={"limit": 10}, timeout=10, + follow_redirects=True, ) response.raise_for_status() features = response.json().get("features", []) @@ -86,10 +89,11 @@ def test_default_server_url_constant(self): def test_server_search_endpoint(self, stac_server_url, check_stac_server): """Test that search endpoint responds.""" - response = requests.post( + response = httpx.post( f"{stac_server_url}/search", json={"limit": 1}, timeout=10, + follow_redirects=True, ) response.raise_for_status() data = response.json() @@ -101,64 +105,66 @@ def test_server_search_endpoint(self, stac_server_url, check_stac_server): class TestResolveCidFromStacServer: """Test the resolve_cid_from_stac_server function.""" - def test_resolve_cid_returns_string(self, stac_server_url, available_dataset): - """Test that resolve returns a non-empty string CID.""" - cid = resolve_cid_from_stac_server( + def test_resolve_cid_returns_dataset(self, stac_server_url, available_dataset): + """Test that resolve returns a result with a non-empty string CID.""" + resolved = resolve_cid_from_stac_server( collection=available_dataset["collection"], dataset=available_dataset["dataset"], server_url=stac_server_url, ) - assert isinstance(cid, str) - assert len(cid) > 0 + assert isinstance(resolved, ResolvedDataset) + assert isinstance(resolved.cid, str) + assert len(resolved.cid) > 0 def test_resolve_cid_no_ipfs_prefix(self, stac_server_url, available_dataset): """Test that returned CID has no ipfs:// prefix.""" - cid = resolve_cid_from_stac_server( + resolved = resolve_cid_from_stac_server( collection=available_dataset["collection"], dataset=available_dataset["dataset"], server_url=stac_server_url, ) - assert not cid.startswith("ipfs://") + assert not resolved.cid.startswith("ipfs://") def test_resolve_cid_valid_format(self, stac_server_url, available_dataset): """Test that returned CID has valid IPFS CID format.""" - cid = resolve_cid_from_stac_server( + resolved = resolve_cid_from_stac_server( collection=available_dataset["collection"], dataset=available_dataset["dataset"], server_url=stac_server_url, ) # IPFS CIDs typically start with these prefixes - assert cid.startswith(("Qm", "bafy", "bafk", "bafz", "bafyr")) + assert resolved.cid.startswith(("Qm", "bafy", "bafk", "bafz", "bafyr")) def test_resolve_cid_with_variant(self, stac_server_url, available_dataset): """Test CID resolution with specific variant.""" if not available_dataset["variant"]: pytest.skip("Test dataset has no variant") - cid = resolve_cid_from_stac_server( + resolved = resolve_cid_from_stac_server( collection=available_dataset["collection"], dataset=available_dataset["dataset"], variant=available_dataset["variant"], server_url=stac_server_url, ) - assert isinstance(cid, str) - assert len(cid) > 0 - assert not cid.startswith("ipfs://") + assert isinstance(resolved.cid, str) + assert len(resolved.cid) > 0 + assert not resolved.cid.startswith("ipfs://") + assert resolved.variant == available_dataset["variant"] def test_resolve_cid_without_variant(self, stac_server_url, available_dataset): """Test CID resolution without specifying variant.""" - cid = resolve_cid_from_stac_server( + resolved = resolve_cid_from_stac_server( collection=available_dataset["collection"], dataset=available_dataset["dataset"], server_url=stac_server_url, ) - assert isinstance(cid, str) - assert len(cid) > 0 + assert isinstance(resolved.cid, str) + assert len(resolved.cid) > 0 def test_resolve_cid_invalid_collection_raises( self, stac_server_url, check_stac_server @@ -202,9 +208,7 @@ def test_resolve_cid_invalid_variant_raises( def test_resolve_cid_connection_error_on_bad_url(self): """Test that connection error is raised for unreachable server.""" - with pytest.raises( - (requests.ConnectionError, requests.exceptions.RequestException) - ): + with pytest.raises(httpx.HTTPError): resolve_cid_from_stac_server( collection="any", dataset="any", @@ -234,10 +238,11 @@ class TestMultipleDatasets: def test_resolve_multiple_datasets(self, stac_server_url, check_stac_server): """Test resolving CIDs for multiple datasets from the server.""" # Get multiple datasets - response = requests.post( + response = httpx.post( f"{stac_server_url}/search", json={"limit": 50}, timeout=10, + follow_redirects=True, ) response.raise_for_status() features = response.json().get("features", []) @@ -270,12 +275,12 @@ def test_resolve_multiple_datasets(self, stac_server_url, check_stac_server): datasets_seen.add(key) try: - cid = resolve_cid_from_stac_server( + resolved = resolve_cid_from_stac_server( collection=collection, dataset=dataset, server_url=stac_server_url, ) - resolved_cids.append((key, cid)) + resolved_cids.append((key, resolved)) except ValueError: continue @@ -284,7 +289,7 @@ def test_resolve_multiple_datasets(self, stac_server_url, check_stac_server): assert len(resolved_cids) >= 1, "Should resolve at least one dataset" - for key, cid in resolved_cids: - assert isinstance(cid, str) - assert len(cid) > 0 - assert not cid.startswith("ipfs://") + for key, resolved in resolved_cids: + assert isinstance(resolved.cid, str) + assert len(resolved.cid) > 0 + assert not resolved.cid.startswith("ipfs://") diff --git a/tests/test_stac_server_listing.py b/tests/test_stac_server_listing.py index 0da1957..a134d3e 100644 --- a/tests/test_stac_server_listing.py +++ b/tests/test_stac_server_listing.py @@ -1,7 +1,7 @@ """ Unit tests for ``list_available_datasets_from_stac_server``. -These are pure unit tests — ``requests.get`` / ``requests.post`` are mocked, so +These are pure unit tests — ``httpx.MockTransport`` handles all requests, so the tests run offline and don't depend on the public STAC server or the IPFS gateway. Integration coverage (parity with the IPFS walker) lives in ``test_list_datasets_parity.py`` and is gated behind ``--run-integration``. @@ -11,46 +11,48 @@ from typing import Any, Dict +import httpx import pytest -import requests +from dclimate_client_py import stac_server from dclimate_client_py.stac_server import ( list_available_datasets_from_stac_server, resolve_cid_from_stac_server, ) -def _mock_response(payload: Dict[str, Any], status: int = 200): - """Build a stand-in for a ``requests.Response`` that has ``json()`` and - ``raise_for_status()``.""" +_install_mock_client = None - class _Resp: - def __init__(self) -> None: - self.status_code = status - def json(self): - return payload +@pytest.fixture(autouse=True) +def _use_managed_httpx_clients(install_httpx_mock): + global _install_mock_client + _install_mock_client = install_httpx_mock + yield + _install_mock_client = None - def raise_for_status(self): - if status >= 400: - raise requests.HTTPError(f"HTTP {status}") - return _Resp() +def _mock_response( + request: httpx.Request, payload: Dict[str, Any], status: int = 200 +) -> httpx.Response: + return httpx.Response(status, json=payload, request=request) def _install_mocks(monkeypatch, *, collections_body, search_body): - """Patch requests.get/post to return canned bodies based on URL.""" + """Inject canned collection/search responses through the client accessor.""" - def fake_get(url, *args, **kwargs): - assert url.endswith("/collections"), f"unexpected GET {url}" - return _mock_response(collections_body) + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET": + assert request.url.path.endswith("/collections"), ( + f"unexpected GET {request.url}" + ) + return _mock_response(request, collections_body) + assert request.method == "POST" + assert request.url.path.endswith("/search"), f"unexpected POST {request.url}" + return _mock_response(request, search_body) - def fake_post(url, *args, **kwargs): - assert url.endswith("/search"), f"unexpected POST {url}" - return _mock_response(search_body) - - monkeypatch.setattr(requests, "get", fake_get) - monkeypatch.setattr(requests, "post", fake_post) + assert _install_mock_client is not None + _install_mock_client(stac_server, handler) SAMPLE_COLLECTIONS = { @@ -368,14 +370,14 @@ def test_resolve_cid_uses_exact_dataset_id_for_prefix_collisions(monkeypatch): }, ) - cid = resolve_cid_from_stac_server( + resolved = resolve_cid_from_stac_server( "ecmwf_era5", "precipitation_total", "finalized", "https://example.test", ) - assert cid == "bafy-era5-precip-finalized" + assert resolved.cid == "bafy-era5-precip-finalized" def test_resolve_cid_rejects_only_prefix_dataset_match(monkeypatch): @@ -432,26 +434,26 @@ def test_resolve_cid_legacy_id_fallback_is_exact(monkeypatch): }, ) - cid = resolve_cid_from_stac_server( + resolved = resolve_cid_from_stac_server( "ecmwf_era5", "temperature_2m", "finalized", "https://example.test", ) - assert cid == "bafy-era5-t2m" + assert resolved.cid == "bafy-era5-t2m" def test_collections_endpoint_error_propagates(monkeypatch): - def failing_get(url, *args, **kwargs): - return _mock_response({}, status=500) + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET": + return _mock_response(request, {}, status=500) + return _mock_response(request, {"features": []}) - monkeypatch.setattr(requests, "get", failing_get) - monkeypatch.setattr( - requests, "post", lambda *a, **k: _mock_response({"features": []}) - ) + assert _install_mock_client is not None + _install_mock_client(stac_server, handler) - with pytest.raises(requests.HTTPError): + with pytest.raises(httpx.HTTPStatusError): list_available_datasets_from_stac_server("https://example.test") diff --git a/uv.lock b/uv.lock index ec31ad2..f4485b1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "affine" @@ -131,6 +135,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -632,7 +677,7 @@ wheels = [ [[package]] name = "dclimate-client-py" -version = "0.5.11" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "aiobotocore" }, @@ -647,12 +692,10 @@ dependencies = [ { name = "py-hamt" }, { name = "pycryptodome" }, { name = "pystac" }, - { name = "requests" }, { name = "rioxarray" }, { name = "s3fs" }, { name = "scipy" }, { name = "shapely" }, - { name = "urllib3" }, { name = "x402", extra = ["evm", "httpx"] }, { name = "xarray" }, { name = "zarr" }, @@ -667,6 +710,7 @@ examples = [ { name = "python-dotenv" }, ] testing = [ + { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -680,6 +724,7 @@ requires-dist = [ { name = "geopandas", specifier = ">=1.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "multiformats", specifier = ">=0.3.1" }, + { name = "mypy", marker = "extra == 'testing'", specifier = ">=1.14" }, { name = "numcodecs", extras = ["crc32c"], specifier = ">=0.14,<0.16" }, { name = "numpy", specifier = ">=2.1.3" }, { name = "opentelemetry-api", specifier = ">=1.30.0" }, @@ -693,13 +738,11 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'testing'" }, { name = "pytest-mock", marker = "extra == 'testing'" }, { name = "python-dotenv", marker = "extra == 'examples'", specifier = ">=1.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, { name = "rioxarray", specifier = ">=0.15.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.5" }, { name = "s3fs", specifier = ">=2024.6.0" }, { name = "scipy", specifier = ">=1.12.0" }, { name = "shapely", specifier = ">=2.0.0" }, - { name = "urllib3", specifier = ">=2.0.0" }, { name = "x402", extras = ["evm", "httpx"], specifier = ">=2.1.0" }, { name = "xarray", specifier = ">=2025.3.0" }, { name = "zarr", specifier = ">=3.0.8" }, @@ -1049,6 +1092,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, ] +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1222,6 +1327,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/9b/c21a9c1d5ea4847989f1eb00e3147e38e79aaea7c4b4d1cbd4f1afae9740/multiformats_config-0.3.1-py3-none-any.whl", hash = "sha256:dec4c9d42ed0d9305889b67440f72e8e8d74b82b80abd7219667764b5b0a8e1d", size = 17153, upload-time = "2023-12-18T21:35:21.171Z" }, ] +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -1370,6 +1529,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/0f/c8b64d9b54ea631fcad4e9e3c8dbe8c11bb32a623be94f22974c88e71eaf/parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f", size = 48427, upload-time = "2022-09-03T17:01:13.814Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "platformdirs" version = "4.3.7"