rustytree-xarray 0.2.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. rustytree_xarray-0.4.0/.github/dependabot.yml +73 -0
  2. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/.github/workflows/ci.yml +35 -8
  3. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/.github/workflows/release.yml +8 -8
  4. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/CHANGELOG.md +327 -1
  5. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/Cargo.lock +262 -109
  6. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/Cargo.toml +34 -5
  7. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/PKG-INFO +6 -5
  8. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/README.md +1 -1
  9. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/docs/architecture.md +13 -2
  10. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/docs/usage.md +67 -10
  11. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/notebooks/klot_demo.ipynb +289 -299
  12. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/pyproject.toml +20 -4
  13. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/python/rustytree/__init__.py +1 -1
  14. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/python/rustytree/_array.py +20 -2
  15. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/python/rustytree/backend.py +128 -57
  16. rustytree_xarray-0.4.0/src/array.rs +740 -0
  17. rustytree_xarray-0.4.0/src/dtype_dispatch.rs +82 -0
  18. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/src/error.rs +0 -7
  19. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/src/icechunk_store.rs +5 -0
  20. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/src/lib.rs +52 -11
  21. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/src/node.rs +4 -1
  22. rustytree_xarray-0.4.0/src/py_credentials.rs +393 -0
  23. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/src/runtime.rs +10 -0
  24. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/src/store.rs +96 -1
  25. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/src/walk.rs +67 -14
  26. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/tests/conftest.py +42 -3
  27. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/tests/test_backend_entrypoint.py +239 -82
  28. rustytree_xarray-0.4.0/tests/test_chunks.py +238 -0
  29. rustytree_xarray-0.4.0/tests/test_codecs.py +80 -0
  30. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/tests/test_eager_fetch.py +6 -9
  31. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/tests/test_icechunk.py +48 -7
  32. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/tests/test_lazy.py +20 -15
  33. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/tests/test_phase1_scaffold.py +1 -1
  34. rustytree_xarray-0.4.0/tests/test_pickle.py +137 -0
  35. rustytree_xarray-0.4.0/tests/test_string_arrays.py +335 -0
  36. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/tests/test_to_rust_source.py +84 -1
  37. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/tests/test_walk.py +25 -1
  38. rustytree_xarray-0.2.1/src/array.rs +0 -300
  39. rustytree_xarray-0.2.1/src/dtype_dispatch.rs +0 -82
  40. rustytree_xarray-0.2.1/tests/test_chunks.py +0 -98
  41. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/.gitignore +0 -0
  42. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/LICENSE +0 -0
  43. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/assets/logo-banner-dark.png +0 -0
  44. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/assets/logo-banner-dark.svg +0 -0
  45. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/assets/logo-banner-light.png +0 -0
  46. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/assets/logo-banner-light.svg +0 -0
  47. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/assets/logo.png +0 -0
  48. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/docs/contributing.md +0 -0
  49. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/docs/release-process.md +0 -0
  50. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/src/glob.rs +0 -0
  51. {rustytree_xarray-0.2.1 → rustytree_xarray-0.4.0}/src/url.rs +0 -0
