Skip to content

Commit 4d37f7f

Browse files
committed
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
1 parent 156cc04 commit 4d37f7f

5 files changed

Lines changed: 234 additions & 73 deletions

File tree

‎src/zarr/api/asynchronous.py‎

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ def _infer_overwrite(mode: AccessModeLiteral) -> bool:
108108
return mode in _OVERWRITE_MODES
109109

110110

111+
def _deferred_overwrite_mode(mode: AccessModeLiteral) -> AccessModeLiteral:
112+
"""
113+
The mode for building the store path of a node that `mode` may overwrite.
114+
115+
Building a store path with mode `"w"` deletes everything under the path before the new
116+
node has been validated, so an invalid argument would destroy the existing node and create
117+
nothing. Callers build the store path with mode `"a"` instead and create the node with
118+
`overwrite=True`, which deletes the existing node only once the new metadata is valid.
119+
"""
120+
return "a" if mode == "w" else mode
121+
122+
111123
def _warn_unimplemented_kwargs(kwargs: dict[str, Any]) -> None:
112124
"""
113125
Emit a "not yet implemented" warning for each provided keyword argument that is not None.
@@ -388,23 +400,32 @@ async def open(
388400
mode = "r"
389401
else:
390402
mode = "a"
391-
store_path = await make_store_path(store, mode=mode, path=path, storage_options=storage_options)
403+
store_path = await make_store_path(
404+
store,
405+
mode=_deferred_overwrite_mode(mode),
406+
path=path,
407+
storage_options=storage_options,
408+
)
392409

393410
# TODO: the mode check below seems wrong!
394411
if "shape" not in kwargs and mode in {"a", "r", "r+", "w"}:
395-
try:
396-
metadata_dict = await get_array_metadata(store_path, zarr_format=zarr_format)
397-
# TODO: remove this cast when we fix typing for array metadata dicts
398-
_metadata_dict = cast("ArrayMetadataDict", metadata_dict)
399-
# for v2, the above would already have raised an exception if not an array
400-
zarr_format = _metadata_dict["zarr_format"]
401-
is_v3_array = zarr_format == 3 and _metadata_dict.get("node_type") == "array"
402-
if is_v3_array or zarr_format == 2:
403-
return AsyncArray(
404-
store_path=store_path, metadata=_metadata_dict, config=kwargs.get("config")
405-
)
406-
except (FileNotFoundError, NodeTypeValidationError):
407-
pass
412+
# mode "w" replaces any existing node, so there is nothing to open
413+
if mode != "w":
414+
try:
415+
metadata_dict = await get_array_metadata(store_path, zarr_format=zarr_format)
416+
# TODO: remove this cast when we fix typing for array metadata dicts
417+
_metadata_dict = cast("ArrayMetadataDict", metadata_dict)
418+
# for v2, the above would already have raised an exception if not an array
419+
zarr_format = _metadata_dict["zarr_format"]
420+
is_v3_array = zarr_format == 3 and _metadata_dict.get("node_type") == "array"
421+
if is_v3_array or zarr_format == 2:
422+
return AsyncArray(
423+
store_path=store_path,
424+
metadata=_metadata_dict,
425+
config=kwargs.get("config"),
426+
)
427+
except (FileNotFoundError, NodeTypeValidationError):
428+
pass
408429
return await open_group(store=store_path, zarr_format=zarr_format, mode=mode, **kwargs)
409430

410431
try:
@@ -500,7 +521,9 @@ async def save_array(
500521
raise TypeError("arr argument must be numpy or other NDArrayLike array")
501522

502523
mode = kwargs.pop("mode", "a")
503-
store_path = await make_store_path(store, path=path, mode=mode, storage_options=storage_options)
524+
store_path = await make_store_path(
525+
store, path=path, mode=_deferred_overwrite_mode(mode), storage_options=storage_options
526+
)
504527
if np.isscalar(arr):
505528
arr = np.array(arr)
506529
shape = arr.shape
@@ -549,8 +572,6 @@ async def save_group(
549572
NumPy arrays with data to save.
550573
"""
551574

552-
store_path = await make_store_path(store, path=path, mode="w", storage_options=storage_options)
553-
554575
if zarr_format is None:
555576
zarr_format = _default_zarr_format()
556577

