Repository: zarr-developers/zarr-python

Issue:
LocalStore.empty() is True if there are empty directories
### Zarr version

v3

### Numcodecs version

?

### Python Version

?

### Operating System

?

### Installation

?

### Description

This should ignore empty directories but doesn't

https://github.com/zarr-developers/zarr-python/blob/6900754138e37c75f952233996d80acbedad60f3/src/zarr/store/local.py#L94-L100

### Steps to reproduce

Discovered in #2202

### Additional output

_No response_

Discussion:

Patch:
diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
index bcff0f61..b03ea06b 100644
--- a/.github/workflows/releases.yml
+++ b/.github/workflows/releases.yml
@@ -55,7 +55,7 @@ jobs:
         with:
           name: releases
           path: dist
-      - uses: pypa/gh-action-pypi-publish@v1.10.1
+      - uses: pypa/gh-action-pypi-publish@v1.10.2
         with:
           user: __token__
           password: ${{ secrets.pypi_password }}
diff --git a/src/zarr/core/array_spec.py b/src/zarr/core/array_spec.py
index 1a251a0a..e84a81cb 100644
--- a/src/zarr/core/array_spec.py
+++ b/src/zarr/core/array_spec.py
@@ -3,11 +3,11 @@ from __future__ import annotations
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any, Literal
 
+import numpy as np
+
 from zarr.core.common import parse_fill_value, parse_order, parse_shapelike
 
 if TYPE_CHECKING:
-    import numpy as np
-
     from zarr.core.buffer import BufferPrototype
     from zarr.core.common import ChunkCoords
 
@@ -29,11 +29,12 @@ class ArraySpec:
         prototype: BufferPrototype,
     ) -> None:
         shape_parsed = parse_shapelike(shape)
+        dtype_parsed = np.dtype(dtype)
         fill_value_parsed = parse_fill_value(fill_value)
         order_parsed = parse_order(order)
 
         object.__setattr__(self, "shape", shape_parsed)
-        object.__setattr__(self, "dtype", dtype)
+        object.__setattr__(self, "dtype", dtype_parsed)
         object.__setattr__(self, "fill_value", fill_value_parsed)
         object.__setattr__(self, "order", order_parsed)
         object.__setattr__(self, "prototype", prototype)
diff --git a/src/zarr/core/attributes.py b/src/zarr/core/attributes.py
index 62ff5fc9..913a4b74 100644
--- a/src/zarr/core/attributes.py
+++ b/src/zarr/core/attributes.py
@@ -51,3 +51,6 @@ class Attributes(MutableMapping[str, JSON]):
            {'a': 3, 'c': 4}
         """
         self._obj = self._obj.update_attributes(d)
+
+    def asdict(self) -> dict[str, JSON]:
+        return dict(self._obj.metadata.attributes)
diff --git a/src/zarr/store/local.py b/src/zarr/store/local.py
index c7883758..fd209cd7 100644
--- a/src/zarr/store/local.py
+++ b/src/zarr/store/local.py
@@ -93,11 +93,15 @@ class LocalStore(Store):
 
     async def empty(self) -> bool:
         try:
-            subpaths = os.listdir(self.root)
+            with os.scandir(self.root) as it:
+                for entry in it:
+                    if entry.is_file():
+                        # stop once a file is found
+                        return False
         except FileNotFoundError:
             return True
         else:
-            return not subpaths
+            return True
 
     def __str__(self) -> str:
         return f"file://{self.root}"


Test patch:
diff --git a/tests/v3/test_attributes.py b/tests/v3/test_attributes.py
index 65b6a02e..14c60492 100644
--- a/tests/v3/test_attributes.py
+++ b/tests/v3/test_attributes.py
@@ -11,3 +11,12 @@ def test_put() -> None:
     attrs.put({"a": 3, "c": 4})
     expected = {"a": 3, "c": 4}
     assert dict(attrs) == expected
+
+
+def test_asdict() -> None:
+    store = zarr.store.MemoryStore({}, mode="w")
+    attrs = zarr.core.attributes.Attributes(
+        zarr.Group.from_store(store, attributes={"a": 1, "b": 2})
+    )
+    result = attrs.asdict()
+    assert result == {"a": 1, "b": 2}
diff --git a/tests/v3/test_store/test_local.py b/tests/v3/test_store/test_local.py
index 5f1dde3f..bdd909c2 100644
--- a/tests/v3/test_store/test_local.py
+++ b/tests/v3/test_store/test_local.py
@@ -35,3 +35,8 @@ class TestLocalStore(StoreTests[LocalStore, cpu.Buffer]):
 
     def test_store_supports_listing(self, store: LocalStore) -> None:
         assert store.supports_listing
+
+    async def test_empty_with_empty_subdir(self, store: LocalStore) -> None:
+        assert await store.empty()
+        (store.root / "foo/bar").mkdir(parents=True)
+        assert await store.empty()