Skip to content
4 changes: 3 additions & 1 deletion docs/release-notes/0.12.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@
- Deprecate `__version__` and use standard {func}`~importlib.metadata.version` API {user}`flying-sheep` ({pr}`1318`)
- Allow writing of views of {class}`dask.array.Array` {user}`ilan-gold` ({pr}`2084`)
- Enable writing of views of {class}`~anndata.AnnData` in backed mode {user}`ilan-gold` ({pr}`2092`)
- Reallow writing of keys in `h5ad` files with forward slashes instead of erroring. Now a warning will be raised that the behavior will be disallowed in the future. To enable the new behavior, use {attr}`anndata.settings.disallow_forward_slash_in_h5ad`. {user}`ilan-gold` ({pr}`2097`)
- Reallow writing of keys in `h5ad` files with forward slashes instead of erroring.
Now a warning will be raised that the behavior will be disallowed in the future.
To enable the new behavior, use {attr}`anndata.settings.disallow_forward_slash_in_h5ad`. {user}`ilan-gold` ({pr}`2097`)
- Respect off-axis merge options in {func}`anndata.experimental.concat_on_disk` {user}`ilan-gold` ({pr}`2122`)
2 changes: 2 additions & 0 deletions docs/release-notes/2436.chore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Flip default for {attr}`anndata.settings.disallow_forward_slash_in_h5ad` to `True`.
Document writing to `k="/"` in {func}`~anndata.io.write_elem` and check `k` more strictly {user}`flying-sheep`
10 changes: 7 additions & 3 deletions src/anndata/_io/h5ad.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from .specs import read_elem, write_elem
from .specs.registry import IOSpec, write_spec
from .utils import (
_check_has_no_slash_key,
_read_legacy_raw,
idx_chunks_along_axis,
no_write_dataset_2d,
Expand Down Expand Up @@ -86,6 +87,8 @@ def write_h5ad(
f.attrs.setdefault("encoding-type", "anndata")
f.attrs.setdefault("encoding-version", "0.1.0")
for k, elem in iter_outer(adata):
_check_has_no_slash_key(k, elem)
Comment thread
flying-sheep marked this conversation as resolved.

if k == "raw":
_write_raw(
f, adata.raw, as_dense=as_dense, dataset_kwargs=dataset_kwargs
Expand Down Expand Up @@ -139,9 +142,10 @@ def _write_raw(
if "raw/X" in as_dense and isinstance(
raw.X, CSMatrix | BaseCompressedSparseDataset
):
write_sparse_as_dense(f, "raw/X", raw.X, dataset_kwargs=dataset_kwargs)
write_elem(f, "raw/var", raw.var, dataset_kwargs=dataset_kwargs)
write_elem(f, "raw/varm", dict(raw.varm), dataset_kwargs=dataset_kwargs)
g = f.require_group("raw")
write_sparse_as_dense(g, "X", raw.X, dataset_kwargs=dataset_kwargs)
write_elem(g, "var", raw.var, dataset_kwargs=dataset_kwargs)
write_elem(g, "varm", dict(raw.varm), dataset_kwargs=dataset_kwargs)
elif raw is not None:
write_elem(f, "raw", raw, dataset_kwargs=dataset_kwargs)

Expand Down
32 changes: 18 additions & 14 deletions src/anndata/_io/specs/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
from anndata._core.index import _normalize_indices
from anndata._core.merge import intersect_keys
from anndata._core.sparse_dataset import _CSCDataset, _CSRDataset, sparse_dataset
from anndata._io.utils import check_key, zero_dim_array_as_scalar
from anndata._io.utils import (
_check_has_no_slash_key,
check_key,
zero_dim_array_as_scalar,
)
from anndata._types import StorageType
from anndata._warnings import OldFormatWarning
from anndata.compat import (
Expand Down Expand Up @@ -345,19 +349,19 @@ def write_anndata(
):
g = f.require_group(k)
for sub_key, elem in iter_outer(adata):
if not (sub_key == "X" and elem is None):
if sub_key == "layers":
if None in elem:
_writer.write_elem(
g, "X", elem[None], dataset_kwargs=dataset_kwargs
)
elem = {k: v for k, v in elem.items() if k is not None}
_writer.write_elem(
g,
sub_key,
dict(elem) if isinstance(elem, MutableMapping) else elem,
dataset_kwargs=dataset_kwargs,
)
if sub_key == "X" and elem is None:
continue
_check_has_no_slash_key(sub_key, elem)
if sub_key == "layers":
if None in elem:
_writer.write_elem(g, "X", elem[None], dataset_kwargs=dataset_kwargs)
Comment thread
flying-sheep marked this conversation as resolved.
elem = {k: v for k, v in elem.items() if k is not None}
_writer.write_elem(
g,
sub_key,
dict(elem) if isinstance(elem, MutableMapping) else elem,
dataset_kwargs=dataset_kwargs,
)


@_REGISTRY.register_read(H5Group, IOSpec("anndata", "0.1.0"))
Expand Down
52 changes: 29 additions & 23 deletions src/anndata/_io/specs/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,42 +364,47 @@ def write_elem(

from anndata._io.zarr import is_group_consolidated

# we allow stores to have a prefix like /uns which are then written to with keys like /uns/foo
is_zarr_group = isinstance(store, ZarrGroup)
if "/" in k.rsplit(store.name, maxsplit=1)[-1][1:]:
if is_zarr_group or settings.disallow_forward_slash_in_h5ad:
msg = f"Forward slashes are not allowed in keys in {type(store)}"
raise ValueError(msg)
else:
msg = "Forward slashes will be disallowed in h5 stores in the next minor release"
warn(msg, FutureWarning)

if isinstance(store, h5py.File):
store = store["/"]

dest_type = type(store)

# Normalize k to absolute path
if isinstance(store, h5py.Group) and not PurePosixPath(k).is_absolute():
k = str(PurePosixPath(store.name) / k)
is_consolidated = is_group_consolidated(store) if is_zarr_group else False
if is_consolidated:
elif is_group_consolidated(store, strict=False):
msg = "Cannot overwrite/edit a store with consolidated metadata"
raise ValueError(msg)

if k == "/":
if store.name != "/":
msg = f"'/' is not in the subpath of {store.name!r}"
raise ValueError(msg)

if isinstance(store, ZarrGroup):
from zarr.core.sync import sync

sync(store.store.clear())
else:
store.clear()
elif k in store:
del store[k]
else:
# we allow stores to have a prefix like /uns which are then written to with keys like /uns/foo
if k.startswith("/"):
k = str(PurePosixPath(k).relative_to(store.name, walk_up=False))

# Apart from this code, we also ban keys containing slashes in `write_adata`/`write_h5ad`
# for AnnData elements other than `obs`, `var`, and `uns`.
if "/" in k:
if (
isinstance(store, ZarrGroup)
or settings.disallow_forward_slash_in_h5ad
):
msg = f"Forward slashes are not allowed in keys in {type(store)}"
raise ValueError(msg)
msg = "Forward slashes will be written differently in a future anndata version"
warn(msg, FutureWarning)

if k in store:
del store[k]

# Normalize array-API (e.g., JAX/CuPy) even if not AnnData
elem = normalize_nested(elem)

write_func = self.find_write_func(dest_type, elem, modifiers)
write_func = self.find_write_func(type(store), elem, modifiers)

if self.callback is None:
return write_func(store, k, elem, dataset_kwargs=dataset_kwargs)
Expand Down Expand Up @@ -522,8 +527,9 @@ def write_elem(
store
The group to write to.
k
The key to write to in the group. Note that absolute paths will be written
from the root.
The key to write into the group.
If the group is the root, set `k` to `"/"` to write directly into it.
Passing an absolute path referring to a direct child of the group is also allowed.
elem
The element to write. Typically an in-memory object, e.g. an AnnData, pandas
dataframe, scipy sparse matrix, etc.
Expand Down
15 changes: 13 additions & 2 deletions src/anndata/_io/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from collections.abc import Callable
from collections.abc import Callable, Mapping
from functools import WRAPPER_ASSIGNMENTS, cache, wraps
from itertools import pairwise
from typing import TYPE_CHECKING, Literal, cast
Expand All @@ -12,7 +12,7 @@
from ..utils import warn

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

from pandas.core.dtypes.dtypes import BaseMaskedDtype
Expand Down Expand Up @@ -257,6 +257,7 @@ def report_write_key_on_error(func):

@wraps(func)
def func_wrapper(*args, **kwargs):
__tracebackhide__ = True
from anndata._io.specs import Writer

# Figure out signature (method vs function) by going through args
Expand All @@ -278,6 +279,16 @@ def func_wrapper(*args, **kwargs):
return func_wrapper


def _check_has_no_slash_key(attr: str, elem: object) -> None:
"""Only attempt to write slash keys where people rely on it for backwards compatibility."""
if attr in {"obs", "var", "uns", "raw"}:
return # separate check for `settings.disallow_forward_slash_in_h5ad` is done in `write_elem`
assert isinstance(elem, Mapping)
if any("/" in k for k in elem if k not in {"/", None}):
Comment thread
flying-sheep marked this conversation as resolved.
msg = f"Forward slashes are not allowed in keys in {attr}"
raise ValueError(msg)


# -------------------------------------------------------------------------------
# Common h5ad/zarr stuff
# -------------------------------------------------------------------------------
Expand Down
10 changes: 7 additions & 3 deletions src/anndata/_io/zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from zarr.core.common import AccessModeLiteral
from zarr.storage import StoreLike

from .._types import _GroupStorageType


@contextmanager
def zarrs_context():
Expand Down Expand Up @@ -175,8 +177,10 @@ def open_write_group(
return zarr.open_group(store, mode=mode, **kwargs)


def is_group_consolidated(group: zarr.Group) -> bool:
def is_group_consolidated(group: _GroupStorageType, *, strict: bool = True) -> bool:
if not isinstance(group, zarr.Group):
msg = f"Expected zarr.Group, got {type(group)}"
raise TypeError(msg)
if strict:
msg = f"Expected zarr.Group, got {type(group)}"
raise TypeError(msg)
return False
return group.metadata.consolidated_metadata is not None
2 changes: 1 addition & 1 deletion src/anndata/_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ def validate_sparse_settings(val: Any, settings: SettingsManager) -> None:

settings.register(
"disallow_forward_slash_in_h5ad",
default_value=False,
default_value=True,
description="Whether or not to disallow the `/` character in keys for h5ad files",
validate=validate_bool,
get_from_env=check_and_get_bool,
Expand Down
2 changes: 1 addition & 1 deletion src/anndata/_settings.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class _AnnDataSettingsManager(SettingsManager):
zarr_write_format: Literal[2, 3] = 2
use_sparse_array_on_read: bool = False
min_rows_for_chunked_h5_copy: int = 1000
disallow_forward_slash_in_h5ad: bool = False
disallow_forward_slash_in_h5ad: bool = True
write_csr_csc_indices_with_min_possible_dtype: bool = False
auto_shard_zarr_v3: bool | None = None

Expand Down
4 changes: 2 additions & 2 deletions src/anndata/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,10 @@ def __call__(
class ReadCallback[S: StorageType, RWAble: typing.RWAble](Protocol):
def __call__(
self,
/,
read_func: Read[S, RWAble],
elem_name: str,
elem: StorageType,
/,
*,
iospec: IOSpec,
) -> RWAble:
Expand Down Expand Up @@ -188,11 +188,11 @@ def __call__(
class WriteCallback[RWAble: typing.RWAble](Protocol):
def __call__(
self,
/,
write_func: Write[RWAble],
store: StorageType,
elem_name: str,
elem: RWAble,
/,
*,
iospec: IOSpec,
dataset_kwargs: Mapping[str, Any],
Expand Down
37 changes: 15 additions & 22 deletions tests/test_concatenate_disk.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from anndata.utils import asarray

if TYPE_CHECKING:
from collections.abc import Collection
from pathlib import Path
from typing import Literal

Expand Down Expand Up @@ -70,31 +71,23 @@ def max_loaded_elems(request) -> int:
return request.param


def _adatas_to_paths(adatas, tmp_path, file_format):
"""
Gets list of adatas, writes them and returns their paths as zarr
"""
paths = None

if isinstance(adatas, Mapping):
paths = {}
for k, v in adatas.items():
p = tmp_path / (f"{k}." + file_format)
with as_group(p, mode="a") as f:
write_elem(f, "", v)
paths[k] = p
else:
paths = []
for i, a in enumerate(adatas):
p = tmp_path / (f"{i}." + file_format)
with as_group(p, mode="a") as f:
write_elem(f, "", a)
paths += [p]
return paths
def _adatas_to_paths(
adatas: Mapping[str, AnnData] | Collection[AnnData],
tmp_path: Path,
file_format: str,
) -> dict[str, Path] | list[Path]:
"""Gets list of adatas, writes them and returns their paths as zarr."""
paths = {}
for k, v in adatas.items() if isinstance(adatas, Mapping) else enumerate(adatas):
p = tmp_path / f"{k}.{file_format}"
with as_group(p, mode="a") as f:
write_elem(f, "/", v)
paths[k] = p
return paths if isinstance(adatas, Mapping) else list(paths.values())


def assert_eq_concat_on_disk(
adatas,
adatas: Mapping[str, AnnData] | Collection[AnnData],
tmp_path: Path,
file_format: Literal["zarr", "h5ad"],
max_loaded_elems: int | None = None,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_dask.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def test_dask_distributed_write(
adata.varm["a"] = da.random.random((N, 10))
orig = adata
with ad.settings.override(auto_shard_zarr_v3=auto_shard_zarr_v3):
ad.io.write_elem(g, "", orig)
ad.io.write_elem(g, "/", orig)
# TODO: See https://github.com/zarr-developers/zarr-python/issues/2716
with as_group(pth, mode="r") as g:
if auto_shard_zarr_v3:
Expand Down
8 changes: 7 additions & 1 deletion tests/test_io_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from __future__ import annotations

from typing import TYPE_CHECKING

import h5py
import numpy as np
import pytest
Expand All @@ -13,6 +15,10 @@
from anndata.compat import CSMatrix
from anndata.tests.helpers import GEN_ADATA_NO_XARRAY_ARGS, assert_equal, gen_adata

if TYPE_CHECKING:
from pathlib import Path
from typing import Literal


@pytest.fixture(
params=[sparse.csr_matrix, sparse.csc_matrix, np.array],
Expand Down Expand Up @@ -64,7 +70,7 @@ def test_sparse_to_dense_disk(tmp_path, mtx_format, to_convert):
assert_equal(disk, from_disk)


def test_sparse_to_dense_inplace(tmp_path, spmtx_format):
def test_sparse_to_dense_inplace(tmp_path: Path, spmtx_format: Literal["csc", "csr"]):
pth = tmp_path / "adata.h5ad"
orig = gen_adata((50, 50), spmtx_format, **GEN_ADATA_NO_XARRAY_ARGS)
orig.raw = orig.copy()
Expand Down
Loading
Loading