Skip to content

Commit b9af951

Browse files
authored
(fix): skip X writing when X is None (#2054)
1 parent 3e662fa commit b9af951

12 files changed

Lines changed: 123 additions & 43 deletions

File tree

.github/workflows/test-cpu.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ jobs:
5656
fetch-depth: 0
5757
filter: blob:none
5858

59+
- name: Install system dependencies
60+
run: sudo apt install -y hdf5-tools
61+
5962
- name: Set up Python ${{ matrix.env.python }}
6063
uses: actions/setup-python@v5
6164
with:

docs/release-notes/2054.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Revert accidental change where {attr}`~anndata.AnnData.X` got written to disk when it was `None` {user}`flying-sheep`

src/anndata/_io/h5ad.py

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from functools import partial
55
from pathlib import Path
66
from types import MappingProxyType
7-
from typing import TYPE_CHECKING, TypeVar
7+
from typing import TYPE_CHECKING, TypeVar, cast
88
from warnings import warn
99

1010
import h5py
@@ -36,11 +36,12 @@
3636
)
3737

3838
if TYPE_CHECKING:
39-
from collections.abc import Callable, Collection, Mapping, Sequence
39+
from collections.abc import Callable, Collection, Container, Mapping, Sequence
4040
from os import PathLike
4141
from typing import Any, Literal
4242

4343
from .._core.file_backing import AnnDataFileManager
44+
from .._core.raw import Raw
4445

4546
T = TypeVar("T")
4647

