Skip to content

Commit 315c06f

Browse files
authored
Merge pull request #5 from cellgeni/root_attributes
Root attributes
2 parents 939aec8 + 7ba61c9 commit 315c06f

5 files changed

Lines changed: 143 additions & 9 deletions

File tree

‎src/h5ad/core/subset.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ def _group_get(parent: Any, key: str) -> Any | None:
4444
return parent[key] if key in parent else None
4545

4646

47+
def _ensure_optional_anndata_groups(dst: Any) -> None:
48+
for key in ("layers", "obsm", "obsp", "varm", "varp"):
49+
_ensure_group(dst, key)
50+
51+
4752
def _decode_attr(value: Any) -> Any:
4853
if isinstance(value, bytes):
4954
return value.decode("utf-8")
@@ -517,6 +522,8 @@ def subset_h5ad(
517522
total=1,
518523
)
519524

525+
_ensure_optional_anndata_groups(dst)
526+
520527
if inplace:
521528
if file.exists():
522529
if file.is_dir():

‎src/h5ad/storage/__init__.py‎

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55
from typing import Any, Iterable, Optional, Sequence
66
import shutil
7+
import warnings
78

89
import h5py
910

@@ -15,6 +16,10 @@
1516
import numpy as np
1617

1718

19+
ROOT_ENCODING_TYPE = "anndata"
20+
ROOT_ENCODING_VERSION = "0.1.0"
21+
22+
1823
@dataclass
1924
class Store:
2025
backend: str
@@ -96,19 +101,63 @@ def open_store(path: Path, mode: str) -> Store:
96101
if backend == "zarr":
97102
_require_zarr()
98103
root = zarr.open_group(str(path), mode=mode)
104+
if _is_writable_mode(mode):
105+
ensure_anndata_root_attrs(root)
106+
else:
107+
warn_if_missing_anndata_root_attrs(root, path=path)
99108
return Store(backend="zarr", root=root, path=path)
100109
root = h5py.File(path, mode)
110+
if _is_writable_mode(mode):
111+
ensure_anndata_root_attrs(root)
112+
else:
113+
warn_if_missing_anndata_root_attrs(root, path=path)
101114
return Store(backend="hdf5", root=root, path=path)
102115

103116

