From 8da13336dfa60c2b4412097bc5e58488aebbb66c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 19:28:07 +0200 Subject: [PATCH 1/2] chore(deps): bump the actions group across 1 directory with 8 updates (#176) Bumps the actions group with 8 updates in the / directory: | Package | From | To | | --- | --- | --- | | [prefix-dev/setup-pixi](https://github.com/prefix-dev/setup-pixi) | `0.9.5` | `0.9.6` | | [codecov/codecov-action](https://github.com/codecov/codecov-action) | `6.0.0` | `6.0.1` | | [github/issue-metrics](https://github.com/github/issue-metrics) | `4.2.2` | `4.2.7` | | [j178/prek-action](https://github.com/j178/prek-action) | `2.0.3` | `2.0.4` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.0` | `7.0.1` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `7.0.0` | `8.0.1` | | [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.13.0` | `1.14.0` | | [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.5.3` | `0.5.6` | Updates `prefix-dev/setup-pixi` from 0.9.5 to 0.9.6 - [Release notes](https://github.com/prefix-dev/setup-pixi/releases) - [Commits](https://github.com/prefix-dev/setup-pixi/compare/1b2de7f3351f171c8b4dfeb558c639cb58ed4ec0...5185adfbffb4bd703da3010310260805d89ebb11) Updates `codecov/codecov-action` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/57e3a136b779b570ffcdbf80b3bdc90e7fab3de2...e79a6962e0d4c0c17b229090214935d2e33f8354) Updates `github/issue-metrics` from 4.2.2 to 4.2.7 - [Release notes](https://github.com/github/issue-metrics/releases) - [Commits](https://github.com/github/issue-metrics/compare/c9e9838147fd355dace335ba787f01b6641a400a...1e38d5e62363e14db8019ed7d106b9855bdba6cc) Updates `j178/prek-action` from 2.0.3 to 2.0.4 - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](https://github.com/j178/prek-action/compare/6ad80277337ad479fe43bd70701c3f7f8aa74db3...bdca6f102f98e2b4c7029491a53dfd366469e33d) Updates `actions/upload-artifact` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v7...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) Updates `actions/download-artifact` from 7.0.0 to 8.0.1 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) Updates `pypa/gh-action-pypi-publish` from 1.13.0 to 1.14.0 - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.13.0...cef221092ed1bacb1cc03d23a2d87d1d172e277b) Updates `zizmorcore/zizmor-action` from 0.5.3 to 0.5.6 - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/b1d7e1fb5de872772f31590499237e7cce841e8e...5f14fd08f7cf1cb1609c1e344975f152c7ee938d) --- updated-dependencies: - dependency-name: prefix-dev/setup-pixi dependency-version: 0.9.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: codecov/codecov-action dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: github/issue-metrics dependency-version: 4.2.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: j178/prek-action dependency-version: 2.0.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.14.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: zizmorcore/zizmor-action dependency-version: 0.5.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/downstream.yml | 2 +- .github/workflows/gpu_test.yml | 2 +- .github/workflows/hypothesis.yaml | 2 +- .github/workflows/issue-metrics.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 4 ++-- .github/workflows/zarr-metadata-release.yml | 12 ++++++------ .github/workflows/zizmor.yml | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index 74026233c4..3eb6898895 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -34,7 +34,7 @@ jobs: persist-credentials: false - name: Set up pixi - uses: prefix-dev/setup-pixi@1b2de7f3351f171c8b4dfeb558c639cb58ed4ec0 # v0.9.5 + uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 with: manifest-path: xarray/pixi.toml diff --git a/.github/workflows/gpu_test.yml b/.github/workflows/gpu_test.yml index 403441b306..333769cb9e 100644 --- a/.github/workflows/gpu_test.yml +++ b/.github/workflows/gpu_test.yml @@ -76,7 +76,7 @@ jobs: hatch env run --env "$HATCH_ENV" run-coverage - name: Upload coverage - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} flags: gpu diff --git a/.github/workflows/hypothesis.yaml b/.github/workflows/hypothesis.yaml index 4f9467be7d..a456b2aa0a 100644 --- a/.github/workflows/hypothesis.yaml +++ b/.github/workflows/hypothesis.yaml @@ -93,7 +93,7 @@ jobs: key: cache-hypothesis-${{ runner.os }}-${{ github.run_id }} - name: Upload coverage - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} flags: tests diff --git a/.github/workflows/issue-metrics.yml b/.github/workflows/issue-metrics.yml index 14fba5b9ec..510849ef3e 100644 --- a/.github/workflows/issue-metrics.yml +++ b/.github/workflows/issue-metrics.yml @@ -33,7 +33,7 @@ jobs: echo "last_month=$first_day..$last_day" >> "$GITHUB_ENV" - name: Run issue-metrics tool - uses: github/issue-metrics@c9e9838147fd355dace335ba787f01b6641a400a # v4.2.2 + uses: github/issue-metrics@1e38d5e62363e14db8019ed7d106b9855bdba6cc # v4.2.7 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SEARCH_QUERY: 'repo:zarr-developers/zarr-python is:issue created:${{ env.last_month }} -reason:"not planned"' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 768e660ec2..fec211b4dd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,4 +30,4 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true - - uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 + - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03143d3e5b..62e571856b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,7 +78,7 @@ jobs: hatch env run --env "$HATCH_ENV" run-coverage - name: Upload coverage if: ${{ matrix.dependency-set == 'optional' && matrix.os == 'ubuntu-latest' }} - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} flags: tests @@ -125,7 +125,7 @@ jobs: run: | hatch env run --env "$HATCH_ENV" run-coverage - name: Upload coverage - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} flags: tests diff --git a/.github/workflows/zarr-metadata-release.yml b/.github/workflows/zarr-metadata-release.yml index 809d502f16..9639fcfdd3 100644 --- a/.github/workflows/zarr-metadata-release.yml +++ b/.github/workflows/zarr-metadata-release.yml @@ -35,7 +35,7 @@ jobs: - name: Build run: hatch build - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: zarr-metadata-dist path: packages/zarr-metadata/dist @@ -45,7 +45,7 @@ jobs: needs: [build] runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: zarr-metadata-dist path: dist @@ -76,7 +76,7 @@ jobs: id-token: write # required for OIDC trusted publishing attestations: write # required for artifact attestations steps: - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: zarr-metadata-dist path: dist @@ -87,7 +87,7 @@ jobs: subject-path: dist/* - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 upload_testpypi: name: Upload to TestPyPI @@ -101,7 +101,7 @@ jobs: id-token: write attestations: write steps: - - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: zarr-metadata-dist path: dist @@ -112,6 +112,6 @@ jobs: subject-path: dist/* - name: Publish package to TestPyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index da19f22421..7ac4fe5d0e 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -32,4 +32,4 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 From 54f2a1643c48fb032e42861d74e15e309ecefea3 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Fri, 12 Jun 2026 14:22:31 +0200 Subject: [PATCH 2/2] Fix consolidated-metadata staleness, require_array dtype check, attribute-update semantics, and sync timeout cancellation B11: _members() and Group.members() ignored use_consolidated_for_children when the root group itself had consolidated metadata, causing all descendants to be served from the stale cache. Fix: skip the consolidated fast-path when the flag is False, and pass the flag through the recursive _iter_members_deep call. B12: Group.update_attributes_async replaced attributes instead of merging them, diverging from every other update path. Fix: merge with existing attributes before writing. B13: AsyncGroup.require_array converted dtype=None to np.dtype(None) = float64, then rejected any existing array whose dtype was not castable to float64. Fix: skip the dtype check entirely when the caller did not supply a dtype. B14: The zarr.storage.default_compressor deprecation warning pointed users to array.v2_default_compressor.numeric, a config key that was itself removed in 3.1.0. Fix: update the message to recommend the compressors= argument. B15: sync() did not cancel the underlying concurrent.futures.Future on timeout, leaving the coroutine running indefinitely on the IO thread. Fix: call future.cancel() before raising TimeoutError. B16: NDBuffer.create on GPU used cp.empty when fill_value is None, diverging from the CPU path which uses np.zeros. Fix: mirror CPU semantics. Adds typed regression tests for B11-B15; GPU test for B16 deferred to CI. Co-Authored-By: Claude Fable 5 --- src/zarr/core/buffer/gpu.py | 7 +- src/zarr/core/group.py | 41 ++++++++---- src/zarr/core/sync.py | 1 + src/zarr/storage/__init__.py | 4 +- tests/test_group.py | 123 ++++++++++++++++++++++++++++++++++- tests/test_sync.py | 30 +++++++++ 6 files changed, 188 insertions(+), 18 deletions(-) diff --git a/src/zarr/core/buffer/gpu.py b/src/zarr/core/buffer/gpu.py index 8672942364..27dd65333d 100644 --- a/src/zarr/core/buffer/gpu.py +++ b/src/zarr/core/buffer/gpu.py @@ -176,9 +176,12 @@ def create( order: Literal["C", "F"] = "C", fill_value: Any | None = None, ) -> Self: + # Mirror CPU semantics: zero-fill when fill_value is None (cp.zeros is + # faster than cp.full and matches numpy's behaviour for the common case). + if fill_value is None or (isinstance(fill_value, int) and fill_value == 0): + return cls(cp.zeros(shape=tuple(shape), dtype=dtype, order=order)) ret = cls(cp.empty(shape=tuple(shape), dtype=dtype, order=order)) - if fill_value is not None: - ret.fill(fill_value) + ret.fill(fill_value) return ret @classmethod diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index 52eaa3e144..807b84ceb2 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -1192,13 +1192,14 @@ async def require_array( if shape != ds.shape: raise TypeError(f"Incompatible shape ({ds.shape} vs {shape})") - dtype = np.dtype(dtype) - if exact: - if ds.dtype != dtype: - raise TypeError(f"Incompatible dtype ({ds.dtype} vs {dtype})") - else: - if not np.can_cast(ds.dtype, dtype): - raise TypeError(f"Incompatible dtype ({ds.dtype} vs {dtype})") + if dtype is not None: + _dtype = np.dtype(dtype) + if exact: + if ds.dtype != _dtype: + raise TypeError(f"Incompatible dtype ({ds.dtype} vs {_dtype})") + else: + if not np.can_cast(ds.dtype, _dtype): + raise TypeError(f"Incompatible dtype ({ds.dtype} vs {_dtype})") except KeyError: ds = await self.create_array(name, shape=shape, dtype=dtype, **kwargs) @@ -1345,7 +1346,7 @@ async def _members( else: raise ValueError(f"Unknown Zarr format: {self.metadata.zarr_format}") - if self.metadata.consolidated_metadata is not None: + if self.metadata.consolidated_metadata is not None and use_consolidated_for_children: members = self._members_consolidated(max_depth=max_depth) for member in members: yield member @@ -1949,20 +1950,25 @@ def __repr__(self) -> str: async def update_attributes_async(self, new_attributes: dict[str, Any]) -> Group: """Update the attributes of this group. + Existing attributes are preserved; ``new_attributes`` are merged on top + of them (same semantics as `Group.update_attributes`). + Examples -------- >>> async def example(): ... import zarr ... ... group = zarr.group() - ... new_group = await group.update_attributes_async({"foo": "bar"}) + ... group = group.update_attributes({"foo": "bar"}) + ... new_group = await group.update_attributes_async({"baz": "qux"}) ... return new_group.attrs.asdict() >>> import asyncio >>> asyncio.run(example()) - {'foo': 'bar'} + {'foo': 'bar', 'baz': 'qux'} """ - new_metadata = replace(self.metadata, attributes=new_attributes) + merged = {**self.metadata.attributes, **new_attributes} + new_metadata = replace(self.metadata, attributes=merged) # Write new metadata to_save = new_metadata.to_buffer_dict(default_buffer_prototype()) @@ -2112,7 +2118,12 @@ def members( value: AsyncArray or AsyncGroup The AsyncArray or AsyncGroup that is a child of ``self``. """ - _members = self._sync_iter(self._async_group.members(max_depth=max_depth)) + _members = self._sync_iter( + self._async_group.members( + max_depth=max_depth, + use_consolidated_for_children=use_consolidated_for_children, + ) + ) return tuple((kv[0], _parse_async_node(kv[1])) for kv in _members) @@ -3341,7 +3352,11 @@ async def _iter_members_deep( if is_group and do_recursion: node = cast("AsyncGroup", node) to_recurse[name] = _iter_members_deep( - node, max_depth=new_depth, skip_keys=skip_keys, semaphore=semaphore + node, + max_depth=new_depth, + skip_keys=skip_keys, + semaphore=semaphore, + use_consolidated_for_children=use_consolidated_for_children, ) for prefix, subgroup_iter in to_recurse.items(): diff --git a/src/zarr/core/sync.py b/src/zarr/core/sync.py index 260d4ad841..93c147c116 100644 --- a/src/zarr/core/sync.py +++ b/src/zarr/core/sync.py @@ -150,6 +150,7 @@ def sync[T]( finished, unfinished = wait([future], return_when=asyncio.ALL_COMPLETED, timeout=timeout) if len(unfinished) > 0: + future.cancel() raise TimeoutError(f"Coroutine {coro} failed to finish within {timeout} s") assert len(finished) == 1 return_result = next(iter(finished)).result() diff --git a/src/zarr/storage/__init__.py b/src/zarr/storage/__init__.py index f1bd1724af..65a48d7cfd 100644 --- a/src/zarr/storage/__init__.py +++ b/src/zarr/storage/__init__.py @@ -33,8 +33,8 @@ def __setattr__(self, attr: str, value: Any) -> None: if attr == "default_compressor": warnings.warn( "setting zarr.storage.default_compressor is deprecated, use " - "zarr.config to configure array.v2_default_compressor " - "e.g. config.set({'codecs.zstd':'numcodecs.Zstd', 'array.v2_default_compressor.numeric': 'zstd'})", + "the `compressors` argument to zarr.create_array() instead " + "e.g. zarr.create_array(..., compressors=[numcodecs.Zstd()])", ZarrDeprecationWarning, stacklevel=1, ) diff --git a/tests/test_group.py b/tests/test_group.py index e05df0dfcb..4ab1ae717a 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -695,6 +695,10 @@ def test_group_update_attributes(store: Store, zarr_format: ZarrFormat) -> None: async def test_group_update_attributes_async(store: Store, zarr_format: ZarrFormat) -> None: """ Test the behavior of `Group.update_attributes_async` + + update_attributes_async must *merge* new attributes with existing ones, + matching the semantics of the synchronous update_attributes path. + Regression test for B12. """ attrs = {"foo": 100} group = Group.from_store(store, zarr_format=zarr_format, attributes=attrs) @@ -705,7 +709,9 @@ async def test_group_update_attributes_async(store: Store, zarr_format: ZarrForm new_group = await group.update_attributes_async(new_attrs) else: new_group = await group.update_attributes_async(new_attrs) - assert new_group.attrs == new_attrs + # Both the original and the new key must be present (merge, not overwrite). + expected_attrs = {**attrs, **new_attrs} + assert new_group.attrs == expected_attrs @pytest.mark.parametrize("name", ["a", "/a"]) @@ -2261,3 +2267,118 @@ def test_open_array_as_group(): z = zarr.create_array(shape=(40, 50), chunks=(10, 10), dtype="f8", store={}) with pytest.raises(ContainsArrayError): zarr.open_group(z.store) + + +# --------------------------------------------------------------------------- +# Regression tests for B11-B15 (bugs identified in 2026-06) +# --------------------------------------------------------------------------- + + +@pytest.mark.filterwarnings("ignore:Consolidated metadata") +async def test_iter_members_deep_use_consolidated_flag_propagates( + store: Store, zarr_format: ZarrFormat +) -> None: + """B11 — _iter_members_deep must pass use_consolidated_for_children on recursion. + + Build root/a/b/c, consolidate at a/b so that a/b carries stale + consolidated metadata, then iterate root with + use_consolidated_for_children=False and assert that the grandchild + group (a/b) has consolidated_metadata=None during iteration — meaning + the fresh-read path was taken rather than the cached one. + """ + root = zarr.group(store=store, zarr_format=zarr_format) + a = root.create_group("a") + ab = a.create_group("b") + ab.create_group("c") + + # Consolidate so that a/b.metadata.consolidated_metadata is populated. + if isinstance(store, ZipStore): + with pytest.warns(UserWarning, match="Duplicate name"): + zarr.consolidate_metadata(store=store, zarr_format=zarr_format) + else: + zarr.consolidate_metadata(store=store, zarr_format=zarr_format) + + root = zarr.open_consolidated(store=store, zarr_format=zarr_format) + + # Collect members yielded by the deep iterator without using consolidated + # metadata for children. + members: dict[str, Group | Array] = dict( + root.members(max_depth=None, use_consolidated_for_children=False) + ) + + # The "a/b" entry must have been stripped of its consolidated_metadata. + ab_node = members.get("a/b") + assert ab_node is not None + assert isinstance(ab_node, Group) + assert ab_node.metadata.consolidated_metadata is None, ( + "consolidated_metadata must be cleared when use_consolidated_for_children=False " + "propagates to recursive calls (B11)" + ) + + +async def test_update_attributes_async_merges(store: Store, zarr_format: ZarrFormat) -> None: + """B12 — Group.update_attributes_async must merge, not overwrite. + + Both the synchronous update_attributes and the async variant must + preserve keys that are not in the new_attributes dict. + """ + initial_attrs: dict[str, Any] = {"existing_key": 42} + grp = zarr.group(store=store, zarr_format=zarr_format, attributes=initial_attrs) + + # Synchronous path already merged; verify for completeness. + if isinstance(store, ZipStore): + with pytest.warns(UserWarning, match="Duplicate name"): + grp_after_sync = grp.update_attributes({"sync_key": 1}) + else: + grp_after_sync = grp.update_attributes({"sync_key": 1}) + assert grp_after_sync.attrs["existing_key"] == 42 + assert grp_after_sync.attrs["sync_key"] == 1 + + # Async path — this was the buggy path (B12). + if isinstance(store, ZipStore): + with pytest.warns(UserWarning, match="Duplicate name"): + grp_after_async = await grp_after_sync.update_attributes_async({"async_key": 2}) + else: + grp_after_async = await grp_after_sync.update_attributes_async({"async_key": 2}) + assert grp_after_async.attrs["existing_key"] == 42, ( + "existing_key must survive update_attributes_async (B12)" + ) + assert grp_after_async.attrs["sync_key"] == 1, ( + "sync_key must survive update_attributes_async (B12)" + ) + assert grp_after_async.attrs["async_key"] == 2 + + +async def test_require_array_no_dtype_accepts_any(store: Store, zarr_format: ZarrFormat) -> None: + """B13 — require_array with dtype=None must not reject existing arrays. + + np.dtype(None) resolves to float64, causing can_cast to fail for types + like complex128 when no dtype argument is actually supplied. + """ + root = await AsyncGroup.from_store(store=store, zarr_format=zarr_format) + # Create a complex128 array — not castable to float64. + _ = await root.create_array("data", shape=(5,), dtype="complex128") + + # Calling require_array without a dtype must return the existing array (B13). + arr = await root.require_array("data", shape=(5,)) + assert arr.dtype == np.dtype("complex128"), ( + "require_array without dtype must accept any existing array dtype (B13)" + ) + + # Calling with an *explicit* incompatible dtype must still raise. + with pytest.raises(TypeError, match="Incompatible dtype"): + await root.require_array("data", shape=(5,), dtype="float32") + + +async def test_require_array_no_dtype_exact_accepts_any( + store: Store, zarr_format: ZarrFormat +) -> None: + """B13 (exact=True branch) — require_array(dtype=None, exact=True) must not raise.""" + root = await AsyncGroup.from_store(store=store, zarr_format=zarr_format) + _ = await root.create_array("data", shape=(3,), dtype="int16") + + # Must succeed — no dtype provided, so no check should be performed. + arr = await root.require_array("data", shape=(3,), exact=True) + assert arr.dtype == np.dtype("int16"), ( + "require_array(dtype=None, exact=True) must skip dtype check (B13)" + ) diff --git a/tests/test_sync.py b/tests/test_sync.py index c5eadb0f4f..74f283197e 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -163,3 +163,33 @@ def test_cleanup_resources_idempotent() -> None: _get_executor() # trigger resource creation (iothread, loop, thread-pool) cleanup_resources() cleanup_resources() + + +def test_sync_timeout_cancels_coroutine() -> None: + """B15 — sync() must cancel the underlying future on timeout. + + Before the fix, the coroutine kept running on the IO thread after + TimeoutError was raised, because future.cancel() was never called. + We verify cancellation by having the coroutine set a flag in its + ``finally`` block, which we observe shortly after the timeout. + """ + import threading + + cancelled_flag: list[bool] = [False] + lock = threading.Event() + + async def slow_coro() -> None: + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + cancelled_flag[0] = True + raise + finally: + lock.set() + + with pytest.raises(TimeoutError): + sync(slow_coro(), timeout=0.02) + + # Give the IO loop a moment to process the cancellation. + lock.wait(timeout=1.0) + assert cancelled_flag[0], "coroutine must be cancelled after sync() timeout (B15)"