From 4dc86a5a5da428bf65db0598f6aa136fabfc3296 Mon Sep 17 00:00:00 2001 From: 0xSwego <0xSwego@gmail.com> Date: Tue, 7 Jul 2026 19:49:19 +0100 Subject: [PATCH 1/4] Update py-hamt to 3.4.0 --- DATASET_CATALOG_USAGE.md | 6 +- README.md | 1 + dclimate_client_py/datasets.py | 1 + dclimate_client_py/dclimate_client.py | 13 +++ dclimate_client_py/ipfs_retrieval.py | 126 ++++++++++++++++++++- pyproject.toml | 2 +- tests/test_ipfs_retrieval.py | 151 ++++++++++++++++++++++++++ uv.lock | 11 +- 8 files changed, 298 insertions(+), 13 deletions(-) diff --git a/DATASET_CATALOG_USAGE.md b/DATASET_CATALOG_USAGE.md index 2f45050..c277271 100644 --- a/DATASET_CATALOG_USAGE.md +++ b/DATASET_CATALOG_USAGE.md @@ -139,6 +139,7 @@ async def load_dataset( organization: Optional[str] = None, # Organization/agency that owns the collection cid: Optional[str] = None, # Direct CID override (bypasses STAC) return_xarray: bool = False, # Return raw xarray.Dataset instead of GeotemporalData + zarr_group: Optional[str] = None, # Explicit Zarr group for grouped/pyramid stores ) -> Union[ Tuple[GeotemporalData, DatasetMetadata], Tuple[xr.Dataset, DatasetMetadata] @@ -155,6 +156,7 @@ async def load_dataset( inferred from the root catalog metadata. - `cid`: Optional direct CID to bypass STAC catalog resolution - `return_xarray`: If True, return raw `xarray.Dataset`, otherwise return `GeotemporalData` wrapper +- `zarr_group`: Optional Zarr group to open. If omitted, multi-group py-hamt v2 stores default to group `"0"` when available. **Returns:** - Tuple of (dataset, metadata) @@ -169,6 +171,7 @@ async def load_dataset( - `url`: Always None for STAC-based loading - `timestamp`: Always None for STAC-based loading - `organization`: The resolved organization id when available + - `zarr_group`: Present when an explicit or inferred Zarr group was opened **Raises:** - `RuntimeError`: If client is not used as async context manager @@ -203,7 +206,8 @@ async with dClimateClient() as client: dataset="temperature", # Used for metadata only collection="ecmwf_ifs", organization="ecmwf", - cid="bafybeiabc123..." + cid="bafybeiabc123...", + zarr_group="0" ) # metadata['source'] will be 'direct_cid' ``` diff --git a/README.md b/README.md index 26675e6..b8ba98c 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ async def main_xarray(): collection="era5", organization="ecmwf", variant="finalized", + zarr_group="0", # Optional for grouped/pyramid sharded stores return_xarray=True # Returns xarray.Dataset ) print(xr_dataset) diff --git a/dclimate_client_py/datasets.py b/dclimate_client_py/datasets.py index 0af1270..c7d41bf 100644 --- a/dclimate_client_py/datasets.py +++ b/dclimate_client_py/datasets.py @@ -96,6 +96,7 @@ class DatasetMetadata(TypedDict, total=False): "catalog", "stac", "direct_cid" ] # How the dataset was loaded organization: Optional[str] + zarr_group: Optional[str] # --- Helper Functions --- diff --git a/dclimate_client_py/dclimate_client.py b/dclimate_client_py/dclimate_client.py index 389c1e8..5f63e00 100644 --- a/dclimate_client_py/dclimate_client.py +++ b/dclimate_client_py/dclimate_client.py @@ -129,6 +129,7 @@ async def load_dataset( organization: typing.Optional[str] = None, cid: typing.Optional[str] = None, return_xarray: bool = False, + zarr_group: typing.Optional[str] = None, ) -> typing.Union[ typing.Tuple[GeotemporalData, DatasetMetadata], typing.Tuple[xr.Dataset, DatasetMetadata], @@ -160,6 +161,10 @@ async def load_dataset( return_xarray : bool, optional If True, return raw xarray.Dataset. If False (default), return GeotemporalData wrapper. + zarr_group : str, optional + Explicit Zarr group to open for grouped/pyramid sharded stores. If + omitted, py-hamt v2 stores with multiple top-level groups default + to group "0" when available. Returns ------- @@ -223,6 +228,7 @@ async def load_dataset( ds = await _load_dataset_from_ipfs_cid( ipfs_cid=cid, kubo_cas=self._kubo_cas, + zarr_group=zarr_group, ) # Build metadata for direct CID case @@ -242,6 +248,9 @@ async def load_dataset( else None ), } + loaded_zarr_group = ds.attrs.get("_ipfs_zarr_group") + if isinstance(loaded_zarr_group, str): + metadata["zarr_group"] = loaded_zarr_group if return_xarray: return ds, metadata @@ -299,6 +308,7 @@ async def load_dataset( ds = await _load_dataset_from_ipfs_cid( ipfs_cid=final_cid, kubo_cas=self._kubo_cas, + zarr_group=zarr_group, ) # Build metadata for STAC case @@ -322,6 +332,9 @@ async def load_dataset( else None ), } + loaded_zarr_group = ds.attrs.get("_ipfs_zarr_group") + if isinstance(loaded_zarr_group, str): + metadata["zarr_group"] = loaded_zarr_group if return_xarray: return ds, metadata diff --git a/dclimate_client_py/ipfs_retrieval.py b/dclimate_client_py/ipfs_retrieval.py index 020dcac..34b35dc 100644 --- a/dclimate_client_py/ipfs_retrieval.py +++ b/dclimate_client_py/ipfs_retrieval.py @@ -6,13 +6,20 @@ import logging import time +import warnings from typing import Any import xarray as xr from multiformats import CID from opentelemetry import metrics, trace from opentelemetry.trace import Span, Status, StatusCode -from py_hamt import HAMT, KuboCAS, ShardedZarrStore, ZarrHAMTStore +from py_hamt import ( + HAMT, + KuboCAS, + ShardedZarrStore, + ShardedZarrV1DeprecationWarning, + ZarrHAMTStore, +) try: import py_hamt.instrumentation as ipfs_instrumentation @@ -146,12 +153,95 @@ def _is_connection_error(exc: Exception) -> bool: ) +def _normalize_zarr_group(zarr_group: str | None) -> str | None: + """Normalize optional Zarr group names for xarray.""" + if zarr_group is None: + return None + normalized = zarr_group.strip("/") + return normalized or None + + +def _zarr_group_candidates(store: Any) -> list[str]: + """Return safe default Zarr groups from py-hamt v2 stores.""" + groups_getter = getattr(store, "_v2_top_level_groups", None) + if not callable(groups_getter): + return [] + + try: + groups = groups_getter() + except Exception: + return [] + + normalized_groups = sorted( + group.strip("/") for group in groups if isinstance(group, str) and group + ) + if "0" not in normalized_groups: + return [] + return ["0"] + + +def _store_requires_explicit_zarr_group(store: Any) -> bool: + """Return whether py-hamt says a root read needs an explicit group.""" + requires_getter = getattr(store, "_v2_requires_explicit_group_for_root_read", None) + if not callable(requires_getter): + return False + + try: + return bool(requires_getter()) + except Exception: + return False + + +def _is_explicit_zarr_group_error(exc: Exception) -> bool: + """Classify py-hamt's multi-group root-read error.""" + text = str(exc) + return "explicit Zarr group" in text or "group='0'" in text + + +def _open_zarr_from_store( + store: Any, + *, + zarr_group: str | None = None, +) -> tuple[xr.Dataset, str | None]: + """Open a Zarr store, choosing a default group for py-hamt v2 pyramids.""" + normalized_group = _normalize_zarr_group(zarr_group) + if normalized_group is not None: + return xr.open_zarr(store=store, group=normalized_group), normalized_group + + if _store_requires_explicit_zarr_group(store): + for candidate_group in _zarr_group_candidates(store): + return xr.open_zarr(store=store, group=candidate_group), candidate_group + + try: + return xr.open_zarr(store=store), None + except ValueError as exc: + if not _is_explicit_zarr_group_error(exc): + raise + for candidate_group in _zarr_group_candidates(store): + return xr.open_zarr(store=store, group=candidate_group), candidate_group + raise + + +async def _open_sharded_zarr_store( + *, + ipfs_cid: str, + kubo_cas: KuboCAS, +) -> ShardedZarrStore: + """Open a py-hamt sharded store without surfacing legacy v1 read warnings.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=ShardedZarrV1DeprecationWarning) + return await ShardedZarrStore.open( + root_cid=ipfs_cid, cas=kubo_cas, read_only=True + ) + + # --- Zarr Dataset Loading --- async def _load_dataset_from_ipfs_cid( ipfs_cid: str, kubo_cas: KuboCAS, + zarr_group: str | None = None, ) -> xr.Dataset: """ Internal function to load a Zarr dataset from IPFS using a provided KuboCAS instance. @@ -163,6 +253,9 @@ async def _load_dataset_from_ipfs_cid( Args: ipfs_cid (str): The IPFS CID of the Zarr dataset's root node. kubo_cas (KuboCAS): An active KuboCAS instance to use for loading. + zarr_group (str, optional): Explicit Zarr group to open. If omitted and + py-hamt reports a multi-group sharded v2 store, group "0" is used + when available. Returns: xr.Dataset: The loaded dataset. @@ -206,6 +299,7 @@ async def _load_dataset_from_ipfs_cid( f"Attempting to load as ShardedZarrStore from CID: {ipfs_cid}" ) sharded_start = time.perf_counter() + sharded_store_opened = False with _TRACER.start_as_current_span( "dclimate_client.ipfs.open_sharded_store", attributes=_attributes( @@ -218,10 +312,15 @@ async def _load_dataset_from_ipfs_cid( set_status_on_exception=False, ) as sharded_span: try: - sharded_store = await ShardedZarrStore.open( - root_cid=ipfs_cid, cas=kubo_cas, read_only=True + sharded_store = await _open_sharded_zarr_store( + ipfs_cid=ipfs_cid, + kubo_cas=kubo_cas, + ) + sharded_store_opened = True + ds, opened_zarr_group = _open_zarr_from_store( + sharded_store, + zarr_group=zarr_group, ) - ds = xr.open_zarr(store=sharded_store) except Exception as sharded_err: sharded_seconds = time.perf_counter() - sharded_start _record_store_open( @@ -240,6 +339,8 @@ async def _load_dataset_from_ipfs_cid( seconds=sharded_seconds, ) ds.attrs["_ipfs_store_type"] = "ShardedZarrStore" + if opened_zarr_group is not None: + ds.attrs["_ipfs_zarr_group"] = opened_zarr_group ipfs_instrumentation.observe( "dclimate_client.open_sharded_store_seconds", sharded_seconds, @@ -257,6 +358,8 @@ async def _load_dataset_from_ipfs_cid( f"IPFS connection failed while loading dataset from CID {ipfs_cid}. Details: {sharded_err}" ) raise connection_error from sharded_err + if sharded_store_opened: + raise # Fall back to HAMT store if sharded loading fails if dataset_span.is_recording(): @@ -290,7 +393,10 @@ async def _load_dataset_from_ipfs_cid( # Wrap with ZarrHAMTStore adapter zarr_hamt_store = ZarrHAMTStore(hamt_store, read_only=True) - ds = xr.open_zarr(store=zarr_hamt_store) + ds, opened_zarr_group = _open_zarr_from_store( + zarr_hamt_store, + zarr_group=zarr_group, + ) except Exception as hamt_err: hamt_seconds = time.perf_counter() - hamt_start _record_store_open( @@ -309,6 +415,8 @@ async def _load_dataset_from_ipfs_cid( seconds=hamt_seconds, ) ds.attrs["_ipfs_store_type"] = "ZarrHAMTStore" + if opened_zarr_group is not None: + ds.attrs["_ipfs_zarr_group"] = opened_zarr_group ipfs_instrumentation.observe( "dclimate_client.open_hamt_store_seconds", hamt_seconds, @@ -365,6 +473,7 @@ async def _get_dataset_by_ipfs_cid( ipfs_cid: str, gateway_uri_stem: str | None = None, rpc_uri_stem: str | None = None, + zarr_group: str | None = None, ) -> xr.Dataset: """ Gets an xarray dataset directly from its Zarr root IPFS CID. @@ -379,6 +488,7 @@ async def _get_dataset_by_ipfs_cid( ipfs_cid (str): The IPFS CID of the Zarr dataset's root node. gateway_uri_stem (str, optional): Custom IPFS HTTP Gateway URI stem. rpc_uri_stem (str, optional): Custom IPFS RPC API URI stem. + zarr_group (str, optional): Explicit Zarr group to open. Returns: xr.Dataset: The loaded dataset. @@ -390,4 +500,8 @@ async def _get_dataset_by_ipfs_cid( async with KuboCAS( rpc_base_url=rpc_uri_stem, gateway_base_url=gateway_uri_stem ) as kubo_cas: - return await _load_dataset_from_ipfs_cid(ipfs_cid, kubo_cas) + return await _load_dataset_from_ipfs_cid( + ipfs_cid, + kubo_cas, + zarr_group=zarr_group, + ) diff --git a/pyproject.toml b/pyproject.toml index dae94d2..ebab547 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "rioxarray", "zarr>=3.0.8", "numpy>=2.1.3", - "py_hamt>=3.3.1", + "py_hamt>=3.4.0", "requests", "pyarrow", "geopandas", diff --git a/tests/test_ipfs_retrieval.py b/tests/test_ipfs_retrieval.py index cfe3d86..63106c9 100644 --- a/tests/test_ipfs_retrieval.py +++ b/tests/test_ipfs_retrieval.py @@ -1,6 +1,11 @@ +import warnings + import pytest +import xarray as xr +import dclimate_client_py.dclimate_client as dclimate_client_module from dclimate_client_py import ipfs_retrieval +from dclimate_client_py.dclimate_client import dClimateClient from dclimate_client_py.dclimate_zarr_errors import IpfsConnectionError @@ -48,3 +53,149 @@ async def hamt_build(**kwargs): VALID_CID, DummyKuboCAS(), ) + + +@pytest.mark.asyncio +async def test_multigroup_sharded_store_defaults_to_group_zero(monkeypatch): + class DummyGroupedStore: + def _v2_requires_explicit_group_for_root_read(self): + return True + + def _v2_top_level_groups(self): + return {"1", "0"} + + async def sharded_open(*, root_cid, cas, read_only): + return DummyGroupedStore() + + opened_groups = [] + + def open_zarr(*, store, group=None): + opened_groups.append(group) + return xr.Dataset() + + monkeypatch.setattr(ipfs_retrieval.ShardedZarrStore, "open", sharded_open) + monkeypatch.setattr(ipfs_retrieval.xr, "open_zarr", open_zarr) + + ds = await ipfs_retrieval._load_dataset_from_ipfs_cid( + VALID_CID, + DummyKuboCAS(), + ) + + assert opened_groups == ["0"] + assert ds.attrs["_ipfs_store_type"] == "ShardedZarrStore" + assert ds.attrs["_ipfs_zarr_group"] == "0" + + +@pytest.mark.asyncio +async def test_explicit_zarr_group_is_passed_to_open_zarr(monkeypatch): + class DummyStore: + pass + + async def sharded_open(*, root_cid, cas, read_only): + return DummyStore() + + opened_groups = [] + + def open_zarr(*, store, group=None): + opened_groups.append(group) + return xr.Dataset() + + monkeypatch.setattr(ipfs_retrieval.ShardedZarrStore, "open", sharded_open) + monkeypatch.setattr(ipfs_retrieval.xr, "open_zarr", open_zarr) + + ds = await ipfs_retrieval._load_dataset_from_ipfs_cid( + VALID_CID, + DummyKuboCAS(), + zarr_group="2", + ) + + assert opened_groups == ["2"] + assert ds.attrs["_ipfs_zarr_group"] == "2" + + +@pytest.mark.asyncio +async def test_zarr_group_error_after_sharded_open_does_not_fallback(monkeypatch): + class DummyStore: + pass + + async def sharded_open(*, root_cid, cas, read_only): + return DummyStore() + + async def hamt_build(**kwargs): + raise AssertionError("HAMT fallback should not be attempted") + + def open_zarr(*, store, group=None): + raise ValueError("explicit Zarr group required") + + monkeypatch.setattr(ipfs_retrieval.ShardedZarrStore, "open", sharded_open) + monkeypatch.setattr(ipfs_retrieval.HAMT, "build", hamt_build) + monkeypatch.setattr(ipfs_retrieval.xr, "open_zarr", open_zarr) + + with pytest.raises(ValueError, match="explicit Zarr group"): + await ipfs_retrieval._load_dataset_from_ipfs_cid( + VALID_CID, + DummyKuboCAS(), + ) + + +@pytest.mark.asyncio +async def test_sharded_v1_warning_is_suppressed_in_client_loader(monkeypatch): + class DummyStore: + pass + + async def sharded_open(*, root_cid, cas, read_only): + warnings.warn( + "sharded_zarr_v1 is deprecated", + ipfs_retrieval.ShardedZarrV1DeprecationWarning, + stacklevel=2, + ) + return DummyStore() + + monkeypatch.setattr(ipfs_retrieval.ShardedZarrStore, "open", sharded_open) + monkeypatch.setattr( + ipfs_retrieval.xr, + "open_zarr", + lambda *, store, group=None: xr.Dataset(), + ) + + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always") + await ipfs_retrieval._load_dataset_from_ipfs_cid( + VALID_CID, + DummyKuboCAS(), + ) + + assert not any( + issubclass( + warning.category, + ipfs_retrieval.ShardedZarrV1DeprecationWarning, + ) + for warning in caught_warnings + ) + + +@pytest.mark.asyncio +async def test_client_direct_cid_passes_zarr_group_and_records_metadata(monkeypatch): + async def load_dataset_from_ipfs_cid(*, ipfs_cid, kubo_cas, zarr_group): + assert ipfs_cid == VALID_CID + assert zarr_group == "2" + return xr.Dataset(attrs={"_ipfs_zarr_group": "2"}) + + monkeypatch.setattr( + dclimate_client_module, + "_load_dataset_from_ipfs_cid", + load_dataset_from_ipfs_cid, + ) + + dclimate = dClimateClient() + dclimate._kubo_cas = DummyKuboCAS() + + ds, metadata = await dclimate.load_dataset( + dataset="pyramid", + cid=VALID_CID, + return_xarray=True, + zarr_group="2", + ) + + assert isinstance(ds, xr.Dataset) + assert metadata["zarr_group"] == "2" diff --git a/uv.lock b/uv.lock index f3c70fb..c9129c4 100644 --- a/uv.lock +++ b/uv.lock @@ -632,7 +632,7 @@ wheels = [ [[package]] name = "dclimate-client-py" -version = "0.5.5" +version = "0.5.9" source = { editable = "." } dependencies = [ { name = "aiobotocore" }, @@ -677,7 +677,7 @@ requires-dist = [ { 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" }, + { name = "py-hamt", specifier = ">=3.4.0" }, { name = "pyarrow" }, { name = "pycryptodome", specifier = ">=3.21.0" }, { name = "pystac", specifier = ">=1.10.0" }, @@ -1455,19 +1455,20 @@ wheels = [ [[package]] name = "py-hamt" -version = "3.3.1" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dag-cbor" }, { name = "httpx", extra = ["http2"] }, { name = "msgspec" }, { name = "multiformats", extra = ["full"] }, + { name = "opentelemetry-api" }, { name = "pycryptodome" }, { name = "zarr" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/46/c3371ca00d00787f168f119afe477fb691c1dd3555e19a6a27f2204964cf/py_hamt-3.3.1.tar.gz", hash = "sha256:65a6c22fb3ba50cc382f6c78427e7269442d43725e3cdf027a53b92edd44d8e5", size = 202731, upload-time = "2025-12-01T13:45:46.787Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/58/b563c568fde2dca469dd5f1aeb90362393ba1b0f658dbfd5f3bb671c19df/py_hamt-3.4.0.tar.gz", hash = "sha256:13095e96cefca2d1a9bbc36e571b627f7c1ada2c3253771da5c549d3e7984aa2", size = 226710, upload-time = "2026-07-07T18:03:55.703Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/08/3b9d9ca319891a507f17bc515c4c0afdc684a0707accfa8537f23a9c28c2/py_hamt-3.3.1-py3-none-any.whl", hash = "sha256:e9836fe9d9e73978353b58fd74d95fe94336eaa0c4e36879c25959c812433083", size = 45857, upload-time = "2025-12-01T13:45:45.463Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0f/241808122e1be49b00ed4db6e6edff7a230f19dcee12bd9657d1f7226b6d/py_hamt-3.4.0-py3-none-any.whl", hash = "sha256:ee2f82a4de475e7693276f10298af3f6b9f643507b5eacd81af6ad9f68c05696", size = 58714, upload-time = "2026-07-07T18:03:57.368Z" }, ] [[package]] From 00fd4c71f833e9b6cd1ef2fff6634feacf303976 Mon Sep 17 00:00:00 2001 From: 0xSwego <0xSwego@gmail.com> Date: Tue, 7 Jul 2026 20:09:27 +0100 Subject: [PATCH 2/4] Bump package version to 0.5.10 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ebab547..ad9159c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "pdm.backend" [project] name = "dclimate-client-py" -version = "0.5.9" # Set a static version or handle it in versioning strategy +version = "0.5.10" # 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"} diff --git a/uv.lock b/uv.lock index c9129c4..aa3e5b9 100644 --- a/uv.lock +++ b/uv.lock @@ -632,7 +632,7 @@ wheels = [ [[package]] name = "dclimate-client-py" -version = "0.5.9" +version = "0.5.10" source = { editable = "." } dependencies = [ { name = "aiobotocore" }, From 6ebada7d7b459278a67ac5e6b73884da5c57f1f7 Mon Sep 17 00:00:00 2001 From: 0xSwego <0xSwego@gmail.com> Date: Tue, 7 Jul 2026 21:12:48 +0100 Subject: [PATCH 3/4] Address grouped zarr review feedback --- dclimate_client_py/dclimate_client.py | 16 ++++--- tests/test_ipfs_retrieval.py | 62 ++++++++++++++++++++------- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/dclimate_client_py/dclimate_client.py b/dclimate_client_py/dclimate_client.py index 5f63e00..47e18ab 100644 --- a/dclimate_client_py/dclimate_client.py +++ b/dclimate_client_py/dclimate_client.py @@ -121,6 +121,14 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): await self._kubo_cas.__aexit__(exc_type, exc_val, exc_tb) self._kubo_cas = None + @staticmethod + def _apply_zarr_group_metadata( + ds: xr.Dataset, metadata: DatasetMetadata + ) -> None: + loaded_zarr_group = ds.attrs.get("_ipfs_zarr_group") + if isinstance(loaded_zarr_group, str): + metadata["zarr_group"] = loaded_zarr_group + async def load_dataset( self, dataset: str, @@ -248,9 +256,7 @@ async def load_dataset( else None ), } - loaded_zarr_group = ds.attrs.get("_ipfs_zarr_group") - if isinstance(loaded_zarr_group, str): - metadata["zarr_group"] = loaded_zarr_group + self._apply_zarr_group_metadata(ds, metadata) if return_xarray: return ds, metadata @@ -332,9 +338,7 @@ async def load_dataset( else None ), } - loaded_zarr_group = ds.attrs.get("_ipfs_zarr_group") - if isinstance(loaded_zarr_group, str): - metadata["zarr_group"] = loaded_zarr_group + self._apply_zarr_group_metadata(ds, metadata) if return_xarray: return ds, metadata diff --git a/tests/test_ipfs_retrieval.py b/tests/test_ipfs_retrieval.py index 63106c9..c6e489c 100644 --- a/tests/test_ipfs_retrieval.py +++ b/tests/test_ipfs_retrieval.py @@ -16,6 +16,18 @@ class DummyKuboCAS: gateway_base_url = "http://example.test" +class DummyStore: + pass + + +class DummyGroupedStore: + def _v2_requires_explicit_group_for_root_read(self): + return True + + def _v2_top_level_groups(self): + return {"1", "0"} + + @pytest.fixture(autouse=True) def check_ipfs_connection(): return None @@ -57,13 +69,6 @@ async def hamt_build(**kwargs): @pytest.mark.asyncio async def test_multigroup_sharded_store_defaults_to_group_zero(monkeypatch): - class DummyGroupedStore: - def _v2_requires_explicit_group_for_root_read(self): - return True - - def _v2_top_level_groups(self): - return {"1", "0"} - async def sharded_open(*, root_cid, cas, read_only): return DummyGroupedStore() @@ -88,9 +93,6 @@ def open_zarr(*, store, group=None): @pytest.mark.asyncio async def test_explicit_zarr_group_is_passed_to_open_zarr(monkeypatch): - class DummyStore: - pass - async def sharded_open(*, root_cid, cas, read_only): return DummyStore() @@ -115,9 +117,6 @@ def open_zarr(*, store, group=None): @pytest.mark.asyncio async def test_zarr_group_error_after_sharded_open_does_not_fallback(monkeypatch): - class DummyStore: - pass - async def sharded_open(*, root_cid, cas, read_only): return DummyStore() @@ -140,9 +139,6 @@ def open_zarr(*, store, group=None): @pytest.mark.asyncio async def test_sharded_v1_warning_is_suppressed_in_client_loader(monkeypatch): - class DummyStore: - pass - async def sharded_open(*, root_cid, cas, read_only): warnings.warn( "sharded_zarr_v1 is deprecated", @@ -174,6 +170,40 @@ async def sharded_open(*, root_cid, cas, read_only): ) +@pytest.mark.asyncio +async def test_hamt_fallback_preserves_explicit_zarr_group(monkeypatch): + async def sharded_open(*, root_cid, cas, read_only): + raise ValueError("not a sharded zarr store") + + async def hamt_build(**kwargs): + return DummyStore() + + opened_groups = [] + + def open_zarr(*, store, group=None): + opened_groups.append(group) + return xr.Dataset() + + monkeypatch.setattr(ipfs_retrieval.ShardedZarrStore, "open", sharded_open) + monkeypatch.setattr(ipfs_retrieval.HAMT, "build", hamt_build) + monkeypatch.setattr( + ipfs_retrieval, + "ZarrHAMTStore", + lambda hamt_store, read_only: hamt_store, + ) + monkeypatch.setattr(ipfs_retrieval.xr, "open_zarr", open_zarr) + + ds = await ipfs_retrieval._load_dataset_from_ipfs_cid( + VALID_CID, + DummyKuboCAS(), + zarr_group="/2/", + ) + + assert opened_groups == ["2"] + assert ds.attrs["_ipfs_store_type"] == "ZarrHAMTStore" + assert ds.attrs["_ipfs_zarr_group"] == "2" + + @pytest.mark.asyncio async def test_client_direct_cid_passes_zarr_group_and_records_metadata(monkeypatch): async def load_dataset_from_ipfs_cid(*, ipfs_cid, kubo_cas, zarr_group): From 67cd871aa60aa727b5a713200da950a671720ce8 Mon Sep 17 00:00:00 2001 From: 0xSwego <0xSwego@gmail.com> Date: Tue, 7 Jul 2026 21:13:54 +0100 Subject: [PATCH 4/4] Apply Ruff formatting --- dclimate_client_py/dclimate_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dclimate_client_py/dclimate_client.py b/dclimate_client_py/dclimate_client.py index 47e18ab..4474c79 100644 --- a/dclimate_client_py/dclimate_client.py +++ b/dclimate_client_py/dclimate_client.py @@ -122,9 +122,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): self._kubo_cas = None @staticmethod - def _apply_zarr_group_metadata( - ds: xr.Dataset, metadata: DatasetMetadata - ) -> None: + def _apply_zarr_group_metadata(ds: xr.Dataset, metadata: DatasetMetadata) -> None: loaded_zarr_group = ds.attrs.get("_ipfs_zarr_group") if isinstance(loaded_zarr_group, str): metadata["zarr_group"] = loaded_zarr_group