@@ -82,29 +83,18 @@ def write_h5ad(
8283
# TODO: Use spec writing system for this
8384
# Currently can't use write_dispatched here because this function is also called to do an
8485
# inplace update of a backed object, which would delete "/"
85-
f = f["/"]
86+
f = cast("h5py.Group", f["/"])
8687
f.attrs.setdefault("encoding-type", "anndata")
8788
f.attrs.setdefault("encoding-version", "0.1.0")
8889

89-
if "X" in as_dense and isinstance(
90-
adata.X, CSMatrix | BaseCompressedSparseDataset
91-
):
92-
write_sparse_as_dense(f, "X", adata.X, dataset_kwargs=dataset_kwargs)
93-
elif not (adata.isbacked and Path(adata.filename) == Path(filepath)):
94-
# If adata.isbacked, X should already be up to date
95-
write_elem(f, "X", adata.X, dataset_kwargs=dataset_kwargs)
96-
if "raw/X" in as_dense and isinstance(
97-
adata.raw.X, CSMatrix | BaseCompressedSparseDataset
98-
):
99-
write_sparse_as_dense(
100-
f, "raw/X", adata.raw.X, dataset_kwargs=dataset_kwargs
101-
)
102-
write_elem(f, "raw/var", adata.raw.var, dataset_kwargs=dataset_kwargs)
103-
write_elem(
104-
f, "raw/varm", dict(adata.raw.varm), dataset_kwargs=dataset_kwargs
105-
)
106-
elif adata.raw is not None:
107-
write_elem(f, "raw", adata.raw, dataset_kwargs=dataset_kwargs)
90+
_write_x(
91+
f,
92+
adata, # accessing adata.X reopens adata.file if it’s backed
93+
is_backed=adata.isbacked and adata.filename == filepath,
94+
as_dense=as_dense,
95+
dataset_kwargs=dataset_kwargs,
96+
)
97+
_write_raw(f, adata.raw, as_dense=as_dense, dataset_kwargs=dataset_kwargs)
10898
write_elem(f, "obs", adata.obs, dataset_kwargs=dataset_kwargs)
10999
write_elem(f, "var", adata.var, dataset_kwargs=dataset_kwargs)
110100
write_elem(f, "obsm", dict(adata.obsm), dataset_kwargs=dataset_kwargs)
@@ -115,6 +105,41 @@ def write_h5ad(
115105
write_elem(f, "uns", dict(adata.uns), dataset_kwargs=dataset_kwargs)
116106

117107

108+
def _write_x(
109+
f: h5py.Group,
110+
adata: AnnData,
111+
*,
112+
is_backed: bool,
113+
as_dense: Container[str],
114+
dataset_kwargs: Mapping[str, Any],
115+
) -> None:
116+
if "X" in as_dense and isinstance(adata.X, CSMatrix | BaseCompressedSparseDataset):
117+
write_sparse_as_dense(f, "X", adata.X, dataset_kwargs=dataset_kwargs)
118+
elif is_backed:
119+
pass # If adata.isbacked, X should already be up to date
120+
elif adata.X is None:
121+
f.pop("X", None)
122+
else:
123+
write_elem(f, "X", adata.X, dataset_kwargs=dataset_kwargs)
124+
125+
126+
def _write_raw(
127+
f: h5py.Group,
128+
raw: Raw,
129+
*,
130+
as_dense: Container[str],
131+
dataset_kwargs: Mapping[str, Any],
132+
) -> None:
133+
if "raw/X" in as_dense and isinstance(
134+
raw.X, CSMatrix | BaseCompressedSparseDataset
135+
):
136+
write_sparse_as_dense(f, "raw/X", raw.X, dataset_kwargs=dataset_kwargs)
137+
write_elem(f, "raw/var", raw.var, dataset_kwargs=dataset_kwargs)
138+
write_elem(f, "raw/varm", dict(raw.varm), dataset_kwargs=dataset_kwargs)
139+
elif raw is not None:
140+
write_elem(f, "raw", raw, dataset_kwargs=dataset_kwargs)
141+
142+
118143
@report_write_key_on_error
119144
@write_spec(IOSpec("array", "0.2.0"))
120145
def write_sparse_as_dense(

src/anndata/_io/specs/methods.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,8 @@ def write_anndata(
275275
dataset_kwargs: Mapping[str, Any] = MappingProxyType({}),
276276
):
277277
g = f.require_group(k)
278-
_writer.write_elem(g, "X", adata.X, dataset_kwargs=dataset_kwargs)
278+
if adata.X is not None:
279+
_writer.write_elem(g, "X", adata.X, dataset_kwargs=dataset_kwargs)
279280
_writer.write_elem(g, "obs", adata.obs, dataset_kwargs=dataset_kwargs)
280281
_writer.write_elem(g, "var", adata.var, dataset_kwargs=dataset_kwargs)
281282
_writer.write_elem(g, "obsm", dict(adata.obsm), dataset_kwargs=dataset_kwargs)

src/anndata/_io/zarr.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
from pathlib import Path
43
from typing import TYPE_CHECKING, TypeVar
54
from warnings import warn
65

@@ -37,8 +36,6 @@ def write_zarr(
3736
**ds_kwargs,
3837
) -> None:
3938
"""See :meth:`~anndata.AnnData.write_zarr`."""
40-
if isinstance(store, Path):
41-
store = str(store)
4239
if convert_strings_to_categoricals:
4340
adata.strings_to_categoricals()
4441
if adata.raw is not None:
@@ -75,9 +72,6 @@ def read_zarr(store: PathLike[str] | str | MutableMapping | zarr.Group) -> AnnDa
7572
store
7673
The filename, a :class:`~typing.MutableMapping`, or a Zarr storage class.
7774
"""
78-
if isinstance(store, Path):
79-
store = str(store)
80-
8175
f = store if isinstance(store, zarr.Group) else zarr.open(store, mode="r")
8276

8377
# Read with handling for backwards compat

tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@
2424

2525
if TYPE_CHECKING:
2626
from collections.abc import Generator
27+
from pathlib import Path
2728
from types import EllipsisType
2829

2930

3031
@pytest.fixture
31-
def backing_h5ad(tmp_path):
32+
def backing_h5ad(tmp_path: Path) -> Path:
3233
return tmp_path / "test.h5ad"
3334

3435

16 KB
Binary file not shown.
6.39 KB
Binary file not shown.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
These files were written with
2+
3+
```bash
4+
uvx '--with=anndata==0.11.4' '--with=zarr<3' python -c '
5+
import zarr
6+
from anndata import AnnData
7+
adata = AnnData(shape=(10, 20))
8+
adata.write_zarr(zarr.ZipStore("tests/data/archives/v0.11.4/adata.zarr.zip"))
9+
adata.write_h5ad("tests/data/archives/v0.11.4/adata.h5ad")'
10+
```

tests/test_backed_hdf5.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def test_read_write_X(tmp_path, mtx_format, backed_mode, as_dense):
108108

109109
# this is very similar to the views test
110110
@pytest.mark.filterwarnings("ignore::anndata.ImplicitModificationWarning")
111-
def test_backing(adata, tmp_path, backing_h5ad):
111+
def test_backing(adata: ad.AnnData, tmp_path: Path, backing_h5ad: Path) -> None:
112112
assert not adata.isbacked
113113

114114
adata.filename = backing_h5ad

0 commit comments

Comments
 (0)