From 651f1f84a3f7dbe26a098543d2b3e831a1306a12 Mon Sep 17 00:00:00 2001 From: 0xSwego <0xSwego@gmail.com> Date: Mon, 8 Jun 2026 14:04:05 +0100 Subject: [PATCH 1/5] Expose IPFS Zarr store metadata --- dclimate_client_py/ipfs_retrieval.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/dclimate_client_py/ipfs_retrieval.py b/dclimate_client_py/ipfs_retrieval.py index 034654c..7c09771 100644 --- a/dclimate_client_py/ipfs_retrieval.py +++ b/dclimate_client_py/ipfs_retrieval.py @@ -5,9 +5,11 @@ """ import logging +import time import xarray as xr from multiformats import CID from py_hamt import KuboCAS, ZarrHAMTStore, ShardedZarrStore, HAMT +import py_hamt.instrumentation as ipfs_instrumentation from .dclimate_zarr_errors import ( IpfsConnectionError, @@ -60,10 +62,16 @@ async def _load_dataset_from_ipfs_cid( # Try loading as ShardedZarrStore first (99% of cases) try: logger.info(f"Attempting to load as ShardedZarrStore from CID: {ipfs_cid}") + sharded_start = time.perf_counter() sharded_store = await ShardedZarrStore.open( root_cid=ipfs_cid, cas=kubo_cas, read_only=True ) ds = xr.open_zarr(store=sharded_store) + ds.attrs["_ipfs_store_type"] = "ShardedZarrStore" + ipfs_instrumentation.observe( + "dclimate_client.open_sharded_store_seconds", + time.perf_counter() - sharded_start, + ) logger.info( f"Successfully loaded ShardedZarrStore dataset from CID: {ipfs_cid}" ) @@ -74,6 +82,7 @@ async def _load_dataset_from_ipfs_cid( f"ShardedZarrStore failed, falling back to HAMT store. Error: {sharded_err}" ) logger.info(f"Loading HAMT store from CID: {ipfs_cid}") + hamt_start = time.perf_counter() hamt_store = await HAMT.build( cas=kubo_cas, root_node_id=cid_obj, @@ -85,6 +94,11 @@ async def _load_dataset_from_ipfs_cid( zarr_hamt_store = ZarrHAMTStore(hamt_store, read_only=True) ds = xr.open_zarr(store=zarr_hamt_store) + ds.attrs["_ipfs_store_type"] = "ZarrHAMTStore" + ipfs_instrumentation.observe( + "dclimate_client.open_hamt_store_seconds", + time.perf_counter() - hamt_start, + ) logger.info(f"Successfully loaded HAMT dataset from CID: {ipfs_cid}") return ds except IpfsConnectionError: From a7d54c1207f3d2a7cd5bd5695461b31abfd63e10 Mon Sep 17 00:00:00 2001 From: 0xSwego <0xSwego@gmail.com> Date: Mon, 8 Jun 2026 14:24:47 +0100 Subject: [PATCH 2/5] Add OpenTelemetry IPFS retrieval instrumentation --- dclimate_client_py/ipfs_retrieval.py | 351 +++++++++++++++++++++------ pyproject.toml | 1 + uv.lock | 18 +- 3 files changed, 292 insertions(+), 78 deletions(-) diff --git a/dclimate_client_py/ipfs_retrieval.py b/dclimate_client_py/ipfs_retrieval.py index 7c09771..0b03469 100644 --- a/dclimate_client_py/ipfs_retrieval.py +++ b/dclimate_client_py/ipfs_retrieval.py @@ -6,10 +6,26 @@ import logging import time +from typing import Any + import xarray as xr from multiformats import CID -from py_hamt import KuboCAS, ZarrHAMTStore, ShardedZarrStore, HAMT -import py_hamt.instrumentation as ipfs_instrumentation +from opentelemetry import metrics, trace +from opentelemetry.trace import Span, Status, StatusCode +from py_hamt import HAMT, KuboCAS, ShardedZarrStore, ZarrHAMTStore + +try: + import py_hamt.instrumentation as ipfs_instrumentation +except ModuleNotFoundError as exc: + if exc.name != "py_hamt.instrumentation": + raise + + class _NoOpIpfsInstrumentation: + @staticmethod + def observe(name: str, seconds: float) -> None: + return None + + ipfs_instrumentation = _NoOpIpfsInstrumentation() from .dclimate_zarr_errors import ( IpfsConnectionError, @@ -17,6 +33,93 @@ # Configure logging logger = logging.getLogger(__name__) +OTEL_ATTRIBUTE_VALUE = str | bool | int | float + +_TRACER = trace.get_tracer("dclimate_client_py.ipfs_retrieval") +_METER = metrics.get_meter("dclimate_client_py.ipfs_retrieval") +_DATASET_OPEN_COUNTER = _METER.create_counter( + "dclimate_client.ipfs.dataset_open.requests", + unit="1", + description="IPFS Zarr dataset open requests.", +) +_DATASET_OPEN_DURATION = _METER.create_histogram( + "dclimate_client.ipfs.dataset_open.duration", + unit="s", + description="IPFS Zarr dataset open latency.", +) +_STORE_OPEN_COUNTER = _METER.create_counter( + "dclimate_client.ipfs.store_open.requests", + unit="1", + description="IPFS Zarr store open attempts.", +) +_STORE_OPEN_DURATION = _METER.create_histogram( + "dclimate_client.ipfs.store_open.duration", + unit="s", + description="IPFS Zarr store open attempt latency.", +) + + +def _attributes( + attributes: dict[str, Any] | None = None, +) -> dict[str, OTEL_ATTRIBUTE_VALUE]: + if not attributes: + return {} + cleaned: dict[str, OTEL_ATTRIBUTE_VALUE] = {} + for key, value in attributes.items(): + if value is None: + continue + if isinstance(value, (str, bool, int, float)): + cleaned[key] = value + else: + cleaned[key] = str(value) + return cleaned + + +def _gateway_metric_attributes( + kubo_cas: KuboCAS, +) -> dict[str, OTEL_ATTRIBUTE_VALUE]: + return _attributes( + {"dclimate_client.ipfs.gateway": getattr(kubo_cas, "gateway_base_url", None)} + ) + + +def _record_dataset_open( + *, + kubo_cas: KuboCAS, + store_type: str, + status: str, + seconds: float, +) -> None: + attributes = { + **_gateway_metric_attributes(kubo_cas), + "dclimate_client.ipfs.store_type": store_type, + "dclimate_client.ipfs.status": status, + } + _DATASET_OPEN_COUNTER.add(1, attributes) + _DATASET_OPEN_DURATION.record(seconds, attributes) + + +def _record_store_open( + *, + kubo_cas: KuboCAS, + store_type: str, + status: str, + seconds: float, +) -> None: + attributes = { + **_gateway_metric_attributes(kubo_cas), + "dclimate_client.ipfs.store_type": store_type, + "dclimate_client.ipfs.status": status, + } + _STORE_OPEN_COUNTER.add(1, attributes) + _STORE_OPEN_DURATION.record(seconds, attributes) + + +def _record_span_error(active_span: Span, exc: Exception) -> None: + if not active_span.is_recording(): + return + active_span.record_exception(exc) + active_span.set_status(Status(StatusCode.ERROR, str(exc))) # --- Zarr Dataset Loading --- @@ -45,87 +148,183 @@ async def _load_dataset_from_ipfs_cid( IpfsConnectionError: If connection to IPFS fails during loading. RuntimeError: Other errors during Zarr parsing or IPFS interaction. """ - if not ipfs_cid: - raise ValueError("IPFS CID cannot be empty.") + dataset_started_at = time.perf_counter() + dataset_status = "error" + dataset_store_type = "unknown" + with _TRACER.start_as_current_span( + "dclimate_client.ipfs.load_zarr_dataset", + attributes=_attributes( + { + "dclimate_client.ipfs.cid": ipfs_cid, + **_gateway_metric_attributes(kubo_cas), + } + ), + ) as dataset_span: + try: + if not ipfs_cid: + raise ValueError("IPFS CID cannot be empty.") - logger.info(f"Loading Zarr dataset from IPFS CID: {ipfs_cid}") + logger.info(f"Loading Zarr dataset from IPFS CID: {ipfs_cid}") - try: - # Validate CID format - try: - cid_obj = CID.decode(ipfs_cid) - except Exception as decode_err: - raise ValueError( - f"Invalid IPFS CID format: {ipfs_cid}. Error: {decode_err}" - ) from decode_err + # Validate CID format + try: + cid_obj = CID.decode(ipfs_cid) + except Exception as decode_err: + raise ValueError( + f"Invalid IPFS CID format: {ipfs_cid}. Error: {decode_err}" + ) from decode_err - # Try loading as ShardedZarrStore first (99% of cases) - try: - logger.info(f"Attempting to load as ShardedZarrStore from CID: {ipfs_cid}") - sharded_start = time.perf_counter() - sharded_store = await ShardedZarrStore.open( - root_cid=ipfs_cid, cas=kubo_cas, read_only=True - ) - ds = xr.open_zarr(store=sharded_store) - ds.attrs["_ipfs_store_type"] = "ShardedZarrStore" - ipfs_instrumentation.observe( - "dclimate_client.open_sharded_store_seconds", - time.perf_counter() - sharded_start, - ) - logger.info( - f"Successfully loaded ShardedZarrStore dataset from CID: {ipfs_cid}" - ) - return ds - except Exception as sharded_err: - # Fall back to HAMT store if sharded loading fails - logger.info( - f"ShardedZarrStore failed, falling back to HAMT store. Error: {sharded_err}" - ) - logger.info(f"Loading HAMT store from CID: {ipfs_cid}") - hamt_start = time.perf_counter() - hamt_store = await HAMT.build( - cas=kubo_cas, - root_node_id=cid_obj, - read_only=True, - values_are_bytes=True, - ) + # Try loading as ShardedZarrStore first (99% of cases) + try: + logger.info( + f"Attempting to load as ShardedZarrStore from CID: {ipfs_cid}" + ) + sharded_start = time.perf_counter() + with _TRACER.start_as_current_span( + "dclimate_client.ipfs.open_sharded_store", + attributes=_attributes( + { + "dclimate_client.ipfs.store_type": "ShardedZarrStore", + **_gateway_metric_attributes(kubo_cas), + } + ), + ) as sharded_span: + try: + sharded_store = await ShardedZarrStore.open( + root_cid=ipfs_cid, cas=kubo_cas, read_only=True + ) + ds = xr.open_zarr(store=sharded_store) + except Exception as sharded_err: + sharded_seconds = time.perf_counter() - sharded_start + _record_store_open( + kubo_cas=kubo_cas, + store_type="ShardedZarrStore", + status="error", + seconds=sharded_seconds, + ) + _record_span_error(sharded_span, sharded_err) + raise + sharded_seconds = time.perf_counter() - sharded_start + _record_store_open( + kubo_cas=kubo_cas, + store_type="ShardedZarrStore", + status="ok", + seconds=sharded_seconds, + ) + ds.attrs["_ipfs_store_type"] = "ShardedZarrStore" + ipfs_instrumentation.observe( + "dclimate_client.open_sharded_store_seconds", + sharded_seconds, + ) + logger.info( + f"Successfully loaded ShardedZarrStore dataset from CID: {ipfs_cid}" + ) + dataset_status = "ok" + dataset_store_type = "ShardedZarrStore" + return ds + except Exception as sharded_err: + # Fall back to HAMT store if sharded loading fails + if dataset_span.is_recording(): + dataset_span.set_attribute( + "dclimate_client.ipfs.sharded_fallback", True + ) + logger.info( + f"ShardedZarrStore failed, falling back to HAMT store. Error: {sharded_err}" + ) + logger.info(f"Loading HAMT store from CID: {ipfs_cid}") + hamt_start = time.perf_counter() + with _TRACER.start_as_current_span( + "dclimate_client.ipfs.open_hamt_store", + attributes=_attributes( + { + "dclimate_client.ipfs.store_type": "ZarrHAMTStore", + **_gateway_metric_attributes(kubo_cas), + } + ), + ) as hamt_span: + try: + hamt_store = await HAMT.build( + cas=kubo_cas, + root_node_id=cid_obj, + read_only=True, + values_are_bytes=True, + ) + + # Wrap with ZarrHAMTStore adapter + zarr_hamt_store = ZarrHAMTStore(hamt_store, read_only=True) - # Wrap with ZarrHAMTStore adapter - zarr_hamt_store = ZarrHAMTStore(hamt_store, read_only=True) + ds = xr.open_zarr(store=zarr_hamt_store) + except Exception as hamt_err: + hamt_seconds = time.perf_counter() - hamt_start + _record_store_open( + kubo_cas=kubo_cas, + store_type="ZarrHAMTStore", + status="error", + seconds=hamt_seconds, + ) + _record_span_error(hamt_span, hamt_err) + raise + hamt_seconds = time.perf_counter() - hamt_start + _record_store_open( + kubo_cas=kubo_cas, + store_type="ZarrHAMTStore", + status="ok", + seconds=hamt_seconds, + ) + ds.attrs["_ipfs_store_type"] = "ZarrHAMTStore" + ipfs_instrumentation.observe( + "dclimate_client.open_hamt_store_seconds", + hamt_seconds, + ) + logger.info(f"Successfully loaded HAMT dataset from CID: {ipfs_cid}") + dataset_status = "ok" + dataset_store_type = "ZarrHAMTStore" + return ds + except IpfsConnectionError as exc: + dataset_status = "connection_error" + _record_span_error(dataset_span, exc) + raise + except ValueError as exc: + # Re-raise ValueError as-is + _record_span_error(dataset_span, exc) + raise + except Exception as e: + # Catch other potential errors (e.g., Zarr format errors, py-hamt errors) + # Check for connection errors + if ( + "Connection refused" in str(e) + or "Max retries exceeded" in str(e) + or "Timeout" in str(e) + ): + dataset_status = "connection_error" + connection_error = IpfsConnectionError( + f"IPFS connection failed while loading dataset from CID {ipfs_cid}. Details: {e}" + ) + _record_span_error(dataset_span, connection_error) + raise connection_error from e - ds = xr.open_zarr(store=zarr_hamt_store) - ds.attrs["_ipfs_store_type"] = "ZarrHAMTStore" - ipfs_instrumentation.observe( - "dclimate_client.open_hamt_store_seconds", - time.perf_counter() - hamt_start, + _record_span_error(dataset_span, e) + logger.error( + f"Failed to load Zarr dataset from IPFS CID {ipfs_cid}: {type(e).__name__}: {e}", + exc_info=True, ) - logger.info(f"Successfully loaded HAMT dataset from CID: {ipfs_cid}") - return ds - except IpfsConnectionError: - # Re-raise IpfsConnectionError as-is - raise - except ValueError: - # Re-raise ValueError as-is - raise - except Exception as e: - # Catch other potential errors (e.g., Zarr format errors, py-hamt errors) - # Check for connection errors - if ( - "Connection refused" in str(e) - or "Max retries exceeded" in str(e) - or "Timeout" in str(e) - ): - raise IpfsConnectionError( - f"IPFS connection failed while loading dataset from CID {ipfs_cid}. Details: {e}" + raise RuntimeError( + f"Failed to load Zarr dataset from IPFS CID {ipfs_cid}" ) from e - - logger.error( - f"Failed to load Zarr dataset from IPFS CID {ipfs_cid}: {type(e).__name__}: {e}", - exc_info=True, - ) - raise RuntimeError( - f"Failed to load Zarr dataset from IPFS CID {ipfs_cid}" - ) from e + finally: + if dataset_span.is_recording(): + dataset_span.set_attribute( + "dclimate_client.ipfs.store_type", dataset_store_type + ) + dataset_span.set_attribute( + "dclimate_client.ipfs.status", dataset_status + ) + _record_dataset_open( + kubo_cas=kubo_cas, + store_type=dataset_store_type, + status=dataset_status, + seconds=time.perf_counter() - dataset_started_at, + ) # Legacy wrapper for backward compatibility diff --git a/pyproject.toml b/pyproject.toml index 3b7ccdf..7223e3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ "httpx>=0.27.0", "x402[evm,httpx]>=2.1.0", "python-dotenv>=1.0.0", + "opentelemetry-api>=1.30.0", ] [project.urls] diff --git a/uv.lock b/uv.lock index ab0291d..f3c70fb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -632,13 +632,14 @@ wheels = [ [[package]] name = "dclimate-client-py" -version = "0.5.3" +version = "0.5.5" source = { editable = "." } dependencies = [ { name = "aiobotocore" }, { name = "geopandas" }, { name = "httpx" }, { name = "numpy" }, + { name = "opentelemetry-api" }, { name = "pandas" }, { name = "py-hamt" }, { name = "pyarrow" }, @@ -673,6 +674,7 @@ requires-dist = [ { name = "geopandas" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "numpy", specifier = ">=2.1.3" }, + { name = "opentelemetry-api", specifier = ">=1.30.0" }, { name = "pandas" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.1.0" }, { name = "py-hamt", specifier = ">=3.3.1" }, @@ -1293,6 +1295,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119, upload-time = "2025-03-16T18:20:03.94Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + [[package]] name = "packaging" version = "24.2" From b4a268484e458510d7b73e1c796412e7d82c1799 Mon Sep 17 00:00:00 2001 From: 0xSwego <0xSwego@gmail.com> Date: Mon, 8 Jun 2026 16:10:40 +0100 Subject: [PATCH 3/5] Address IPFS instrumentation review --- dclimate_client_py/ipfs_retrieval.py | 37 +++++++++++++++++--- tests/test_ipfs_retrieval.py | 50 ++++++++++++++++++++++++++++ tests/test_list_datasets_parity.py | 9 ++--- tests/test_stac_server_listing.py | 2 +- 4 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 tests/test_ipfs_retrieval.py diff --git a/dclimate_client_py/ipfs_retrieval.py b/dclimate_client_py/ipfs_retrieval.py index 0b03469..64befb0 100644 --- a/dclimate_client_py/ipfs_retrieval.py +++ b/dclimate_client_py/ipfs_retrieval.py @@ -122,6 +122,24 @@ def _record_span_error(active_span: Span, exc: Exception) -> None: active_span.set_status(Status(StatusCode.ERROR, str(exc))) +def _is_connection_error(exc: Exception) -> bool: + text = str(exc).lower() + return any( + token in text + for token in ( + "connection refused", + "connection reset", + "max retries exceeded", + "name or service not known", + "network is unreachable", + "nodename nor servname", + "temporary failure in name resolution", + "timeout", + "timed out", + ) + ) + + # --- Zarr Dataset Loading --- @@ -159,6 +177,8 @@ async def _load_dataset_from_ipfs_cid( **_gateway_metric_attributes(kubo_cas), } ), + record_exception=False, + set_status_on_exception=False, ) as dataset_span: try: if not ipfs_cid: @@ -188,6 +208,8 @@ async def _load_dataset_from_ipfs_cid( **_gateway_metric_attributes(kubo_cas), } ), + record_exception=False, + set_status_on_exception=False, ) as sharded_span: try: sharded_store = await ShardedZarrStore.open( @@ -223,6 +245,13 @@ async def _load_dataset_from_ipfs_cid( dataset_store_type = "ShardedZarrStore" return ds except Exception as sharded_err: + if _is_connection_error(sharded_err): + dataset_status = "connection_error" + connection_error = IpfsConnectionError( + f"IPFS connection failed while loading dataset from CID {ipfs_cid}. Details: {sharded_err}" + ) + raise connection_error from sharded_err + # Fall back to HAMT store if sharded loading fails if dataset_span.is_recording(): dataset_span.set_attribute( @@ -241,6 +270,8 @@ async def _load_dataset_from_ipfs_cid( **_gateway_metric_attributes(kubo_cas), } ), + record_exception=False, + set_status_on_exception=False, ) as hamt_span: try: hamt_store = await HAMT.build( @@ -291,11 +322,7 @@ async def _load_dataset_from_ipfs_cid( except Exception as e: # Catch other potential errors (e.g., Zarr format errors, py-hamt errors) # Check for connection errors - if ( - "Connection refused" in str(e) - or "Max retries exceeded" in str(e) - or "Timeout" in str(e) - ): + if _is_connection_error(e): dataset_status = "connection_error" connection_error = IpfsConnectionError( f"IPFS connection failed while loading dataset from CID {ipfs_cid}. Details: {e}" diff --git a/tests/test_ipfs_retrieval.py b/tests/test_ipfs_retrieval.py new file mode 100644 index 0000000..cfe3d86 --- /dev/null +++ b/tests/test_ipfs_retrieval.py @@ -0,0 +1,50 @@ +import pytest + +from dclimate_client_py import ipfs_retrieval +from dclimate_client_py.dclimate_zarr_errors import IpfsConnectionError + + +VALID_CID = "bafkreigh2akiscaildc6snya3u5ox6jz5p3xxrrbf2znsnz2j3twg2ucqi" + + +class DummyKuboCAS: + gateway_base_url = "http://example.test" + + +@pytest.fixture(autouse=True) +def check_ipfs_connection(): + return None + + +@pytest.mark.parametrize( + "message", + [ + "Connection refused", + "Max retries exceeded", + "Name or service not known", + "network is unreachable", + "nodename nor servname provided", + "temporary failure in name resolution", + "timed out opening sharded store", + ], +) +def test_is_connection_error_classifies_gateway_failures(message): + assert ipfs_retrieval._is_connection_error(RuntimeError(message)) + + +@pytest.mark.asyncio +async def test_sharded_connection_error_skips_hamt_fallback(monkeypatch): + async def sharded_open(*, root_cid, cas, read_only): + raise TimeoutError("timed out opening sharded store") + + async def hamt_build(**kwargs): + raise AssertionError("HAMT fallback should not be attempted") + + monkeypatch.setattr(ipfs_retrieval.ShardedZarrStore, "open", sharded_open) + monkeypatch.setattr(ipfs_retrieval.HAMT, "build", hamt_build) + + with pytest.raises(IpfsConnectionError, match="IPFS connection failed"): + await ipfs_retrieval._load_dataset_from_ipfs_cid( + VALID_CID, + DummyKuboCAS(), + ) diff --git a/tests/test_list_datasets_parity.py b/tests/test_list_datasets_parity.py index b6495c5..517147d 100644 --- a/tests/test_list_datasets_parity.py +++ b/tests/test_list_datasets_parity.py @@ -188,9 +188,7 @@ def test_cids_agree(both_catalogs): detail = "\n".join( f" {k}\n STAC: {s}\n IPFS: {i}" for k, s, i in mismatches[:10] ) - raise AssertionError( - f"CID mismatch in {len(mismatches)} variants:\n{detail}" - ) + raise AssertionError(f"CID mismatch in {len(mismatches)} variants:\n{detail}") def test_bbox_agrees(both_catalogs): @@ -200,6 +198,7 @@ def test_bbox_agrees(both_catalogs): for key, ipfs_bbox in ipfs_bboxes.items(): stac_bbox = stac_bboxes.get(key) + # bbox is a TypedDict {"bbox": (lo, la, hi, la)} — compare structurally. # Tuples vs lists may differ across paths; normalize to tuple of floats. def _norm(b): @@ -223,7 +222,9 @@ def _to_ms(s): if s is None: return None try: - return int(dt.datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() * 1000) + return int( + dt.datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() * 1000 + ) except (ValueError, AttributeError): return None diff --git a/tests/test_stac_server_listing.py b/tests/test_stac_server_listing.py index 7b73b20..8e215ed 100644 --- a/tests/test_stac_server_listing.py +++ b/tests/test_stac_server_listing.py @@ -10,7 +10,6 @@ from __future__ import annotations from typing import Any, Dict -from unittest.mock import patch import pytest import requests @@ -23,6 +22,7 @@ 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()``.""" + class _Resp: def __init__(self) -> None: self.status_code = status From f1de26822862e5ee6029507e1ae0a58c8e96ce03 Mon Sep 17 00:00:00 2001 From: 0xSwego <0xSwego@gmail.com> Date: Mon, 8 Jun 2026 16:12:45 +0100 Subject: [PATCH 4/5] Document IPFS instrumentation helpers --- dclimate_client_py/ipfs_retrieval.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dclimate_client_py/ipfs_retrieval.py b/dclimate_client_py/ipfs_retrieval.py index 64befb0..020dcac 100644 --- a/dclimate_client_py/ipfs_retrieval.py +++ b/dclimate_client_py/ipfs_retrieval.py @@ -62,6 +62,7 @@ def observe(name: str, seconds: float) -> None: def _attributes( attributes: dict[str, Any] | None = None, ) -> dict[str, OTEL_ATTRIBUTE_VALUE]: + """Return OTEL-safe attributes with unsupported values stringified.""" if not attributes: return {} cleaned: dict[str, OTEL_ATTRIBUTE_VALUE] = {} @@ -78,6 +79,7 @@ def _attributes( def _gateway_metric_attributes( kubo_cas: KuboCAS, ) -> dict[str, OTEL_ATTRIBUTE_VALUE]: + """Return gateway attributes derived from the KuboCAS instance.""" return _attributes( {"dclimate_client.ipfs.gateway": getattr(kubo_cas, "gateway_base_url", None)} ) @@ -90,6 +92,7 @@ def _record_dataset_open( status: str, seconds: float, ) -> None: + """Record dataset-open request count and latency metrics.""" attributes = { **_gateway_metric_attributes(kubo_cas), "dclimate_client.ipfs.store_type": store_type, @@ -106,6 +109,7 @@ def _record_store_open( status: str, seconds: float, ) -> None: + """Record store-open attempt count and latency metrics.""" attributes = { **_gateway_metric_attributes(kubo_cas), "dclimate_client.ipfs.store_type": store_type, @@ -116,6 +120,7 @@ def _record_store_open( def _record_span_error(active_span: Span, exc: Exception) -> None: + """Attach exception details and ERROR status to an active span.""" if not active_span.is_recording(): return active_span.record_exception(exc) @@ -123,6 +128,7 @@ def _record_span_error(active_span: Span, exc: Exception) -> None: def _is_connection_error(exc: Exception) -> bool: + """Classify gateway and network failures from exception text.""" text = str(exc).lower() return any( token in text From a38ba57ca7a12cc0486e6efae4e8eabcafe9bdc8 Mon Sep 17 00:00:00 2001 From: TheGreatAlgo <37487508+TheGreatAlgo@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:54:04 -0400 Subject: [PATCH 5/5] fix: update version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7223e3c..3e037d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "pdm.backend" [project] name = "dclimate-client-py" -version = "0.5.5" # Set a static version or handle it in versioning strategy +version = "0.5.7" # 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"}