Skip to content

Commit b8dbf56

Browse files
d-v-bmaxrjones
andauthored
chore/unformatted exceptions (#3403)
* refactor errors * make metadatavalidationerrors take a single argument * create all errors with a single argument * move indexing-specific errors into the main errors module * ensure tests pass * remove redundant template from array indexing exception * add tests for single-argument templated exceptions * changelog * put the f on the f string * Update src/zarr/core/group.py Co-authored-by: Max Jones <14077947+maxrjones@users.noreply.github.com> * fix test for specific error message --------- Co-authored-by: Max Jones <14077947+maxrjones@users.noreply.github.com>
1 parent 6d4b5e7 commit b8dbf56

File tree

12 files changed

+187
-81
lines changed

12 files changed

+187
-81
lines changed

changes/3403.misc.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Moves some indexing-specific exceptions to ``zarr.errors``, and ensures that all Zarr-specific
2+
exception classes accept a pre-formatted string as a single argument. This is a breaking change to
3+
the following exceptions classes: :class:`zarr.errors.BoundsCheckError`, :class:`zarr.errors.NegativeStepError`
4+
:class:`zarr.errors.VindexInvalidSelectionError`. These classes previously generated internally
5+
formatted error messages when given a single argument. After this change, formatting of the error
6+
message is up to the routine invoking the error.

src/zarr/api/asynchronous.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,8 @@ async def open_group(
862862
overwrite=overwrite,
863863
attributes=attributes,
864864
)
865-
raise GroupNotFoundError(store, store_path.path)
865+
msg = f"No group found in store {store!r} at path {store_path.path!r}"
866+
raise GroupNotFoundError(msg)
866867

867868

868869
async def create(
@@ -1268,7 +1269,8 @@ async def open_array(
12681269
overwrite=overwrite,
12691270
**kwargs,
12701271
)
1271-
raise ArrayNotFoundError(store_path.store, store_path.path) from err
1272+
msg = f"No array found in store {store_path.store} at path {store_path.path}"
1273+
raise ArrayNotFoundError(msg) from err
12721274

12731275

12741276
async def open_like(

src/zarr/core/array.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,8 @@ async def get_array_metadata(
257257
else:
258258
zarr_format = 2
259259
else:
260-
raise MetadataValidationError("zarr_format", "2, 3, or None", zarr_format)
260+
msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." # type: ignore[unreachable]
261+
raise MetadataValidationError(msg)
261262

262263
metadata_dict: dict[str, JSON]
263264
if zarr_format == 2:

src/zarr/core/group.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ def parse_node_type(data: Any) -> NodeType:
9696
"""Parse the node_type field from metadata."""
9797
if data in ("array", "group"):
9898
return cast("Literal['array', 'group']", data)
99-
raise MetadataValidationError("node_type", "array or group", data)
99+
msg = f"Invalid value for 'node_type'. Expected 'array' or 'group'. Got '{data}'."
100+
raise MetadataValidationError(msg)
100101

101102

102103
# todo: convert None to empty dict
@@ -574,7 +575,8 @@ async def open(
574575
else:
575576
zarr_format = 2
576577
else:
577-
raise MetadataValidationError("zarr_format", "2, 3, or None", zarr_format)
578+
msg = f"Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '{zarr_format}'." # type: ignore[unreachable]
579+
raise MetadataValidationError(msg)
578580

579581
if zarr_format == 2:
580582
# this is checked above, asserting here for mypy
@@ -3129,10 +3131,12 @@ async def create_hierarchy(
31293131
else:
31303132
# we have proposed an explicit group, which is an error, given that a
31313133
# group already exists.
3132-
raise ContainsGroupError(store, key)
3134+
msg = f"A group exists in store {store!r} at path {key!r}."
3135+
raise ContainsGroupError(msg)
31333136
elif isinstance(extant_node, ArrayV2Metadata | ArrayV3Metadata):
31343137
# we are trying to overwrite an existing array. this is an error.
3135-
raise ContainsArrayError(store, key)
3138+
msg = f"An array exists in store {store!r} at path {key!r}."
3139+
raise ContainsArrayError(msg)
31363140

31373141
nodes_explicit: dict[str, GroupMetadata | ArrayV2Metadata | ArrayV3Metadata] = {}
31383142

@@ -3549,7 +3553,8 @@ def _build_metadata_v3(zarr_json: dict[str, JSON]) -> ArrayV3Metadata | GroupMet
35493553
Convert a dict representation of Zarr V3 metadata into the corresponding metadata class.
35503554
"""
35513555
if "node_type" not in zarr_json:
3552-
raise MetadataValidationError("node_type", "array or group", "nothing (the key is missing)")
3556+
msg = "Required key 'node_type' is missing from the provided metadata document."
3557+
raise MetadataValidationError(msg)
35533558
match zarr_json:
35543559
case {"node_type": "array"}:
35553560
return ArrayV3Metadata.from_dict(zarr_json)

src/zarr/core/indexing.py

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828

2929
from zarr.core.common import ceildiv, product
3030
from zarr.core.metadata import T_ArrayMetadata
31+
from zarr.errors import (
32+
ArrayIndexError,
33+
BoundsCheckError,
34+
NegativeStepError,
35+
VindexInvalidSelectionError,
36+
)
3137

3238
if TYPE_CHECKING:
3339
from zarr.core.array import Array, AsyncArray
@@ -51,29 +57,6 @@
5157
Fields = str | list[str] | tuple[str, ...]
5258

5359

54-
class ArrayIndexError(IndexError):
55-
pass
56-
57-
58-
class BoundsCheckError(IndexError):
59-
_msg = ""
60-
61-
def __init__(self, dim_len: int) -> None:
62-
self._msg = f"index out of bounds for dimension with length {dim_len}"
63-
64-
65-
class NegativeStepError(IndexError):
66-
_msg = "only slices with step >= 1 are supported"
67-
68-
69-
class VindexInvalidSelectionError(IndexError):
70-
_msg = (
71-
"unsupported selection type for vectorized indexing; only "
72-
"coordinate selection (tuple of integer arrays) and mask selection "
73-
"(single Boolean array) are supported; got {!r}"
74-
)
75-
76-
7760
def err_too_many_indices(selection: Any, shape: tuple[int, ...]) -> None:
7861
raise IndexError(f"too many indices for array; expected {len(shape)}, got {len(selection)}")
7962

@@ -361,7 +344,8 @@ def normalize_integer_selection(dim_sel: int, dim_len: int) -> int:
361344

362345
# handle out of bounds
363346
if dim_sel >= dim_len or dim_sel < 0:
364-
raise BoundsCheckError(dim_len)
347+
msg = f"index out of bounds for dimension with length {dim_len}"
348+
raise BoundsCheckError(msg)
365349

366350
return dim_sel
367351

@@ -421,7 +405,7 @@ def __init__(self, dim_sel: slice, dim_len: int, dim_chunk_len: int) -> None:
421405
# normalize
422406
start, stop, step = dim_sel.indices(dim_len)
423407
if step < 1:
424-
raise NegativeStepError
408+
raise NegativeStepError("only slices with step >= 1 are supported.")
425409

426410
object.__setattr__(self, "start", start)
427411
object.__setattr__(self, "stop", stop)
@@ -744,7 +728,8 @@ def wraparound_indices(x: npt.NDArray[Any], dim_len: int) -> None:
744728

745729
def boundscheck_indices(x: npt.NDArray[Any], dim_len: int) -> None:
746730
if np.any(x < 0) or np.any(x >= dim_len):
747-
raise BoundsCheckError(dim_len)
731+
msg = f"index out of bounds for dimension with length {dim_len}"
732+
raise BoundsCheckError(msg)
748733

749734

750735
@dataclass(frozen=True)
@@ -1098,7 +1083,8 @@ def __init__(
10981083
dim_indexers.append(dim_indexer)
10991084

11001085
if start >= dim_len or start < 0:
1101-
raise BoundsCheckError(dim_len)
1086+
msg = f"index out of bounds for dimension with length {dim_len}"
1087+
raise BoundsCheckError(msg)
11021088

11031089
shape = tuple(s.nitems for s in dim_indexers)
11041090

@@ -1329,7 +1315,12 @@ def __getitem__(
13291315
elif is_mask_selection(new_selection, self.array.shape):
13301316
return self.array.get_mask_selection(new_selection, fields=fields)
13311317
else:
1332-
raise VindexInvalidSelectionError(new_selection)
1318+
msg = (
1319+
"unsupported selection type for vectorized indexing; only "
1320+
"coordinate selection (tuple of integer arrays) and mask selection "
1321+
f"(single Boolean array) are supported; got {new_selection!r}"
1322+
)
1323+
raise VindexInvalidSelectionError(msg)
13331324

13341325
def __setitem__(
13351326
self, selection: CoordinateSelection | MaskSelection, value: npt.ArrayLike
@@ -1342,7 +1333,12 @@ def __setitem__(
13421333
elif is_mask_selection(new_selection, self.array.shape):
13431334
self.array.set_mask_selection(new_selection, value, fields=fields)
13441335
else:
1345-
raise VindexInvalidSelectionError(new_selection)
1336+
msg = (
1337+
"unsupported selection type for vectorized indexing; only "
1338+
"coordinate selection (tuple of integer arrays) and mask selection "
1339+
f"(single Boolean array) are supported; got {new_selection!r}"
1340+
)
1341+
raise VindexInvalidSelectionError(msg)
13461342

13471343

13481344
@dataclass(frozen=True)
@@ -1368,7 +1364,12 @@ async def getitem(
13681364
elif is_mask_selection(new_selection, self.array.shape):
13691365
return await self.array.get_mask_selection(new_selection, fields=fields)
13701366
else:
1371-
raise VindexInvalidSelectionError(new_selection)
1367+
msg = (
1368+
"unsupported selection type for vectorized indexing; only "
1369+
"coordinate selection (tuple of integer arrays) and mask selection "
1370+
f"(single Boolean array) are supported; got {new_selection!r}"
1371+
)
1372+
raise VindexInvalidSelectionError(msg)
13721373

13731374

13741375
def check_fields(fields: Fields | None, dtype: np.dtype[Any]) -> np.dtype[Any]:
@@ -1487,7 +1488,12 @@ def get_indexer(
14871488
elif is_mask_selection(new_selection, shape):
14881489
return MaskIndexer(cast("MaskSelection", selection), shape, chunk_grid)
14891490
else:
1490-
raise VindexInvalidSelectionError(new_selection)
1491+
msg = (
1492+
"unsupported selection type for vectorized indexing; only "
1493+
"coordinate selection (tuple of integer arrays) and mask selection "
1494+
f"(single Boolean array) are supported; got {new_selection!r}"
1495+
)
1496+
raise VindexInvalidSelectionError(msg)
14911497
elif is_pure_orthogonal_indexing(pure_selection, len(shape)):
14921498
return OrthogonalIndexer(cast("OrthogonalSelection", selection), shape, chunk_grid)
14931499
else:

src/zarr/core/metadata/v3.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,15 @@
4141
def parse_zarr_format(data: object) -> Literal[3]:
4242
if data == 3:
4343
return 3
44-
raise MetadataValidationError("zarr_format", 3, data)
44+
msg = f"Invalid value for 'zarr_format'. Expected '3'. Got '{data}'."
45+
raise MetadataValidationError(msg)
4546

4647

4748
def parse_node_type_array(data: object) -> Literal["array"]:
4849
if data == "array":
4950
return "array"
50-
raise NodeTypeValidationError("node_type", "array", data)
51+
msg = f"Invalid value for 'node_type'. Expected 'array'. Got '{data}'."
52+
raise NodeTypeValidationError(msg)
5153

5254

5355
def parse_codecs(data: object) -> tuple[Codec, ...]:

src/zarr/errors.py

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
from typing import Any
2-
31
__all__ = [
2+
"ArrayIndexError",
43
"ArrayNotFoundError",
54
"BaseZarrError",
5+
"BoundsCheckError",
66
"ContainsArrayAndGroupError",
77
"ContainsArrayError",
88
"ContainsGroupError",
99
"GroupNotFoundError",
1010
"MetadataValidationError",
11+
"NegativeStepError",
1112
"NodeTypeValidationError",
1213
"UnstableSpecificationWarning",
14+
"VindexInvalidSelectionError",
1315
"ZarrDeprecationWarning",
1416
"ZarrFutureWarning",
1517
"ZarrRuntimeWarning",
@@ -21,55 +23,41 @@ class BaseZarrError(ValueError):
2123
Base error which all zarr errors are sub-classed from.
2224
"""
2325

24-
_msg = ""
26+
_msg: str = "{}"
2527

26-
def __init__(self, *args: Any) -> None:
27-
super().__init__(self._msg.format(*args))
28+
def __init__(self, *args: object) -> None:
29+
"""
30+
If a single argument is passed, treat it as a pre-formatted message.
31+
32+
If multiple arguments are passed, they are used as arguments for a template string class
33+
variable. This behavior is deprecated.
34+
"""
35+
if len(args) == 1:
36+
super().__init__(args[0])
37+
else:
38+
super().__init__(self._msg.format(*args))
2839

2940

3041
class NodeNotFoundError(BaseZarrError, FileNotFoundError):
3142
"""
3243
Raised when a node (array or group) is not found at a certain path.
3344
"""
3445

35-
def __init__(self, *args: Any) -> None:
36-
if len(args) == 1:
37-
# Pre-formatted message
38-
super(BaseZarrError, self).__init__(args[0])
39-
else:
40-
# Store and path arguments - format them
41-
_msg = "No node found in store {!r} at path {!r}"
42-
super(BaseZarrError, self).__init__(_msg.format(*args))
43-
4446

4547
class ArrayNotFoundError(NodeNotFoundError):
4648
"""
4749
Raised when an array isn't found at a certain path.
4850
"""
4951

50-
def __init__(self, *args: Any) -> None:
51-
if len(args) == 1:
52-
# Pre-formatted message
53-
super(BaseZarrError, self).__init__(args[0])
54-
else:
55-
# Store and path arguments - format them
56-
_msg = "No array found in store {!r} at path {!r}"
57-
super(BaseZarrError, self).__init__(_msg.format(*args))
52+
_msg = "No array found in store {!r} at path {!r}"
5853

5954

6055
class GroupNotFoundError(NodeNotFoundError):
6156
"""
6257
Raised when a group isn't found at a certain path.
6358
"""
6459

65-
def __init__(self, *args: Any) -> None:
66-
if len(args) == 1:
67-
# Pre-formatted message
68-
super(BaseZarrError, self).__init__(args[0])
69-
else:
70-
# Store and path arguments - format them
71-
_msg = "No group found in store {!r} at path {!r}"
72-
super(BaseZarrError, self).__init__(_msg.format(*args))
60+
_msg = "No group found in store {!r} at path {!r}"
7361

7462

7563
class ContainsGroupError(BaseZarrError):
@@ -106,8 +94,6 @@ class UnknownCodecError(BaseZarrError):
10694
Raised when a unknown codec was used.
10795
"""
10896

109-
_msg = "{}"
110-
11197

11298
class NodeTypeValidationError(MetadataValidationError):
11399
"""
@@ -146,3 +132,15 @@ class ZarrRuntimeWarning(RuntimeWarning):
146132
"""
147133
A warning for dubious runtime behavior.
148134
"""
135+
136+
137+
class VindexInvalidSelectionError(IndexError): ...
138+
139+
140+
class NegativeStepError(IndexError): ...
141+
142+
143+
class BoundsCheckError(IndexError): ...
144+
145+
146+
class ArrayIndexError(IndexError): ...

src/zarr/storage/_common.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,9 +413,11 @@ async def ensure_no_existing_node(store_path: StorePath, zarr_format: ZarrFormat
413413
extant_node = await _contains_node_v3(store_path)
414414

415415
if extant_node == "array":
416-
raise ContainsArrayError(store_path.store, store_path.path)
416+
msg = f"An array exists in store {store_path.store!r} at path {store_path.path!r}."
417+
raise ContainsArrayError(msg)
417418
elif extant_node == "group":
418-
raise ContainsGroupError(store_path.store, store_path.path)
419+
msg = f"An array exists in store {store_path.store!r} at path {store_path.path!r}."
420+
raise ContainsGroupError(msg)
419421
elif extant_node == "nothing":
420422
return
421423
msg = f"Invalid value for extant_node: {extant_node}" # type: ignore[unreachable]
@@ -476,7 +478,13 @@ async def _contains_node_v2(store_path: StorePath) -> Literal["array", "group",
476478
_group = await contains_group(store_path=store_path, zarr_format=2)
477479

478480
if _array and _group:
479-
raise ContainsArrayAndGroupError(store_path.store, store_path.path)
481+
msg = (
482+
"Array and group metadata documents (.zarray and .zgroup) were both found in store "
483+
f"{store_path.store!r} at path {store_path.path!r}. "
484+
"Only one of these files may be present in a given directory / prefix. "
485+
"Remove the .zarray file, or the .zgroup file, or both."
486+
)
487+
raise ContainsArrayAndGroupError(msg)
480488
elif _array:
481489
return "array"
482490
elif _group:

tests/test_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,13 +1231,13 @@ def test_open_modes_creates_group(tmp_path: Path, mode: str) -> None:
12311231
async def test_metadata_validation_error() -> None:
12321232
with pytest.raises(
12331233
MetadataValidationError,
1234-
match="Invalid value for 'zarr_format'. Expected '2, 3, or None'. Got '3.0'.",
1234+
match="Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '3.0'.",
12351235
):
12361236
await zarr.api.asynchronous.open_group(zarr_format="3.0") # type: ignore[arg-type]
12371237

12381238
with pytest.raises(
12391239
MetadataValidationError,
1240-
match="Invalid value for 'zarr_format'. Expected '2, 3, or None'. Got '3.0'.",
1240+
match="Invalid value for 'zarr_format'. Expected 2, 3, or None. Got '3.0'.",
12411241
):
12421242
await zarr.api.asynchronous.open_array(shape=(1,), zarr_format="3.0") # type: ignore[arg-type]
12431243

0 commit comments

Comments
 (0)