@@ -0,0 +1,73 @@
1
+ # Dependabot version updates. Opens PRs on a schedule; nothing auto-merges —
2
+ # every PR is reviewed and merged by hand (including the grouped github-actions
3
+ # one). Security updates (CVE-triggered PRs) are enabled separately at the repo
4
+ # level, independent of this schedule.
5
+ #
6
+ # Deliberately NOT managed here (bumped by hand — coordinated, cross-manifest):
7
+ # - icechunk / typetag: FFI lockstep. The Python `icechunk` (pyproject) must
8
+ # share a minor with the Rust `icechunk` (`=2.1.0`) and `typetag`
9
+ # (`=0.2.22`) the crate links — the msgpack `Session` bytes format is
10
+ # coupled across the boundary, so a solo bump breaks `Session::from_bytes`
11
+ # (the 2.0.5→2.0.6 skew behind #46). Bump all three together manually.
12
+ # - ruff: pinned in pyproject AND hardcoded in ci.yml (`uvx ruff@0.15.8`),
13
+ # which Dependabot can't edit; a solo bump desyncs the lint gate and can
14
+ # churn formatting. Bump pyproject + both ci.yml literals together.
15
+ # - dtolnay/rust-toolchain@stable, pypa/gh-action-pypi-publish@release/v1:
16
+ # branch refs, not semver tags — Dependabot leaves them alone (no rule
17
+ # needed).
18
+ version: 2
19
+
20
+ updates:
21
+ # GitHub Actions — the `uses:` version pins in .github/workflows/* (exactly
22
+ # the manual toil of #54). One grouped PR per run.
23
+ - package-ecosystem: github-actions
24
+ directory: "/"
25
+ schedule:
26
+ interval: weekly
27
+ labels: ["dependencies"]
28
+ commit-message:
29
+ prefix: ci
30
+ groups:
31
+ actions:
32
+ patterns: ["*"]
33
+
34
+ # Rust crates (Cargo.toml / Cargo.lock).
35
+ - package-ecosystem: cargo
36
+ directory: "/"
37
+ schedule:
38
+ interval: weekly
39
+ labels: ["dependencies"]
40
+ commit-message:
41
+ prefix: deps
42
+ open-pull-requests-limit: 5
43
+ groups:
44
+ # Keep the zarrs family (zarrs, zarrs_storage, zarrs_object_store,
45
+ # zarrs_icechunk) moving together — they share a release cadence.
46
+ zarrs:
47
+ patterns: ["zarrs*"]
48
+ # pyo3 and numpy track matched 0.x minors (pyo3 0.28 ↔ numpy 0.28).
49
+ pyo3:
50
+ patterns: ["pyo3", "numpy"]
51
+ # Everything else: fold minor/patch into one PR; majors get their own.
52
+ cargo-minor:
53
+ update-types: ["minor", "patch"]
54
+ ignore:
55
+ - dependency-name: "icechunk" # FFI lockstep — bump by hand (see header)
56
+ - dependency-name: "typetag" # FFI lockstep — bump by hand (see header)
57
+
58
+ # Python deps (pyproject.toml: project deps + the `dev` extra).
59
+ - package-ecosystem: pip
60
+ directory: "/"
61
+ schedule:
62
+ interval: weekly
63
+ labels: ["dependencies"]
64
+ commit-message:
65
+ prefix: deps
66
+ open-pull-requests-limit: 5
67
+ groups:
68
+ # Fold minor/patch into one PR; majors get their own.
69
+ python:
70
+ update-types: ["minor", "patch"]
71
+ ignore:
72
+ - dependency-name: "icechunk" # FFI lockstep — bump by hand (see header)
73
+ - dependency-name: "ruff" # also hardcoded in ci.yml — bump by hand
@@ -15,19 +15,45 @@ env:
15
15
  RUSTFLAGS: -D warnings
16
16
 
17
17
  jobs:
18
- # Cargo gates run once on stable Rust. Cheap; no Python needed.
18
+ # Python lint + format gate. Fast and dependency-free (ruff is a standalone
19
+ # binary; no extension build needed), so it runs in parallel with the cargo
20
+ # and pytest jobs and fails fast on style drift. The pinned version must
21
+ # match the `ruff` pin in pyproject's `dev` extra so local `ruff format`
22
+ # and this check agree.
23
+ lint:
24
+ name: ruff (lint + format)
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v7
28
+ - uses: astral-sh/setup-uv@v7
29
+ - run: uvx 'ruff@0.15.8' check .
30
+ - run: uvx 'ruff@0.15.8' format --check .
31
+
32
+ # Cargo gates run once on stable Rust. `cargo test` links libpython (see the
33
+ # test step), so Python is set up here too.
19
34
  cargo:
20
35
  name: cargo (fmt, clippy, test)
21
36
  runs-on: ubuntu-latest
22
37
  steps:
23
- - uses: actions/checkout@v4
38
+ - uses: actions/checkout@v7
24
39
  - uses: dtolnay/rust-toolchain@stable
25
40
  with:
26
41
  components: clippy, rustfmt
42
+ - uses: actions/setup-python@v6
43
+ with:
44
+ python-version: "3.12"
27
45
  - uses: Swatinem/rust-cache@v2
28
46
  - run: cargo fmt --check
29
47
  - run: cargo clippy --all-targets --all-features --locked -- -D warnings
30
- - run: cargo test --locked
48
+ # Build tests WITHOUT `extension-module` so PyO3 links libpython: the
49
+ # credential-fetcher shim's `#[typetag::serde]` impls are force-retained
50
+ # by `inventory` and call `Python::attach`, so the test binary references
51
+ # libpython symbols that can't be dead-code-eliminated (they can under
52
+ # `extension-module`, which suppresses libpython linking for the cdylib).
53
+ - name: cargo test
54
+ run: cargo test --locked --no-default-features
55
+ env:
56
+ LD_LIBRARY_PATH: ${{ env.pythonLocation }}/lib
31
57
 
32
58
  # Python integration: build the cdylib via maturin and run pytest against
33
59
  # the supported interpreter range. Each matrix cell rebuilds the extension
@@ -41,16 +67,16 @@ jobs:
41
67
  matrix:
42
68
  python-version: ["3.12"]
43
69
  steps:
44
- - uses: actions/checkout@v4
70
+ - uses: actions/checkout@v7
45
71
  - uses: dtolnay/rust-toolchain@stable
46
72
  - uses: Swatinem/rust-cache@v2
47
73
  with:
48
74
  # Keyed by Python version so each matrix cell gets its own cache.