117+
def _decode_attr(value: Any) -> Any:
118+
if isinstance(value, bytes):
119+
return value.decode("utf-8")
120+
return value
121+
122+
123+
def _is_writable_mode(mode: str) -> bool:
124+
return any(flag in mode for flag in ("w", "a", "+", "x"))
125+
126+
127+
def has_valid_anndata_root_attrs(root: Any) -> bool:
128+
enc_type = _decode_attr(root.attrs.get("encoding-type", None))
129+
enc_ver = _decode_attr(root.attrs.get("encoding-version", None))
130+
return enc_type == ROOT_ENCODING_TYPE and enc_ver == ROOT_ENCODING_VERSION
131+
132+
133+
def ensure_anndata_root_attrs(root: Any) -> None:
134+
root.attrs["encoding-type"] = ROOT_ENCODING_TYPE
135+
root.attrs["encoding-version"] = ROOT_ENCODING_VERSION
136+
137+
138+
def warn_if_missing_anndata_root_attrs(root: Any, *, path: Path) -> None:
139+
if has_valid_anndata_root_attrs(root):
140+
return
141+
142+
enc_type = _decode_attr(root.attrs.get("encoding-type", None))
143+
enc_ver = _decode_attr(root.attrs.get("encoding-version", None))
144+
warnings.warn(
145+
(
146+
f"Store '{path}' root has missing or invalid AnnData attrs "
147+
f"(encoding-type={ROOT_ENCODING_TYPE!r}, encoding-version={ROOT_ENCODING_VERSION!r}). "
148+
f"Found encoding-type={enc_type!r}, encoding-version={enc_ver!r}."
149+
),
150+
UserWarning,
151+
stacklevel=2,
152+
)
153+
154+
104155
def _normalize_attr_value(value: Any, target_backend: str) -> Any:
105156
if target_backend == "zarr":
106157
if isinstance(value, bytes):
107158
return value.decode("utf-8")
108159
if isinstance(value, (list, tuple)):
109-
return [
110-
v.decode("utf-8") if isinstance(v, bytes) else v for v in value
111-
]
160+
return [v.decode("utf-8") if isinstance(v, bytes) else v for v in value]
112161
if isinstance(value, np.ndarray):
113162
if value.dtype.kind in ("S", "O"):
114163
return [
@@ -187,7 +236,9 @@ def create_dataset(
187236
if zarr_format == 3:
188237
kwargs = dict(kwargs)
189238
kwargs.pop("compressor", None)
190-
elif zarr_format == 2 and "compressors" in kwargs and "compressor" not in kwargs:
239+
elif (
240+
zarr_format == 2 and "compressors" in kwargs and "compressor" not in kwargs
241+
):
191242
kwargs = dict(kwargs)
192243
compressors = kwargs.pop("compressors")
193244
if isinstance(compressors, (list, tuple)) and len(compressors) == 1:
@@ -234,8 +285,12 @@ def copy_dataset(src: Any, dst_group: Any, name: str) -> Any:
234285
return ds
235286

236287

237-
def copy_tree(src_obj: Any, dst_group: Any, name: str, *, exclude: Iterable[str] = ()) -> Any:
238-
if is_hdf5_group(dst_group) and (is_hdf5_group(src_obj) or is_hdf5_dataset(src_obj)):
288+
def copy_tree(
289+
src_obj: Any, dst_group: Any, name: str, *, exclude: Iterable[str] = ()
290+
) -> Any:
291+
if is_hdf5_group(dst_group) and (
292+
is_hdf5_group(src_obj) or is_hdf5_dataset(src_obj)
293+
):
239294
if not exclude:
240295
dst_group.copy(src_obj, dst_group, name)
241296
return dst_group[name]
@@ -256,6 +311,9 @@ def copy_tree(src_obj: Any, dst_group: Any, name: str, *, exclude: Iterable[str]
256311

257312

258313
def copy_store_contents(src_root: Any, dst_root: Any) -> None:
314+
target_backend = "zarr" if is_zarr_group(dst_root) else "hdf5"
315+
copy_attrs(src_root.attrs, dst_root.attrs, target_backend=target_backend)
316+
ensure_anndata_root_attrs(dst_root)
259317
for key in src_root.keys():
260318
copy_tree(src_root[key], dst_root, key)
261319

‎tests/test_storage_root_attrs.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Tests for AnnData root encoding attributes enforcement/warnings."""
2+
3+
from pathlib import Path
4+
5+
import h5py
6+
import pytest
7+
8+
from h5ad.storage import open_store
9+
10+
11+
def _make_minimal_h5ad(path: Path) -> None:
12+
with h5py.File(path, "w") as f:
13+
obs = f.create_group("obs")
14+
obs.attrs["_index"] = "obs_names"
15+
obs.create_dataset("obs_names", data=[b"cell_1"])
16+
17+
var = f.create_group("var")
18+
var.attrs["_index"] = "var_names"
19+
var.create_dataset("var_names", data=[b"gene_1"])
20+
21+
f.create_dataset("X", data=[[1.0]])
22+
23+
24+
def test_open_store_read_warns_for_missing_root_attrs(temp_dir: Path) -> None:
25+
file_path = temp_dir / "missing_root_attrs.h5ad"
26+
_make_minimal_h5ad(file_path)
27+
28+
with pytest.warns(UserWarning, match="missing required AnnData attrs"):
29+
with open_store(file_path, "r"):
30+
pass
31+
32+
33+
def test_open_store_writable_mode_sets_root_attrs(temp_dir: Path) -> None:
34+
file_path = temp_dir / "set_root_attrs.h5ad"
35+
_make_minimal_h5ad(file_path)
36+
37+
with open_store(file_path, "a"):
38+
pass
39+
40+
with h5py.File(file_path, "r") as f:
41+
assert f.attrs.get("encoding-type") == "anndata"
42+
assert f.attrs.get("encoding-version") == "0.1.0"

‎tests/test_subset.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,30 @@ def test_subset_sparse_empty_result(self, sample_sparse_csr_h5ad, temp_dir):
284284
class TestSubsetH5ad:
285285
"""Integration tests for subset_h5ad function."""
286286

287+
def test_subset_h5ad_creates_optional_empty_groups(
288+
self, sample_h5ad_file, temp_dir
289+
):
290+
"""Subset output should include optional AnnData groups even if absent in source."""
291+
obs_file = temp_dir / "obs_names.txt"
292+
obs_file.write_text("cell_1\ncell_3\n")
293+
294+
output = temp_dir / "subset.h5ad"
295+
console = Console(stderr=True)
296+
297+
subset_h5ad(
298+
file=sample_h5ad_file,
299+
output=output,
300+
obs_file=obs_file,
301+
var_file=None,
302+
chunk_rows=1024,
303+
console=console,
304+
)
305+
306+
with h5py.File(output, "r") as f:
307+
for key in ("layers", "obsm", "obsp", "varm", "varp"):
308+
assert key in f
309+
assert isinstance(f[key], h5py.Group)
310+
287311
def test_subset_h5ad_obs_only(self, sample_h5ad_file, temp_dir):
288312
"""Test subsetting h5ad file by obs only."""
289313
obs_file = temp_dir / "obs_names.txt"
@@ -438,7 +462,9 @@ def test_subset_h5ad_obsp_sparse_group(self, temp_dir):
438462
conn.attrs["shape"] = np.array([4, 4], dtype=np.int64)
439463
conn.create_dataset("data", data=np.array([1.0, 2.0, 3.0, 4.0]))
440464
conn.create_dataset("indices", data=np.array([0, 1, 2, 3], dtype=np.int64))
441-
conn.create_dataset("indptr", data=np.array([0, 1, 2, 3, 4], dtype=np.int64))
465+
conn.create_dataset(
466+
"indptr", data=np.array([0, 1, 2, 3, 4], dtype=np.int64)
467+
)
442468

443469
obs_file = temp_dir / "obs_names.txt"
444470
obs_file.write_text("cell_1\ncell_3\n")
@@ -562,7 +588,8 @@ def _csr_group(parent, name, shape):
562588
obs = f.create_group("obs")
563589
obs.attrs["_index"] = "obs_names"
564590
obs.create_dataset(
565-
"obs_names", data=np.array(["cell_1", "cell_2", "cell_3", "cell_4"], dtype="S")
591+
"obs_names",
592+
data=np.array(["cell_1", "cell_2", "cell_3", "cell_4"], dtype="S"),
566593
)
567594

568595
var = f.create_group("var")

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)