Skip to content

Commit d8c07e1

Browse files
d-v-bclaude
andauthored
fix(api): validate a new node before deleting the existing one (#4410)
* fix: validate a new node before overwriting the existing one Opening or creating a node with mode="w" or overwrite=True deleted the existing node before the new one was validated, so an invalid argument destroyed the existing data and created nothing. For example, zarr.open(store, mode="w", config=...) on an existing array erased the store and then raised TypeError from open_group. Two deletion points ran too early: - make_store_path(mode="w") erased the path before dispatch in open, open_array, open_group, create and save_array. These now build the store path with mode "a" and create the node with overwrite=True. - overwrite=True deleted the node before its metadata was built in AsyncArray._create_v2/_create_v3, init_array and AsyncGroup.from_store. save_new_metadata now encodes the metadata first, then deletes, then writes. save_group checks its arguments before erasing the path. Assisted-by: ClaudeCode:claude-opus-5-5 * docs(changes): add fragment for #4410 Assisted-by: ClaudeCode:claude-opus-5-5 * fix(storage): let node creation own the mode "w" erase `StorePath.open(mode="w")` no longer deletes the keys under the path. The node created there replaces them through `save_new_metadata(overwrite=True)`, once its metadata is valid, so the API functions pass their mode through unchanged and `_deferred_overwrite_mode` is gone. A read-only store refused with mode "w" names that mode again in its error. `save_group` resolves every array's data type before the group is replaced, and replaces it explicitly with `AsyncGroup.from_store(overwrite=True)`. The docstrings of `open`, `open_group` and `create` state that a store which cannot delete keys raises instead of replacing an existing node; the tests pin that contract over `ZipStore`, seed the overwrite tests with a group that has children, and cover the read-only error and the `save_group` dtype path. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 090e1b4 commit d8c07e1

9 files changed

Lines changed: 329 additions & 87 deletions

File tree

‎changes/4410.bugfix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Opening or creating an array or group with `mode="w"` or `overwrite=True` no longer deletes the existing node when the call fails. Previously the existing node was deleted before the new one was validated, so an invalid argument (for example `zarr.open(store, mode="w", config=...)`, an invalid `fill_value`, attributes that are not JSON, or an array `zarr.save_group` cannot store) destroyed the existing data and created nothing. Deletion now belongs to node creation: `StorePath.open(mode="w")` no longer erases the keys under the path, and the node written there replaces them once its metadata is valid. On a store that cannot delete keys, such as `ZipStore`, `mode="w"` creates the node when the path is empty and raises `ContainsArrayError` or `ContainsGroupError` when a node already exists, instead of `NotImplementedError`.

‎src/zarr/api/asynchronous.py‎

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,8 @@ async def open(
366366
Persistence mode: 'r' means read only (must exist); 'r+' means
367367
read/write (must exist); 'a' means read/write (create if doesn't
368368
exist); 'w' means create (overwrite if exists); 'w-' means create
369-
(fail if exists).
369+
(fail if exists). On a store that cannot delete keys, 'w' raises an
370+
error instead of replacing an existing node.
370371
If the store is read-only, the default is 'r'; otherwise, it is 'a'.
371372
zarr_format : {2, 3, None}, optional
372373
The zarr format to use when saving.
@@ -404,20 +405,24 @@ async def open(
404405
store_path = await make_store_path(store, mode=mode, path=path, storage_options=storage_options)
405406

406407
# TODO: the mode check below seems wrong!
407-
if "shape" not in kwargs and mode in {"a", "r", "r+", "w"}:
408-
try:
409-
metadata_dict = await get_array_metadata(store_path, zarr_format=zarr_format)
410-
# TODO: remove this cast when we fix typing for array metadata dicts
411-
_metadata_dict = cast("ArrayMetadataDict", metadata_dict)
412-
# for v2, the above would already have raised an exception if not an array
413-
zarr_format = _metadata_dict["zarr_format"]
414-
is_v3_array = zarr_format == 3 and _metadata_dict.get("node_type") == "array"
415-
if is_v3_array or zarr_format == 2:
416-
return AsyncArray(
417-
store_path=store_path, metadata=_metadata_dict, config=kwargs.get("config")
418-
)
419-
except (FileNotFoundError, NodeTypeValidationError):
420-
pass
408+
if "shape" not in kwargs and mode in (*_READ_MODES, "w"):
409+
# mode "w" replaces any existing node, so there is nothing to open
410+
if mode in _READ_MODES:
411+
try:
412+
metadata_dict = await get_array_metadata(store_path, zarr_format=zarr_format)
413+
# TODO: remove this cast when we fix typing for array metadata dicts
414+
_metadata_dict = cast("ArrayMetadataDict", metadata_dict)
415+
# for v2, the above would already have raised an exception if not an array
416+
zarr_format = _metadata_dict["zarr_format"]
417+
is_v3_array = zarr_format == 3 and _metadata_dict.get("node_type") == "array"
418+
if is_v3_array or zarr_format == 2:
419+
return AsyncArray(
420+
store_path=store_path,
421+
metadata=_metadata_dict,
422+
config=kwargs.get("config"),
423+
)
424+
except (FileNotFoundError, NodeTypeValidationError):
425+
pass
421426
return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs)
422427

423428
try:
@@ -575,8 +580,6 @@ async def save_group(
575580
NumPy arrays with data to save.
576581
"""
577582

578-
store_path = await make_store_path(store, path=path, mode="w", storage_options=storage_options)
579-
580583
if zarr_format is None:
581584
zarr_format = _default_zarr_format()
582585

@@ -591,6 +594,16 @@ async def save_group(
591594

592595
if len(args) == 0 and len(kwargs) == 0:
593596
raise ValueError("at least one array must be provided")
597+
598+
# Resolve every data type before anything is deleted, so an array that Zarr cannot
599+
# store raises while the existing node is still intact.
600+
for arr in (*args, *kwargs.values()):
601+
get_data_type_from_native_dtype(arr.dtype)
602+
603+
store_path = await make_store_path(store, path=path, mode="w", storage_options=storage_options)
604+
# The group replaces whatever is stored under the path, now that the arguments are known
605+
# to be valid.
606+
await AsyncGroup.from_store(store_path, zarr_format=zarr_format, overwrite=True)
594607
aws = []
595608
# `store_path` already consumed `storage_options`, so passing them on again would
596609
# make `make_store_path` reject them as unused.
@@ -823,7 +836,8 @@ async def open_group(
823836
Persistence mode: 'r' means read only (must exist); 'r+' means
824837
read/write (must exist); 'a' means read/write (create if doesn't
825838
exist); 'w' means create (overwrite if exists); 'w-' means create
826-
(fail if exists).
839+
(fail if exists). On a store that cannot delete keys, 'w' raises an
840+
error instead of replacing an existing node.
827841
cache_attrs : bool, optional
828842
If True (default), user attributes will be cached for attribute read
829843
operations. If False, user attributes are reloaded from the store prior
@@ -1070,7 +1084,8 @@ async def create(
10701084
`True`.
10711085
- `'r'` always fails.
10721086
1073-
`mode` has no effect, and is not validated, if `store` is a `StorePath`.
1087+
If `store` is a `StorePath`, `mode` is not validated against it: `'w'` still
1088+
sets `overwrite`, and the other modes have no effect.
10741089
data : array-like, optional
10751090
Values written into the new array after it is created. Unlike the `data`
10761091
parameter of `create_array`, it does not set the shape or data type of the
@@ -1102,6 +1117,7 @@ async def create(
11021117

11031118
if mode is None:
11041119
mode = "a"
1120+
overwrite = overwrite or _infer_overwrite(mode)
11051121
store_path = await make_store_path(store, path=path, mode=mode, storage_options=storage_options)
11061122

11071123
config_parsed = parse_array_config(config)
@@ -1321,20 +1337,20 @@ async def open_array(
13211337
if "write_empty_chunks" in kwargs:
13221338
_warn_write_empty_chunks_kwarg()
13231339

1324-
try:
1325-
return await AsyncArray.open(store_path, zarr_format=zarr_format)
1326-
except FileNotFoundError as err:
1327-
if not store_path.read_only and mode in _CREATE_MODES:
1328-
overwrite = _infer_overwrite(mode)
1329-
_zarr_format = zarr_format or _default_zarr_format()
1330-
return await create(
1331-
store=store_path,
1332-
zarr_format=_zarr_format,
1333-
overwrite=overwrite,
1334-
**kwargs,
1335-
)
1336-
msg = f"No array found in store {store_path.store} at path {store_path.path}"
1337-
raise ArrayNotFoundError(msg) from err
1340+
# mode "w" replaces any existing array, so there is nothing to open
1341+
if mode != "w":
1342+
try:
1343+
return await AsyncArray.open(store_path, zarr_format=zarr_format)
1344+
except FileNotFoundError as err:
1345+
if store_path.read_only or mode not in _CREATE_MODES:
1346+
msg = f"No array found in store {store_path.store} at path {store_path.path}"
1347+
raise ArrayNotFoundError(msg) from err
1348+
return await create(
1349+
store=store_path,
1350+
zarr_format=zarr_format or _default_zarr_format(),
1351+
overwrite=_infer_overwrite(mode),
1352+
**kwargs,
1353+
)
13381354

13391355

13401356
async def open_like(

‎src/zarr/api/synchronous.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@ def open(
200200
Persistence mode: 'r' means read only (must exist); 'r+' means
201201
read/write (must exist); 'a' means read/write (create if doesn't
202202
exist); 'w' means create (overwrite if exists); 'w-' means create
203-
(fail if exists).
203+
(fail if exists). On a store that cannot delete keys, 'w' raises an
204+
error instead of replacing an existing node.
204205
If the store is read-only, the default is 'r'; otherwise, it is 'a'.
205206
zarr_format : {2, 3, None}, optional
206207
The zarr format to use when saving.
@@ -518,7 +519,8 @@ def open_group(
518519
Persistence mode: 'r' means read only (must exist); 'r+' means
519520
read/write (must exist); 'a' means read/write (create if doesn't
520521
exist); 'w' means create (overwrite if exists); 'w-' means create
521-
(fail if exists).
522+
(fail if exists). On a store that cannot delete keys, 'w' raises an
523+
error instead of replacing an existing node.
522524
cache_attrs : bool, optional
523525
If True (default), user attributes will be cached for attribute read
524526
operations. If False, user attributes are reloaded from the store prior
@@ -800,7 +802,8 @@ def create(
800802
`True`.
801803
- `'r'` always fails.
802804
803-
`mode` has no effect, and is not validated, if `store` is a `StorePath`.
805+
If `store` is a `StorePath`, `mode` is not validated against it: `'w'` still
806+
sets `overwrite`, and the other modes have no effect.
804807
data : array-like, optional
805808
Values written into the new array after it is created. Unlike the `data`
806809
parameter of `create_array`, it does not set the shape or data type of the

‎src/zarr/core/array.py‎

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@
118118
ArrayV2MetadataDict,
119119
ArrayV3Metadata,
120120
)
121-
from zarr.core.metadata.io import save_metadata
121+
from zarr.core.metadata.io import save_metadata, save_new_metadata
122122
from zarr.core.metadata.v2 import (
123123
CompressorLikev2,
124124
get_object_codec_id,
@@ -146,7 +146,7 @@
146146
_parse_bytes_bytes_codec,
147147
get_pipeline_class,
148148
)
149-
from zarr.storage._common import StorePath, ensure_no_existing_node, make_store_path
149+
from zarr.storage._common import StorePath, make_store_path
150150
from zarr.storage._utils import _relativize_path
151151

152152
if TYPE_CHECKING:
@@ -334,22 +334,6 @@ def _array_metadata_dict_v3(zarr_json_bytes: Buffer) -> dict[str, JSON]:
334334
return metadata_dict
335335

336336

337-
async def _prepare_overwrite(
338-
store_path: StorePath, *, zarr_format: ZarrFormat, overwrite: bool
339-
) -> None:
340-
"""
341-
Prepare a store path for writing a new node.
342-
343-
If `overwrite` is true and the store supports deletes, any existing node at
344-
`store_path` is deleted. Otherwise, the absence of an existing node is enforced
345-
(raising if one is present).
346-
"""
347-
if overwrite and store_path.store.supports_deletes:
348-
await store_path.delete_dir()
349-
else:
350-
await ensure_no_existing_node(store_path, zarr_format=zarr_format)
351-
352-
353337
@dataclass(frozen=True)
354338
class AsyncArray[T_ArrayMetadata: (ArrayV2Metadata, ArrayV3Metadata)]:
355339
"""
@@ -629,8 +613,6 @@ async def _create_v3(
629613
attributes: dict[str, JSON] | None = None,
630614
overwrite: bool = False,
631615
) -> AsyncArrayV3:
632-
await _prepare_overwrite(store_path, zarr_format=3, overwrite=overwrite)
633-
634616
if isinstance(chunk_key_encoding, tuple):
635617
chunk_key_encoding = (
636618
V2ChunkKeyEncoding(separator=chunk_key_encoding[1])
@@ -650,7 +632,7 @@ async def _create_v3(
650632
)
651633

652634
array = cls(metadata=metadata, store_path=store_path, config=config)
653-
await array._save_metadata(metadata, ensure_parents=True)
635+
await save_new_metadata(store_path, metadata, overwrite=overwrite, ensure_parents=True)
654636
return array
655637

656638
@staticmethod
@@ -704,8 +686,6 @@ async def _create_v2(
704686
attributes: dict[str, JSON] | None = None,
705687
overwrite: bool = False,
706688
) -> AsyncArrayV2:
707-
await _prepare_overwrite(store_path, zarr_format=2, overwrite=overwrite)
708-
709689
compressor_parsed: CompressorLikev2
710690
if compressor == "auto":
711691
compressor_parsed = default_compressor_v2(dtype)
@@ -733,7 +713,7 @@ async def _create_v2(
733713
)
734714

735715
array = cls(metadata=metadata, store_path=store_path, config=config)
736-
await array._save_metadata(metadata, ensure_parents=True)
716+
await save_new_metadata(store_path, metadata, overwrite=overwrite, ensure_parents=True)
737717
return array
738718

739719
@classmethod
@@ -4530,8 +4510,6 @@ async def init_array(
45304510
chunk_key_encoding, zarr_format=zarr_format
45314511
)
45324512

4533-
await _prepare_overwrite(store_path, zarr_format=zarr_format, overwrite=overwrite)
4534-
45354513
# Validate rectilinear chunks constraints
45364514
if _is_rectilinear_chunks(chunks):
45374515
if zarr_format == 2:
@@ -4645,7 +4623,7 @@ async def init_array(
46454623
)
46464624

46474625
arr = AsyncArray(metadata=meta, store_path=store_path, config=config)
4648-
await arr._save_metadata(meta, ensure_parents=True)
4626+
await save_new_metadata(store_path, meta, overwrite=overwrite, ensure_parents=True)
46494627
return arr
46504628

46514629

‎src/zarr/core/group.py‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from zarr.core.dtype import parse_data_type
5050
from zarr.core.json_parse import parse_field
5151
from zarr.core.metadata import ArrayV2Metadata, ArrayV3Metadata
52-
from zarr.core.metadata.io import save_metadata
52+
from zarr.core.metadata.io import save_metadata, save_new_metadata
5353
from zarr.core.metadata.v3 import AllowedExtraField, parse_extra_fields
5454
from zarr.core.sync import SyncMixin, sync
5555
from zarr.errors import (
@@ -62,7 +62,7 @@
6262
ZarrUserWarning,
6363
)
6464
from zarr.storage import StoreLike, StorePath
65-
from zarr.storage._common import ensure_no_existing_node, make_store_path
65+
from zarr.storage._common import make_store_path
6666
from zarr.storage._utils import _join_paths, _normalize_path_keys, normalize_path
6767

6868
if TYPE_CHECKING:
@@ -506,20 +506,14 @@ async def from_store(
506506
zarr_format: ZarrFormat = 3,
507507
) -> AsyncGroup:
508508
store_path = await make_store_path(store)
509-
510-
if overwrite:
511-
if store_path.store.supports_deletes:
512-
await store_path.delete_dir()
513-
else:
514-
await ensure_no_existing_node(store_path, zarr_format=zarr_format)
515-
else:
516-
await ensure_no_existing_node(store_path, zarr_format=zarr_format)
517509
attributes = attributes or {}
518510
group = cls(
519511
metadata=GroupMetadata(attributes=attributes, zarr_format=zarr_format),
520512
store_path=store_path,
521513
)
522-
await group._save_metadata(ensure_parents=True)
514+
await save_new_metadata(
515+
store_path, group.metadata, overwrite=overwrite, ensure_parents=True
516+
)
523517
return group
524518

525519
@classmethod

‎src/zarr/core/metadata/io.py‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from zarr.storage._common import StorePath, ensure_no_existing_node
1010

1111
if TYPE_CHECKING:
12+
from zarr.core.buffer import Buffer
1213
from zarr.core.common import ZarrFormat
1314
from zarr.core.group import GroupMetadata
1415
from zarr.core.metadata import ArrayMetadata
@@ -52,11 +53,54 @@ async def save_metadata(
5253
ValueError
5354
"""
5455
to_save = metadata.to_buffer_dict(default_buffer_prototype())
56+
await _write_metadata(store_path, to_save, metadata.zarr_format, ensure_parents=ensure_parents)
57+
58+
59+
async def save_new_metadata(
60+
store_path: StorePath,
61+
metadata: ArrayMetadata | GroupMetadata,
62+
*,
63+
overwrite: bool,
64+
ensure_parents: bool = False,
65+
) -> None:
66+
"""Save the metadata of a new array or group, replacing any existing node if requested.
67+
68+
The metadata is encoded before the store is modified, so metadata that cannot be
69+
encoded raises without deleting an existing node.
70+
71+
Parameters
72+
----------
73+
store_path : StorePath
74+
Location of the new node.
75+
metadata : ArrayMetadata | GroupMetadata
76+
Metadata of the new node.
77+
overwrite : bool
78+
If true and the store supports deletes, delete everything under `store_path`.
79+
Otherwise, raise if a node already exists at `store_path`; a store that cannot
80+
delete keys cannot overwrite a node, so it raises the same error either way.
81+
ensure_parents : bool, optional
82+
Create any missing parent groups, and check no existing parents are arrays.
83+
"""
84+
to_save = metadata.to_buffer_dict(default_buffer_prototype())
85+
if overwrite and store_path.store.supports_deletes:
86+
await store_path.delete_dir()
87+
else:
88+
await ensure_no_existing_node(store_path, zarr_format=metadata.zarr_format)
89+
await _write_metadata(store_path, to_save, metadata.zarr_format, ensure_parents=ensure_parents)
90+
91+
92+
async def _write_metadata(
93+
store_path: StorePath,
94+
to_save: dict[str, Buffer],
95+
zarr_format: ZarrFormat,
96+
*,
97+
ensure_parents: bool,
98+
) -> None:
5599
set_awaitables = [set_or_delete(store_path / key, value) for key, value in to_save.items()]
56100

57101
if ensure_parents:
58102
# To enable zarr.create(store, path="a/b/c"), we need to create all the intermediate groups.
59-
parents = _build_parents(store_path, metadata.zarr_format)
103+
parents = _build_parents(store_path, zarr_format)
60104
ensure_array_awaitables = []
61105

62106
for parent_path, parent_metadata in parents.items():

0 commit comments

Comments
 (0)