From d89c0350589e7e79a10b45d8f97bd81f7def2634 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 15 Jul 2026 12:09:53 -0600 Subject: [PATCH] Pin the Zarr export to format v3 explicitly The zarr grid export was written with a bare `ds.to_zarr(zarr_dir)`, so its on-disk format version was whatever zarr-python defaulted to rather than something we chose. That artifact is zipped and handed to users via a signed download URL, which makes the format version part of the export contract: zarr-python 2.x cannot read format v3 at all. This matters most for v1 migrants. v1 pinned zarr==2.18.2 and therefore handed out format v2; v2 hands out v3. A user whose environment is still pinned to zarr 2.x downloads a v2 export and it fails to open on their machine, with a confusing error and nothing on our side indicating why. Staying on v3 rather than falling back to the more widely-readable v2: zarr 3 has been out ~18 months, the client base is small, we are in beta where a conscious break is acceptable, and one format across the codebase beats carrying a v2 compat path. The remaining exposure is closed from the client side by pinning zarr>=3 in the SDK. Making it explicit also guards against a future zarr-python default flip silently changing what users receive. Internal grid stores (lib/zarr_utils.py) keep the implicit default: they are read only by our own zarr-python 3.x services and are already 100% v3 on disk across 400 production stores, with no mixed-format legacy. Refs #447 --- services/exporter/exporter/handlers/grid.py | 6 +- services/exporter/tests/handlers/test_zarr.py | 77 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 services/exporter/tests/handlers/test_zarr.py diff --git a/services/exporter/exporter/handlers/grid.py b/services/exporter/exporter/handlers/grid.py index 00b2d40d..c8d6ba42 100644 --- a/services/exporter/exporter/handlers/grid.py +++ b/services/exporter/exporter/handlers/grid.py @@ -177,7 +177,11 @@ def export_zarr( tmp_dir = tempfile.mkdtemp() try: zarr_dir = os.path.join(tmp_dir, "export.zarr") - ds.to_zarr(zarr_dir) + # The format version is part of the export contract: users open this + # store with their own tooling, and zarr-python 2.x cannot read v3. + # Pinned rather than left to the library default so a future default + # flip can't silently change what users receive. + ds.to_zarr(zarr_dir, zarr_format=3) progress("Zipping Zarr...", 85) zip_path = os.path.join(tmp_dir, "export") diff --git a/services/exporter/tests/handlers/test_zarr.py b/services/exporter/tests/handlers/test_zarr.py new file mode 100644 index 00000000..ccc1ef2e --- /dev/null +++ b/services/exporter/tests/handlers/test_zarr.py @@ -0,0 +1,77 @@ +""" +Tests for the zipped-Zarr exporter handler. + +Unit tests mock storage to test handler logic. +""" + +import shutil +import zipfile +from unittest.mock import patch + +import numpy as np +import rioxarray # noqa: F401 +import xarray as xr +import zarr +from exporter.handlers.grid import export_zarr +from rasterio.transform import from_bounds + + +def make_test_dataset( + crs: str = "EPSG:32611", + shape: tuple[int, int] = (10, 10), +) -> xr.Dataset: + """2D Dataset analogous to test_netcdf.make_test_dataset.""" + ny, nx = shape + transform = from_bounds( + 500000, 5200000, 500000 + nx * 30, 5200000 + ny * 30, nx, ny + ) + ds = xr.Dataset( + {"fuel_load.1hr": (("y", "x"), np.random.rand(ny, nx).astype(np.float32))}, + coords={"y": np.arange(ny), "x": np.arange(nx)}, + ) + ds = ds.rio.write_crs(crs) + ds = ds.rio.write_transform(transform) + return ds + + +def noop_progress(message: str, percent: int | None = None) -> None: + pass + + +class TestExportZarrUnit: + """Unit tests for the Zarr handler — mock storage and the GCS upload.""" + + @patch("google.cloud.storage.Client") + @patch("exporter.handlers.grid.load_grid_zarr") + def test_writes_zarr_format_3(self, mock_load, mock_client, tmp_path): + """The exported store must be Zarr format v3. + + Users open this store with their own tooling and zarr-python 2.x + cannot read v3, so the format version is part of the export contract + and must not drift with the library default. + """ + mock_load.return_value = make_test_dataset() + + captured = {} + + def capture(local_path: str) -> None: + dest = tmp_path / "captured.zip" + shutil.copy(local_path, dest) + captured["path"] = str(dest) + + blob = mock_client.return_value.bucket.return_value.blob.return_value + blob.upload_from_filename.side_effect = capture + + export_zarr( + {"id": "test-export"}, + {"grid_id": "grid-abc", "name": "zarr"}, + noop_progress, + ) + + extract_dir = tmp_path / "extracted" + with zipfile.ZipFile(captured["path"]) as zf: + zf.extractall(extract_dir) + + group = zarr.open_group(str(extract_dir / "export.zarr"), mode="r") + assert group.metadata.zarr_format == 3 + assert group["fuel_load.1hr"].metadata.zarr_format == 3