49
75
  key: py-${{ matrix.python-version }}
50
- - uses: actions/setup-python@v5
76
+ - uses: actions/setup-python@v6
51
77
  with:
52
78
  python-version: ${{ matrix.python-version }}
53
- - uses: astral-sh/setup-uv@v4
79
+ - uses: astral-sh/setup-uv@v7
54
80
  - name: Set up venv and install maturin
55
81
  run: |
56
82
  uv venv --python "${{ matrix.python-version }}"
@@ -59,8 +85,9 @@ jobs:
59
85
  # Install the [dev] extras directly rather than `pip install -e ".[dev]"`,
60
86
  # which would re-trigger a full cdylib rebuild on top of maturin develop
61
87
  # below (we'd build the wheel twice — saves ~80s per matrix cell).
62
- # Keep this list in sync with the `dev` extra in pyproject.toml.
63
- run: uv pip install --python .venv/bin/python pytest pytest-cov 'zarr>=3.0' 'icechunk>=2.0.5' ruff
88
+ # These are the deps pytest needs; ruff is not among them — linting runs
89
+ # in the dedicated `lint` job above.
90
+ run: uv pip install --python .venv/bin/python pytest pytest-cov 'zarr>=3.0' 'icechunk>=2.1.0,<2.2' 'dask[distributed]>=2024.1'
64
91
  - name: Build extension (maturin develop)
65
92
  run: .venv/bin/maturin develop
66
93
  - name: pytest
@@ -47,8 +47,8 @@ jobs:
47
47
  python-version: ["3.12", "3.13"]
48
48
  runs-on: ${{ matrix.platform.os }}
49
49
  steps:
50
- - uses: actions/checkout@v4
51
- - uses: actions/setup-python@v5
50
+ - uses: actions/checkout@v7
51
+ - uses: actions/setup-python@v6
52
52
  with:
53
53
  python-version: ${{ matrix.python-version }}
54
54
  - uses: dtolnay/rust-toolchain@stable
@@ -61,7 +61,7 @@ jobs:
61
61
  target: ${{ matrix.platform.target }}
62
62
  manylinux: ${{ matrix.platform.manylinux }}
63
63
  args: --release --strip --out dist -i python${{ matrix.python-version }}
64
- - uses: actions/upload-artifact@v4
64
+ - uses: actions/upload-artifact@v7
65
65
  with:
66
66
  name: wheel-${{ matrix.platform.target }}-py${{ matrix.python-version }}