@@ -565,6 +586,9 @@ async def save_group(
565586

566587
if len(args) == 0 and len(kwargs) == 0:
567588
raise ValueError("at least one array must be provided")
589+
590+
# mode "w" deletes everything under the path, so the arguments are checked first
591+
store_path = await make_store_path(store, path=path, mode="w", storage_options=storage_options)
568592
aws = []
569593
for i, arr in enumerate(args):
570594
aws.append(
@@ -856,7 +880,9 @@ async def open_group(
856880
}
857881
)
858882

859-
store_path = await make_store_path(store, mode=mode, storage_options=storage_options, path=path)
883+
store_path = await make_store_path(
884+
store, mode=_deferred_overwrite_mode(mode), storage_options=storage_options, path=path
885+
)
860886
if attributes is None:
861887
attributes = {}
862888

@@ -1062,7 +1088,10 @@ async def create(
10621088
mode = kwargs.pop("mode", None)
10631089
if mode is None:
10641090
mode = "a"
1065-
store_path = await make_store_path(store, path=path, mode=mode, storage_options=storage_options)
1091+
overwrite = overwrite or _infer_overwrite(mode)
1092+
store_path = await make_store_path(
1093+
store, path=path, mode=_deferred_overwrite_mode(mode), storage_options=storage_options
1094+
)
10661095

10671096
config_parsed = parse_array_config(config)
10681097

@@ -1261,25 +1290,30 @@ async def open_array(
12611290
"""
12621291

12631292
mode = kwargs.pop("mode", None)
1264-
store_path = await make_store_path(store, path=path, mode=mode, storage_options=storage_options)
1293+
store_path = await make_store_path(
1294+
store,
1295+
path=path,
1296+
mode=None if mode is None else _deferred_overwrite_mode(mode),
1297+
storage_options=storage_options,
1298+
)
12651299

12661300
if "write_empty_chunks" in kwargs:
12671301
_warn_write_empty_chunks_kwarg()
12681302

1269-
try:
1270-
return await AsyncArray.open(store_path, zarr_format=zarr_format)
1271-
except FileNotFoundError as err:
1272-
if not store_path.read_only and mode in _CREATE_MODES:
1273-
overwrite = _infer_overwrite(mode)
1274-
_zarr_format = zarr_format or _default_zarr_format()
1275-
return await create(
1276-
store=store_path,
1277-
zarr_format=_zarr_format,
1278-
overwrite=overwrite,
1279-
**kwargs,
1280-
)
1281-
msg = f"No array found in store {store_path.store} at path {store_path.path}"
1282-
raise ArrayNotFoundError(msg) from err
1303+
# mode "w" replaces any existing array, so there is nothing to open
1304+
if mode != "w":
1305+
try:
1306+
return await AsyncArray.open(store_path, zarr_format=zarr_format)
1307+
except FileNotFoundError as err:
1308+
if store_path.read_only or mode not in _CREATE_MODES:
1309+
msg = f"No array found in store {store_path.store} at path {store_path.path}"
1310+
raise ArrayNotFoundError(msg) from err
1311+
return await create(
1312+
store=store_path,
1313+
zarr_format=zarr_format or _default_zarr_format(),
1314+
overwrite=_infer_overwrite(mode),
1315+
**kwargs,
1316+
)
12831317

12841318

12851319
async def open_like(a: ArrayLike, path: str, **kwargs: Any) -> AnyAsyncArray:

‎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
@@ -48,7 +48,7 @@
4848
from zarr.core.dtype import parse_data_type
4949
from zarr.core.json_parse import parse_field
5050
from zarr.core.metadata import ArrayV2Metadata, ArrayV3Metadata
51-
from zarr.core.metadata.io import save_metadata
51+
from zarr.core.metadata.io import save_metadata, save_new_metadata
5252
from zarr.core.sync import SyncMixin, sync
5353
from zarr.errors import (
5454
ArrayNotFoundError,
@@ -60,7 +60,7 @@
6060
ZarrUserWarning,
6161
)
6262
from zarr.storage import StoreLike, StorePath
63-
from zarr.storage._common import ensure_no_existing_node, make_store_path
63+
from zarr.storage._common import make_store_path
6464
from zarr.storage._utils import _join_paths, _normalize_path_keys, normalize_path
6565

6666
if TYPE_CHECKING:
@@ -477,20 +477,14 @@ async def from_store(
477477
zarr_format: ZarrFormat = 3,
478478
) -> AsyncGroup:
479479
store_path = await make_store_path(store)
480-
481-
if overwrite:
482-
if store_path.store.supports_deletes:
483-
await store_path.delete_dir()
484-
else:
485-
await ensure_no_existing_node(store_path, zarr_format=zarr_format)
486-
else:
487-
await ensure_no_existing_node(store_path, zarr_format=zarr_format)
488480
attributes = attributes or {}
489481
group = cls(
490482
metadata=GroupMetadata(attributes=attributes, zarr_format=zarr_format),
491483
store_path=store_path,
492484
)
493-
await group._save_metadata(ensure_parents=True)
485+
await save_new_metadata(
486+
store_path, group.metadata, overwrite=overwrite, ensure_parents=True
487+
)
494488
return group
495489

496490
@classmethod

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

Lines changed: 44 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,53 @@ 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 any existing node at `store_path`.
79+
Otherwise, raise if a node already exists at `store_path`.
80+
ensure_parents : bool, optional
81+
Create any missing parent groups, and check no existing parents are arrays.
82+
"""
83+
to_save = metadata.to_buffer_dict(default_buffer_prototype())
84+
if overwrite and store_path.store.supports_deletes:
85+
await store_path.delete_dir()
86+
else:
87+
await ensure_no_existing_node(store_path, zarr_format=metadata.zarr_format)
88+
await _write_metadata(store_path, to_save, metadata.zarr_format, ensure_parents=ensure_parents)
89+
90+
91+
async def _write_metadata(
92+
store_path: StorePath,
93+
to_save: dict[str, Buffer],
94+
zarr_format: ZarrFormat,
95+
*,
96+
ensure_parents: bool,
97+
) -> None:
5598
set_awaitables = [set_or_delete(store_path / key, value) for key, value in to_save.items()]
5699

57100
if ensure_parents:
58101
# 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)
102+
parents = _build_parents(store_path, zarr_format)
60103
ensure_array_awaitables = []
61104

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

0 commit comments

Comments
 (0)