Skip to content

Create read only copy if needed when opening a store path #3156

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion changes/3068.bugfix.rst

This file was deleted.

1 change: 1 addition & 0 deletions changes/3156.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Trying to open a StorePath/Array with ``mode='r'`` when the store is not read-only creates a read-only copy of the store.
2 changes: 2 additions & 0 deletions src/zarr/core/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import (
TYPE_CHECKING,
Any,
Final,
Generic,
Literal,
TypedDict,
Expand Down Expand Up @@ -39,6 +40,7 @@
JSON = str | int | float | Mapping[str, "JSON"] | Sequence["JSON"] | None
MemoryOrder = Literal["C", "F"]
AccessModeLiteral = Literal["r", "r+", "a", "w", "w-"]
ANY_ACCESS_MODE: Final = "r", "r+", "a", "w", "w-"
DimensionNames = Iterable[str | None] | None

TName = TypeVar("TName", bound=str)
Expand Down
80 changes: 55 additions & 25 deletions src/zarr/storage/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@

from zarr.abc.store import ByteRequest, Store
from zarr.core.buffer import Buffer, default_buffer_prototype
from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZGROUP_JSON, AccessModeLiteral, ZarrFormat
from zarr.core.common import (
ANY_ACCESS_MODE,
ZARR_JSON,
ZARRAY_JSON,
ZGROUP_JSON,
AccessModeLiteral,
ZarrFormat,
)
from zarr.errors import ContainsArrayAndGroupError, ContainsArrayError, ContainsGroupError
from zarr.storage._local import LocalStore
from zarr.storage._memory import MemoryStore
Expand Down Expand Up @@ -54,59 +61,82 @@
def read_only(self) -> bool:
return self.store.read_only

@classmethod
async def _create_open_instance(cls, store: Store, path: str) -> Self:
"""Helper to create and return a StorePath instance."""
await store._ensure_open()
return cls(store, path)

@classmethod
async def open(cls, store: Store, path: str, mode: AccessModeLiteral | None = None) -> Self:
"""
Open StorePath based on the provided mode.

* If the mode is 'w-' and the StorePath contains keys, raise a FileExistsError.
* If the mode is 'w', delete all keys nested within the StorePath
* If the mode is 'a', 'r', or 'r+', do nothing
* If the mode is None, return an opened version of the store with no changes.
* If the mode is 'r+', 'w-', 'w', or 'a' and the store is read-only, raise a ValueError.
* If the mode is 'r' and the store is not read-only, return a copy of the store with read_only set to True.
* If the mode is 'w-' and the store is not read-only and the StorePath contains keys, raise a FileExistsError.
* If the mode is 'w' and the store is not read-only, delete all keys nested within the StorePath.

Parameters
----------
mode : AccessModeLiteral
The mode to use when initializing the store path.

The accepted values are:

- ``'r'``: read only (must exist)
- ``'r+'``: read/write (must exist)
- ``'a'``: read/write (create if doesn't exist)
- ``'w'``: read/write (overwrite if exists)
- ``'w-'``: read/write (create if doesn't exist).

Raises
------
FileExistsError
If the mode is 'w-' and the store path already exists.
ValueError
If the mode is not "r" and the store is read-only, or
if the mode is "r" and the store is not read-only.
"""

await store._ensure_open()
self = cls(store, path)

# fastpath if mode is None
if mode is None:
return self
return await cls._create_open_instance(store, path)

if store.read_only and mode != "r":
raise ValueError(f"Store is read-only but mode is '{mode}'")
if not store.read_only and mode == "r":
raise ValueError(f"Store is not read-only but mode is '{mode}'")
if mode not in ANY_ACCESS_MODE:
raise ValueError(f"Invalid mode: {mode}, expected one of {ANY_ACCESS_MODE}")

Check warning on line 107 in src/zarr/storage/_common.py

View check run for this annotation

Codecov / codecov/patch

src/zarr/storage/_common.py#L107

Added line #L107 was not covered by tests

if store.read_only:
# Don't allow write operations on a read-only store
if mode != "r":
raise ValueError(

Check warning on line 112 in src/zarr/storage/_common.py

View check run for this annotation

Codecov / codecov/patch

src/zarr/storage/_common.py#L111-L112

Added lines #L111 - L112 were not covered by tests
f"Store is read-only but mode is {mode!r}. Create a writable store or use 'r' mode."
)
self = await cls._create_open_instance(store, path)

Check warning on line 115 in src/zarr/storage/_common.py

View check run for this annotation

Codecov / codecov/patch

src/zarr/storage/_common.py#L115

Added line #L115 was not covered by tests
elif mode == "r":
# Create read-only copy for read mode on writable store
try:
read_only_store = store.with_read_only(True)
except NotImplementedError as e:
raise ValueError(

Check warning on line 121 in src/zarr/storage/_common.py

View check run for this annotation

Codecov / codecov/patch

src/zarr/storage/_common.py#L118-L121

Added lines #L118 - L121 were not covered by tests
"Store is not read-only but mode is 'r'. Unable to create a read-only copy of the store. "
"Please use a read-only store or a storage class that implements .with_read_only()."
) from e
self = await cls._create_open_instance(read_only_store, path)

Check warning on line 125 in src/zarr/storage/_common.py

View check run for this annotation

Codecov / codecov/patch

src/zarr/storage/_common.py#L125

Added line #L125 was not covered by tests
else:
# writable store and writable mode
self = await cls._create_open_instance(store, path)

# Handle mode-specific operations
match mode:
case "w-":
if not await self.is_empty():
msg = (
f"{self} is not empty, but `mode` is set to 'w-'."
"Either remove the existing objects in storage,"
"or set `mode` to a value that handles pre-existing objects"
"in storage, like `a` or `w`."
raise FileExistsError(

Check warning on line 134 in src/zarr/storage/_common.py

View check run for this annotation

Codecov / codecov/patch

src/zarr/storage/_common.py#L134

Added line #L134 was not covered by tests
f"Cannot create '{path}' with mode 'w-' because it already contains data. "
f"Use mode 'w' to overwrite or 'a' to append."
)
raise FileExistsError(msg)
case "w":
await self.delete_dir()
case "a" | "r" | "r+":
# No init action
pass
case _:
raise ValueError(f"Invalid mode: {mode}")

return self

async def get(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1318,7 +1318,7 @@ def test_no_overwrite_open(tmp_path: Path, open_func: Callable, mode: str) -> No
existing_fpath = add_empty_file(tmp_path)

assert existing_fpath.exists()
with contextlib.suppress(FileExistsError, FileNotFoundError, ValueError):
with contextlib.suppress(FileExistsError, FileNotFoundError, UserWarning):
open_func(store=store, mode=mode)
if mode == "w":
assert not existing_fpath.exists()
Expand Down
25 changes: 19 additions & 6 deletions tests/test_common.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from collections.abc import Iterable
from typing import Any, Literal
from typing import TYPE_CHECKING, get_args

import numpy as np
import pytest

from zarr.core.common import parse_name, parse_shapelike, product
from zarr.core.common import (
ANY_ACCESS_MODE,
AccessModeLiteral,
parse_name,
parse_shapelike,
product,
)
from zarr.core.config import parse_indexing_order

if TYPE_CHECKING:
from collections.abc import Iterable
from typing import Any, Literal


@pytest.mark.parametrize("data", [(0, 0, 0, 0), (1, 3, 4, 5, 6), (2, 4)])
def test_product(data: tuple[int, ...]) -> None:
assert product(data) == np.prod(data)


def test_access_modes() -> None:
"""
Test that the access modes type and variable for run-time checking are equivalent.
"""
assert set(ANY_ACCESS_MODE) == set(get_args(AccessModeLiteral))


# todo: test
def test_concurrent_map() -> None: ...

Expand Down
17 changes: 14 additions & 3 deletions tests/test_store/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import zarr
from zarr import Group
from zarr.core.common import AccessModeLiteral, ZarrFormat
from zarr.storage import FsspecStore, LocalStore, MemoryStore, StoreLike, StorePath
from zarr.storage import FsspecStore, LocalStore, MemoryStore, StoreLike, StorePath, ZipStore
from zarr.storage._common import contains_array, contains_group, make_store_path
from zarr.storage._utils import (
_join_paths,
Expand Down Expand Up @@ -263,8 +263,19 @@ def test_relativize_path_invalid() -> None:
_relativize_path(path="a/b/c", prefix="b")


def test_invalid_open_mode() -> None:
def test_different_open_mode(tmp_path: LEGACY_PATH) -> None:
# Test with a store that implements .with_read_only()
store = MemoryStore()
zarr.create((100,), store=store, zarr_format=2, path="a")
with pytest.raises(ValueError, match="Store is not read-only but mode is 'r'"):
arr = zarr.open_array(store=store, path="a", zarr_format=2, mode="r")
assert arr.store.read_only

# Test with a store that doesn't implement .with_read_only()
zarr_path = tmp_path / "foo.zarr"
store = ZipStore(zarr_path, mode="w")
zarr.create((100,), store=store, zarr_format=2, path="a")
with pytest.raises(
ValueError,
match="Store is not read-only but mode is 'r'. Unable to create a read-only copy of the store. Please use a read-only store or a storage class that implements .with_read_only().",
):
zarr.open_array(store=store, path="a", zarr_format=2, mode="r")