67
67
  path: dist/*.whl
@@ -71,12 +71,12 @@ jobs:
71
71
  name: sdist
72
72
  runs-on: ubuntu-latest
73
73
  steps:
74
- - uses: actions/checkout@v4
74
+ - uses: actions/checkout@v7
75
75
  - uses: PyO3/maturin-action@v1
76
76
  with:
77
77
  command: sdist
78
78
  args: --out dist
79
- - uses: actions/upload-artifact@v4
79
+ - uses: actions/upload-artifact@v7
80
80
  with:
81
81
  name: sdist
82
82
  path: dist/*.tar.gz
@@ -97,8 +97,8 @@ jobs:
97
97
  group: release-${{ github.ref }}
98
98
  cancel-in-progress: false
99
99
  steps:
100
- - uses: actions/checkout@v4
101
- - uses: actions/download-artifact@v4
100
+ - uses: actions/checkout@v7
101
+ - uses: actions/download-artifact@v8
102
102
  with:
103
103
  path: dist
104
104
  merge-multiple: true
@@ -128,7 +128,7 @@ jobs:
128
128
  permissions:
129
129
  id-token: write # mint short-lived OIDC tokens for trusted publishing
130
130
  steps:
131
- - uses: actions/download-artifact@v4
131
+ - uses: actions/download-artifact@v8
132
132
  with:
133
133
  path: dist
134
134
  merge-multiple: true
@@ -11,6 +11,311 @@ release, that section is renamed to `[x.y.z] - YYYY-MM-DD` and a fresh
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [0.4.0] - 2026-07-24
15
+
16
+ ### Added
17
+
18
+ - Dependabot dependency automation ([#56]). A `.github/dependabot.yml` opens
19
+ weekly grouped version-update PRs for three ecosystems — `cargo`, `pip`
20
+ (pyproject), and `github-actions` — plus repo-level vulnerability alerts and
21
+ Dependabot security updates for CVE-triggered PRs. Nothing auto-merges; every
22
+ PR is reviewed by hand. `icechunk`/`typetag` (FFI lockstep) and `ruff` (also
23
+ pinned in `ci.yml`) are intentionally ignored and stay manual, coordinated
24
+ bumps.
25
+
26
+ ### Changed
27
+
28
+ - Bump `zarrs` 0.22 → 0.23 ([#60]). A breaking upstream release: `DataType`
29
+ became a newtype over `Arc<dyn DataTypeTraits>` rather than an enum, so the
30
+ shared `for_each_supported_dtype!` dispatch is now an
31
+ `is::<Float64DataType>()` chain against the marker types in
32
+ `zarrs::array::data_type`. `ArraySubset` moved from the removed
33
+ `zarrs::array_subset` module into `zarrs::array`, and
34
+ `async_retrieve_array_subset_elements::<T>` (deprecated in 0.23, already
35
+ deleted upstream for 0.24) became `async_retrieve_array_subset::<Vec<T>>`.
36
+ The supported-dtype set, the read path, and the storage stack are otherwise
37
+ unchanged — `zarrs_storage` still resolves to a single 0.4.x, so
38
+ `zarrs_object_store` and `zarrs_icechunk` needed no bump and the icechunk
39
+ FFI lockstep is untouched.
40
+ - Fixes a dtype string that 0.23 would have silently corrupted: the walk
41
+ built `VarMeta::dtype` via `format!("{}", array.data_type())`, and 0.23's
42
+ `Display` renders *both* Zarr spellings when they differ (`float64` →
43
+ `"float64 / <f8"`), which `numpy.dtype()` rejects. Both walk sites now
44
+ route through `zarrs_dtype_to_numpy_str`, the same canonical mapper the
45
+ array handle already used.
46
+ - `zarrs_dtype_to_numpy_str` now reads the dtype's Zarr V3 name (identical
47
+ to the NumPy name for all thirteen dtypes we can name; pinned by a new
48
+ unit test) instead of maintaining a parallel hand-written table. The one
49
+ exception is `bytes`, reported as `object` to match zarr-python —
50
+ `np.dtype("bytes")` is `|S0`, which would mislabel a variable-length
51
+ binary array rather than fail. Dtypes we can neither read nor safely
52
+ rename (`string`, `numpy.datetime64`, `r32`, …) still surface as a
53
+ `TypeError` from `np.dtype()` at open, as before.
54
+
55
+ - CI: bump GitHub Actions off the deprecated Node 20 runtime ([#55], fixes #54).
56
+ `actions/checkout` v4 → v5, `actions/setup-python` v5 → v6,
57
+ `actions/upload-artifact` v4 → v6, `actions/download-artifact` v4 → v7 — the
58
+ lowest major of each that runs on Node 24 (verified via each tag's
59
+ `runs.using`; the artifact actions only moved to Node 24 at v6/v7, so a naïve
60
+ bump to v5 would still have been Node 20). No workflow behaviour change.
61
+
62
+ - Raise the NumPy floor `numpy>=1.24` → `numpy>=2.0` ([#71]). Variable-length
63
+ `string` arrays are surfaced as numpy 2's variable-width `StringDType`
64
+ (`np.dtype("T")`), which did not exist before 2.0. rustytree's other pins
65
+ (`zarr>=3`, recent `xarray`) already track numpy 2, so this only makes an
66
+ existing expectation explicit.
67
+
68
+ - Routine dependency and CI-action bumps via Dependabot ([#57], [#58], [#59],
69
+ [#64]) — the first batch after the automation ([#56]) landed. Cargo: `tokio`
70
+ 1.52.3 → 1.53.0, `futures` 0.3.32 → 0.3.33, `zarrs_storage` 0.4.3 → 0.4.4
71
+ ([#64]); `chrono` 0.4.44 → 0.4.45, `zarrs_icechunk` 0.5.0 → 0.5.1 ([#59]);
72
+ `cmov` 0.5.3 → 0.5.4 ([#57]). CI actions ([#58]): `actions/checkout` 5 → 7,
73
+ `astral-sh/setup-uv` 4 → 7, `actions/upload-artifact` 6 → 7, and
74
+ `actions/download-artifact` 7 → 8 — superseding the v5/v6/v7 pins the [#55]
75
+ entry above describes.
76
+
77
+ ### Fixed
78
+
79
+ - Zarr v3 string arrays now open and read instead of crashing ([#71], fixes
80
+ #70). Both v3 string flavours failed before: a variable-length `string` array
81
+ raised `TypeError: data type 'string' not understood` (its name reached
82
+ `np.dtype()` unmapped), and a `fixed_length_utf32` array (numpy `<U…`,
83
+ xarray's default `str` encoding) raised `NotImplementedError` at read. This
84
+ became a production regression when raw2zarr's FM301 retrofit added
85
+ `sweep_mode`/`prt_mode`/`follow_mode` as vlen-`string` scalars to the KLOT
86
+ eager store and the Serbian OSN stores, forcing consumers to fall back to the
87
+ slower `engine="zarr"`. rustytree now mirrors `engine="zarr"` for both
88
+ flavours — `xr.testing.assert_identical` holds against it for all
89
+ valid-Unicode content (2-D chunking, interior NULs, non-BMP code points,
90
+ empty strings, and unwritten-chunk fill values all included):
91
+ - **vlen `string`** is *declared* `object` but its values *materialise* as
92
+ numpy-2 `StringDType`. That declared/read split is exactly what
93
+ `engine="zarr"` produces: xarray's CF-decode flattens a
94
+ `StringDType`-declared variable's values back to `object`, so declaring
95
+ `object` up-front is the only way to keep `StringDType` values. The `numpy`
96
+ crate can only build `object` arrays across the FFI boundary, so the read
97
+ returns `object` and `RustyBackendArray` casts to the dtype the new
98
+ `ZarrsArrayHandle.read_dtype` reports.
99
+ - **`fixed_length_utf32`** → fixed-width `<U{n}` (`n` code points). zarrs 0.23
100
+ opens this dtype (0.22 could not); rustytree decodes the native-endian
101
+ UTF-32 to `str` and builds the `<U` array. One documented divergence: lone
102
+ surrogate code points (U+D800–U+DFFF, malformed Unicode) are dropped rather
103
+ than preserved, because the decode goes through Rust `char`.
104
+ - `slice_nd`'s bound relaxed from `T: Copy` to `T: Clone` so the same slicer
105
+ serves the `String` buffers; zero-cost for the numeric arms.
106
+
107
+ - An empty selection no longer returns a stray element or panics ([#68], fixes
108
+ #65). `isel(time=slice(1, 1))` and friends returned one bogus element whenever
109
+ the offset was not a multiple of the chunk size, and raised
110
+ `pyo3_runtime.PanicException` when the selection sat at the end of a ragged
111
+ final chunk (e.g. `slice(13, 13)` on a shape-13 array with chunks of 4).
112
+ Raggedness was needed only for the panic — the wrong length happened on any
113
+ non-chunk-aligned offset, including on arrays whose shape divides evenly.
114
+ `slice_nd`'s walk was a do-while that pushed an element before it could test
115
+ the bounds; it is now driven by the output element count, so an empty request
116
+ runs zero iterations and totality is structural rather than a guard. The panic
117
+ mattered beyond the wrong length: `PanicException` derives from
118
+ `BaseException`, so it passed straight through ordinary `except Exception:`
119
+ handlers. Empty selections also short-circuit before the chunk-alignment step
120
+ now, so they no longer pull a chunk off the network to return nothing.
121
+
122
+ - A reversed slice is empty rather than an error ([#68]). `v.isel(n=slice(5, 3))`
123
+ and `v[5:3]` raised `ValueError: start 5 > stop 3`, where numpy, pandas and
124
+ `engine="zarr"` all return an empty array. `slice.indices()` reports a reversed
125
+ slice faithfully as `(5, 3, 1)`, and that pair reached `read_subset` unchanged;
126
+ the Python adapter now normalises it. `read_subset` keeps its stricter
127
+ `start <= stop` contract, so a genuinely malformed range still raises.
128
+
129
+ - Tests: the two opt-in KTWX smoke tests now skip when the directory exists but
130
+ holds no icechunk repository ([#60]). The guard tested `KTWX_PATH.exists()`,
131
+ so an empty leftover directory failed the guard's purpose and the tests
132
+ errored with `KeyError: group / not found in store` instead of skipping. The
133
+ shared `ktwx_repo_available()` helper in `tests/conftest.py` now mirrors
134
+ rustytree's own detector (`looks_like_icechunk_repo`): a `repo` manifest file
135
+ plus a `snapshots/` directory.
136
+
137
+ ### Security
138
+
139
+ - Bump `pyo3` and `numpy` 0.28 → 0.29 ([#61]) to fix a **high**-severity
140
+ out-of-bounds read in PyO3's `PyIterator` `nth`/`nth_back` (RUSTSEC/GHSA)
141
+ plus a medium-severity missing `Sync` bound on `PyCFunction::new_closure`.
142
+ A clean bump — no source changes; the pyo3/numpy majors move in lockstep.
143
+ (Surfaced by the newly-enabled Dependabot security alerts.)
144
+
145
+ ## [0.3.0] - 2026-07-04
146
+
147
+ ### Added
148
+
149
+ - `group_filter` keyword for `open_datatree` ([#49]). An
150
+ `fnmatch`-style glob matched against every group path — only matching groups
151
+ (plus their ancestors and the root, so the tree stays connected) are loaded.
152
+ Mirrors xarray PR [pydata/xarray#11302](https://github.com/pydata/xarray/pull/11302):
153
+ matching follows `PurePosixPath.match` (right-anchored), so
154
+ `group_filter="*/sweep_0"` opens the lowest sweep of every volume, and group
155
+ names that literally contain glob metacharacters are reachable via character-
156
+ class escapes (`[*]`, `[?]`, `[[]`). Mutually exclusive with `group`. The Rust
157
+ walk prunes non-matching subtrees up front (conservative prefix predicate);
158
+ Python's `_filter_by_glob` remains the source of truth. `open_dataset` rejects
159
+ `group_filter` (a single Dataset has no defined target for a multi-match glob).
160
+
161
+ - Docs: a "Distributed compute (`dask.distributed` / Coiled)" section in
162
+ `docs/usage.md` ([#46]) covering the requirements for a remote cluster
163
+ (`spawn` workers, matching `rustytree` + `icechunk` in the worker environment)
164
+ and — mirroring icechunk's own guidance — how to avoid shipping secrets to
165
+ workers: use `from_env` / `refreshable` / `anonymous` credentials (secret-free
166
+ in the task graph) rather than `static` keys. Verified end-to-end that a
167
+ `from_env` session opened via rustytree pickles with no secret in the graph and
168
+ still computes on a distributed cluster.
169
+
170
+ - Picklable array handles for `dask.distributed` ([#44], fixes #44).
171
+ DataArrays opened with `engine="rustytree"` could not be computed under a
172
+ `dask.distributed` cluster — `.compute()` failed while distributed pickled
173
+ the task graph, because each lazy chunk held a native
174
+ `ZarrsArrayHandle` that raised `TypeError: cannot pickle
175
+ 'rustytree._rustytree.ZarrsArrayHandle' object`. `ZarrsArrayHandle` now
176
+ implements `__reduce__`: for stores opened via an icechunk `Session` (both
177
+ local-filesystem and remote S3, the cases in #44) the handle carries the
178
+ session's own `as_bytes()` msgpack plus the array path, and a worker revives
179
+ it via icechunk's `Session::from_bytes` + `Array::async_open`. This mirrors
180
+ icechunk exactly — rustytree adds **no** credential handling of its own, so
181
+ credential exposure is precisely icechunk's: `from_env` / `anonymous` sessions
182
+ carry no secret into the task graph, while **static** credentials (and a
183
+ refreshable session's scattered `initial` creds) are embedded in the session
184
+ bytes and travel to every worker in the pickled graph — the same as
185
+ distributing an icechunk Session directly. Opening through an icechunk Session
186
+ with `from_env` / `anonymous` / `refreshable` credentials is the recommended
187
+ distributed pattern. Workers must use the `spawn` start-method (the
188
+ `dask.distributed` default); rustytree's tokio runtime is not fork-safe, so
189
+ fork-based multiprocessing after opening a store is unsupported. Vanilla
190
+ `s3://` / local Zarr stores are not yet picklable (no icechunk Session to
191
+ mirror) and now raise a clear, actionable error at pickle time instead of the
192
+ opaque default. New `tests/test_pickle.py` covers the pickle round-trip
193
+ (including deeply-nested arrays and multi-hop re-pickling), the not-picklable
194
+ and corrupt-state error paths, and an opt-in `distributed`-marked
195
+ `LocalCluster` compute.
196
+
197
+ - `numcodecs.zlib` codec support ([#41], fixes #42). Enables the `zlib`
198
+ feature on the `zarrs` dependency so rustytree can decode arrays whose
199
+ codec pipeline uses the non-standard `numcodecs.`-namespace codecs
200
+ zarr-python writes — e.g. the public `earthmover-public/goes-16`
201
+ arraylake dataset (and the ismip6 icechunk store reported in #42, where
202
+ enabling it gave a ~10× speedup over `engine="zarr"`), which chain
203
+ `bytes → numcodecs.shuffle → numcodecs.zlib`. zarrs's `zlib` codec is
204
+ documented byte-compatible
205
+ with zarr-python's; `numcodecs.shuffle` is always compiled, and
206
+ `gzip` / `blosc` / `zstd` / `crc32c` already come from zarrs's
207
+ default features. Verified bit-for-bit against zarr-python on a
208
+ GOES-16 `CMI_C01` slice. New non-gated `tests/test_codecs.py` writes
209
+ the shuffle+zlib pipeline to a local store and checks the decode
210
+ round-trips.
211
+
212
+ ### Changed
213
+
214
+ - CI now runs `ruff check` and `ruff format --check` in a dedicated `lint` job,
215
+ and `ruff` is pinned (`>=0.15.8,<0.16`) in the `dev` extra so local and CI
216
+ formatting agree ([#50], fixes #48). Previously CI installed `ruff` but never
217
+ invoked it, so lint/format drift accumulated silently on `main`; this normalises
218
+ the drifted files (`backend.py`, `conftest.py`, `test_backend_entrypoint.py`,
219
+ `test_codecs.py`, `klot_demo.ipynb`) in one no-logic-change pass and gates
220
+ against recurrence. No runtime behaviour change.
221
+
222
+ - **Breaking:** `group=` is now exact-path only and no longer auto-detects glob
223
+ patterns ([#49]). Previously a `group=` value containing `*`, `?`, or `[`
224
+ silently switched to glob filtering; that overloaded behaviour is removed in
225
+ favour of the explicit `group_filter=` (see Added). This aligns with the final
226
+ design of xarray PR [pydata/xarray#11302](https://github.com/pydata/xarray/pull/11302),
227
+ which rejected the ambiguous overloaded form because group names may legitimately
228
+ contain glob metacharacters. Migrate `open_datatree(group="*/sweep_0")` to
229
+ `open_datatree(group_filter="*/sweep_0")`; `group` and `group_filter` are
230
+ mutually exclusive (passing both raises `ValueError`). A `group=` value with
231
+ glob characters is now looked up as a literal path.
232
+
233
+ - Docs: tidy the `notebooks/klot_demo.ipynb` demo — trim WHAT-narrating comments
234
+ and add an `xradar` prerequisite note ([#47]). No code change.
235
+
236
+ - Bump the pinned `icechunk` from 2.0.5 to 2.1.0 ([#46]). rustytree links the
237
+ `icechunk` Rust crate and round-trips sessions through
238
+ `Session::{as,from}_bytes`, so it can only open stores whose on-disk format
239
+ matches its pinned icechunk — 2.0.5 could not open repositories written by
240
+ icechunk 2.0.6+, failing in `Repository::open` with "the repository doesn't
241
+ exist" even though the store was present and the credentials valid. Bumping to
242
+ 2.1.0 restores access to current icechunk stores; `typetag` moves to `=0.2.22`
243
+ to keep sharing icechunk's `inventory` credential-fetcher registry (the #41
244
+ `py_credentials` shim). The `dev` extra and CI now require
245
+ `icechunk>=2.1.0,<2.2` so the Python and Rust icechunk versions share a minor —
246
+ the msgpack session-bytes format is coupled across the FFI boundary, and a skew
247
+ is exactly what produced the "repository doesn't exist" failure. No rustytree
248
+ source changes were needed (the API surface is unchanged). Verified end-to-end
249
+ against a real credentialed AWS S3 icechunk 2.0.6 store: parity with
250
+ `engine="zarr"`, pickle round-trip, and a distributed `LocalCluster` compute
251
+ whose workers reopen the store from the pickled session.
252
+
253
+ ### Fixed
254
+
255
+ - Unified the missing-group error type across backends ([#52], fixes #51).
256
+ A literal `group=` path that doesn't exist now raises `KeyError` on both
257
+ vanilla Zarr and icechunk stores — previously vanilla raised `RuntimeError`
258
+ (from the Rust walk) while icechunk raised `KeyError`, so callers couldn't
259
+ `except` one type. The Rust walk now maps zarrs' `GroupCreateError::MissingMetadata`
260
+ to `RustytreeError::NotFound` (→ `PyKeyError`); genuine storage/IO/corruption
261
+ errors still surface as `RuntimeError`.
262
+
263
+ - `open_dataset` and `open_datatree` now validate the `group`/`group_filter`
264
+ pair consistently ([#52]). Passing both to `open_dataset` raises the same
265
+ `ValueError` (mutually exclusive) as `open_datatree` instead of a
266
+ `NotImplementedError`; the shared validator (renamed
267
+ `_check_group_filter_mutex` → `_validate_group_filter` since it also rejects
268
+ an empty `group_filter`) runs first on both entry points.
269
+
270
+ - Docs and the `klot_demo` notebook still used the removed glob-`group=`
271
+ form (`group="*/sweep_0"`) that [#49] turned into a literal-path lookup —
272
+ the examples would now raise instead of filtering. Migrated `README.md`,
273
+ `docs/usage.md`, and `notebooks/klot_demo.ipynb` to `group_filter=`
274
+ ([#50]).
275
+
276
+ - Spurious `SerializationWarning: variable '...' has multiple fill values`
277
+ and silently-broken masking for stores whose `_FillValue` is written in
278
+ raw Zarr wire form ([#43]). Some virtual/icechunk stores (e.g. the
279
+ source.coop ISMIP6 dataset) carry `_FillValue` as a base64-encoded
280
+ string (`'AAAAgB2vFUQ='`) alongside a numeric `missing_value`; xarray's
281
+ CF mask coder saw two distinct sentinels for the same value and warned,
282
+ and the base64 string masked nothing (a float array never equals a
283
+ `str`), so masking quietly fell back to `missing_value` alone.
284
+ `_RustyDataStore.get_variables` now mirrors xarray's zarr backend
285
+ (`ZarrStore.open_store_variable`): a base64 `str`/`bytes` `_FillValue`
286
+ is decoded to a numeric sentinel via `FillValueCoder.decode`, while an
287
+ already-numeric fill is left untouched. Verified byte-for-byte against
288
+ `engine="zarr"` on the ISMIP6 store (warnings 28 → 0, identical
289
+ `_FillValue` encoding and masking). Non-numeric list/tuple wire forms
290
+ (e.g. a complex `[real, imag]` fill) have no decoder branch and pass
291
+ through unchanged rather than raising. Network-free regression tests
292
+ cover the base64 decode, numeric pass-through, and list pass-through.
293
+
294
+ - Open arraylake / Earthmover icechunk sessions ([#41], fixes #40).
295
+ `xr.open_datatree(session.store, engine="rustytree")` raised
296
+ `ValueError: icechunk session: unknown error: unknown variant
297
+ PythonCredentialsFetcher, there are no variants` for sessions
298
+ created via arraylake. Such sessions store S3 credentials as
299
+ `Refreshable(Arc<dyn S3CredentialsFetcher>)` whose concrete fetcher
300
+ is `#[typetag::serde]`-registered only inside icechunk-python's
301
+ cdylib; rustytree links the vanilla `icechunk` crate, whose fetcher
302
+ registry is empty, so `Session::from_bytes` couldn't resolve the tag.
303
+ `xr.open_zarr` is unaffected because it uses the live in-process
304
+ store and never round-trips through bytes. New `src/py_credentials.rs`
305
+ re-registers `PythonCredentialsFetcher` (S3 / GCS / Azure) inside
306
+ rustytree's cdylib, mirroring icechunk-python's `get()`: serve the
307
+ scattered `initial` static credentials while fresh, else acquire the
308
+ GIL and re-run the embedded pickled credential callable to refresh —
309
+ so the common case and `scatter_initial_credentials=False` /
310
+ mid-walk credential expiry all keep working. `typetag` is pinned
311
+ `=0.2.21` to share icechunk's `inventory` registry. A Python-side
312
+ friendly error (`_rust_open_or_explain` in `backend.py`) is a safety
313
+ net for a future icechunk-python that renames the fetcher. CI now
314
+ builds tests with `--no-default-features` so PyO3 links libpython:
315
+ the inventory-retained fetcher impls reference `Python::attach`, so
316
+ their libpython symbols can no longer be dead-code-eliminated from
317
+ the test binary.
318
+
14
319
  ## [0.2.1] - 2026-05-23
15
320
 
16
321
  ### Fixed
@@ -530,7 +835,10 @@ below.
530
835
  intentionally not normalized — relative-vs-absolute glob
531
836
  semantics differ in `PurePosixPath.match`.
532
837
 
533
- [Unreleased]: https://github.com/aladinor/rustytree/compare/v0.2.0...HEAD
838
+ [Unreleased]: https://github.com/aladinor/rustytree/compare/v0.4.0...HEAD
839
+ [0.4.0]: https://github.com/aladinor/rustytree/compare/v0.3.0...v0.4.0
840
+ [0.3.0]: https://github.com/aladinor/rustytree/compare/v0.2.1...v0.3.0
841
+ [0.2.1]: https://github.com/aladinor/rustytree/compare/v0.2.0...v0.2.1
534
842
  [0.2.0]: https://github.com/aladinor/rustytree/compare/v0.1.0...v0.2.0
535
843
  [0.1.0]: https://github.com/aladinor/rustytree/releases/tag/v0.1.0
536
844
  [#1]: https://github.com/aladinor/rustytree/pull/1
@@ -558,3 +866,21 @@ below.
558
866
  [#25]: https://github.com/aladinor/rustytree/pull/25
559
867
  [#26]: https://github.com/aladinor/rustytree/pull/26
560
868
  [#27]: https://github.com/aladinor/rustytree/pull/27
869
+ [#41]: https://github.com/aladinor/rustytree/pull/41
870
+ [#43]: https://github.com/aladinor/rustytree/pull/43
871
+ [#44]: https://github.com/aladinor/rustytree/pull/44
872
+ [#46]: https://github.com/aladinor/rustytree/pull/46
873
+ [#47]: https://github.com/aladinor/rustytree/pull/47
874
+ [#49]: https://github.com/aladinor/rustytree/pull/49
875
+ [#50]: https://github.com/aladinor/rustytree/pull/50
876
+ [#52]: https://github.com/aladinor/rustytree/pull/52
877
+ [#55]: https://github.com/aladinor/rustytree/pull/55
878
+ [#56]: https://github.com/aladinor/rustytree/pull/56
879
+ [#57]: https://github.com/aladinor/rustytree/pull/57
880
+ [#58]: https://github.com/aladinor/rustytree/pull/58
881
+ [#59]: https://github.com/aladinor/rustytree/pull/59
882
+ [#60]: https://github.com/aladinor/rustytree/pull/60
883
+ [#61]: https://github.com/aladinor/rustytree/pull/61
884
+ [#64]: https://github.com/aladinor/rustytree/pull/64
885
+ [#68]: https://github.com/aladinor/rustytree/pull/68
886
+ [#71]: https://github.com/aladinor/rustytree/pull/71