Skip to content

Commit d4b9b10

Browse files
committed
Fix
1 parent 52748f4 commit d4b9b10

19 files changed

Lines changed: 241 additions & 17 deletions

‎docs/user_guide/python_package/d_viewing_apis/02_loading_routes.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ If you don’t have a strong reason otherwise:
6262
- a directory that looks like an export (or contains exported subfolders) → treated as **exported** → served by `serve(...)`
6363
- a file with the exact `.h5ad` suffix → treated as **AnnData** and opened
6464
read-only-backed by `serve_anndata(...)`
65-
- a complete Zarr v2 directory (both valid `.zgroup` and `.zattrs`, without
66-
`zarr.json`) → treated as **AnnData** and materialized eagerly by
65+
- a complete Zarr v2 directory (a valid `.zgroup`, a valid `.zattrs` when it
66+
has one — anndata before 0.8 wrote none — and no `zarr.json`) → treated as
67+
**AnnData** and materialized eagerly by
6768
`serve_anndata(...)`; the directory name does not need a `.zarr` suffix
6869

6970
Details: {doc}`04_cli_cellucid_serve_quickstart`.

‎docs/user_guide/python_package/d_viewing_apis/04_cli_cellucid_serve_quickstart.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,9 @@ Both rules are in
122122
`cellucid serve <data_path>` decides what you meant using these rules:
123123

124124
- A regular file must use the exact `.h5ad` suffix.
125-
- A Zarr v2 directory must contain both `.zgroup` and `.zattrs` as valid root
126-
metadata and must not also contain `zarr.json`.
125+
- A Zarr v2 directory must contain a valid `.zgroup` (and a valid `.zattrs`
126+
when it has one; anndata before 0.8 wrote none) and must not also contain
127+
`zarr.json`.
127128
- A directory name ending in `.zarr` is not sufficient by itself, and a valid
128129
Zarr store does not need that suffix.
129130
- Any other directory that is one complete prepared dataset or contains

‎docs/user_guide/python_package/h_developer_docs/05_cli_architecture_and_commands.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ Implementation lives in:
6767
Detection rules:
6868

6969
1) A regular file is `h5ad` only when it has the exact `.h5ad` suffix.
70-
2) A directory is `zarr` only when it is a complete Zarr v2 root (both valid
71-
`.zgroup` and `.zattrs`, with no `zarr.json`).
70+
2) A directory is `zarr` only when it is a complete Zarr v2 root (a valid
71+
`.zgroup`, a valid `.zattrs` when present, and no `zarr.json`).
7272
3) A `.zarr` directory name alone does not establish the format.
7373
4) A non-Zarr directory is `exported` when `_list_exported_datasets(...)`
7474
discovers one or more complete prepared datasets, including an exports root

‎docs/user_guide/python_package/installation.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ The core installation includes the coupled direct-data runtime:
131131
- `zarr>=3.1.4,<4`
132132
- `numcodecs>=0.16.3,<0.17`
133133

134-
Python `.zarr` input is one complete Zarr v2 root containing both `.zgroup` and
135-
`.zattrs`. The core installation supplies the complete reader runtime,
134+
Python `.zarr` input is one complete Zarr v2 root containing `.zgroup` (and
135+
`.zattrs` when it has one; anndata before 0.8 wrote none). The core installation supplies the complete reader runtime,
136136
including the compiled numcodecs wheel used on Python 3.14.
137137

138138
### Additional tools you might need

‎docs/user_guide/web_app/b_data_loading/03_browser_file_picker_tutorial.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,8 @@ Use this like a checklist. Most issues are diagnosable in < 2 minutes.
399399
**Fix**
400400
- With the supported AnnData version, re-export with
401401
`adata.write_zarr("data.zarr")`; the resulting store must contain the Zarr
402-
v2 root markers `.zgroup` and `.zattrs`.
402+
v2 root marker `.zgroup` (stores from anndata before 0.8 have no root
403+
`.zattrs` and open as they are).
403404
- Package the complete store while preserving its dotfiles, then select the
404405
resulting `.zarr.zip` or `.zip` file with **Zarr ZIP**.
405406

‎docs/user_guide/web_app/b_data_loading/07_folder_file_format_expectations_high_level_link_to_spec.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,25 @@ and the notebook viewers. In the browser any other array can then be turned
150150
on under **Session › Data mapping** and shown in any view; `cellucid serve` takes
151151
`--embedding-1d/--embedding-2d/--embedding-3d KEY[:c0,c1]`.
152152

153+
### Files from older AnnData releases
154+
155+
Files written before anndata 0.8 (2022) open as written, from the file pickers,
156+
a dropped file, a link and `cellucid serve` alike:
157+
158+
- **anndata 0.7**, and every file SeuratDisk's `Convert()` writes: dataframes
159+
whose categorical columns keep their categories under `__categories`, arrays
160+
without encoding attributes, and (Zarr) a store without a root `.zattrs`;
161+
- **anndata 0.6 and earlier**: `obs` and `var` as one record per cell
162+
(compound datasets) with categories in `uns`, a compound `obsm`, `raw` stored
163+
as `raw.X` / `raw.var`, and sparse matrices named by `h5sparse_format`.
164+
165+
Every column a file stores is shown, including those a SeuratDisk file leaves
166+
out of its `column-order` (anndata itself drops those). Ordered categories and
167+
missing values keep their meaning. Nothing needs converting first.
168+
`prepare()` and `show_anndata(adata)` take an AnnData you have already read, so
169+
they hold the columns anndata read; pass the file's path to `cellucid serve`,
170+
`serve_anndata` or `show_anndata` to keep the unlisted ones.
171+
153172
### Optional (but commonly expected)
154173

155174
- **Obs fields** (metadata for coloring/filtering): `adata.obs[...]`

‎docs/user_guide/web_app/b_data_loading/14_open_from_link.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,15 +208,13 @@ you export one.
208208
| Cellucid found nothing to plot … | The file has no embedding | Add one, as above |
209209
| … has 2 files Cellucid opens. Open one of them: … | A record, series or repository holds several openable files | Paste one of the links the message lists |
210210
| … holds no datasets.json, so it is not a folder of prepared exports | A folder link that points at a web page or the wrong folder | Link to the folder that holds `datasets.json`, or to one file |
211-
| This .h5ad file was written by an AnnData release before 0.8 … | The file predates anndata 0.8 (2022), whose layout Cellucid does not read yet | Open it with a current anndata and save it again, then open the new file |
212211
| This .h5ad file compresses … with LZF … | A filter outside HDF5 (LZF is h5py's own; Blosc, Zstandard) | Save it again with gzip: `adata.write_h5ad(new_path, compression="gzip")` |
213212

214213
Each refusal can be seen on real published data; paste one of these into
215214
**Open from link**:
216215

217216
- several openable files: `zenodo:15713160`
218217
- nothing to plot: `geo:GSE307135`
219-
- written before anndata 0.8: `zenodo:7496490/packer19_small.h5ad`
220218
- LZF compression: `zenodo:7838976/GSM3768152_Brain_8_dge_asStruCluster.h5ad`
221219
- newer than the Census release: `cellxgene:8f3cb9c4-38d5-4b9c-b309-bcccd76d0075`
222220

‎src/cellucid/_older_anndata.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Columns that files from older AnnData writers store but do not list.
2+
3+
A dataframe in the layout anndata wrote before 0.8 names its columns in a
4+
``column-order`` attribute, and the current anndata reads exactly the columns
5+
listed there. SeuratDisk's ``Convert()`` lists only some of them, so reading
6+
such a file through anndata silently drops the rest — a cell field the web
7+
app's own reader shows would be missing from ``cellucid serve``. This restores
8+
them, read from the file itself, so both readers show the same fields:
9+
10+
- a column stored with its categories under ``__categories/<column>`` is a
11+
categorical (``ordered`` as the categories record it, codes of ``-1`` are
12+
missing);
13+
- any other column is its stored values, text decoded as text.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Any
19+
20+
import numpy as np
21+
import pandas as pd
22+
23+
__all__ = ["restore_unlisted_columns"]
24+
25+
26+
def _attribute_strings(value: Any) -> list[str]:
27+
if value is None:
28+
return []
29+
return [item.decode() if isinstance(item, bytes) else str(item) for item in np.ravel(value)]
30+
31+
32+
def _read_values(array: Any) -> np.ndarray:
33+
"""An array's values, with text as ``str`` whatever the store encodes."""
34+
try:
35+
import h5py
36+
except ImportError: # pragma: no cover - h5py is an anndata dependency
37+
h5py = None
38+
if h5py is not None and isinstance(array, h5py.Dataset):
39+
if h5py.check_string_dtype(array.dtype) is not None:
40+
return np.asarray(array.asstr()[...], dtype=object)
41+
return np.asarray(array[...])
42+
values = np.asarray(array[...])
43+
if values.dtype.kind in ("S", "O"):
44+
return np.array(
45+
[item.decode() if isinstance(item, bytes) else item for item in values],
46+
dtype=object,
47+
)
48+
return values
49+
50+
51+
def restore_unlisted_columns(adata: Any, store: Any) -> list[str]:
52+
"""Add every ``obs``/``var`` column the store holds but anndata dropped.
53+
54+
Parameters
55+
----------
56+
adata
57+
The AnnData object read from ``store``.
58+
store
59+
The open file (``h5py.File``) or store (``zarr.Group``) it was read
60+
from.
61+
62+
Returns
63+
-------
64+
list of str
65+
``"<axis>/<column>"`` for every column restored.
66+
"""
67+
restored: list[str] = []
68+
for axis in ("obs", "var"):
69+
if axis not in store:
70+
continue
71+
group = store[axis]
72+
if not hasattr(group, "keys"):
73+
continue # anndata 0.6 compound dataframes list every column
74+
frame: pd.DataFrame = getattr(adata, axis)
75+
index_key = _attribute_strings(group.attrs.get("_index"))
76+
stored = [
77+
key
78+
for key in group
79+
if key != "__categories" and key not in index_key and key not in frame.columns
80+
]
81+
if not stored:
82+
continue
83+
categories = group.get("__categories")
84+
for key in stored:
85+
if not hasattr(group[key], "shape"):
86+
continue # an encoded element (a group), which anndata reads itself
87+
values = _read_values(group[key])
88+
if values.ndim != 1 or values.shape[0] != frame.shape[0]:
89+
continue # not a per-row column
90+
if categories is not None and key in categories:
91+
labels = _read_values(categories[key])
92+
ordered = bool(np.ravel(categories[key].attrs.get("ordered", False))[0])
93+
frame[key] = pd.Categorical.from_codes(values.astype(np.int64), labels, ordered=ordered)
94+
else:
95+
frame[key] = values
96+
restored.append(f"{axis}/{key}")
97+
return restored

‎src/cellucid/anndata_adapter.py‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
_require_nonempty_string,
108108
_require_positive_native_integer,
109109
)
110+
from ._older_anndata import restore_unlisted_columns
110111
from ._server_base import print_detail
111112
from .connectivity_contract import (
112113
ConnectivityEdgePairs,
@@ -263,17 +264,21 @@ def _classify_anndata_path(path: str | Path) -> Literal["h5ad", "zarr"]:
263264
if zarr_v3_metadata.exists():
264265
raise ValueError(
265266
f"AnnData Zarr input must use the exact Zarr v2 root contract "
266-
f"(.zgroup and .zattrs, without zarr.json): {resolved_path}"
267+
f"(.zgroup, without zarr.json): {resolved_path}"
267268
)
268269

269270
if has_v2_marker:
270-
if not zarr_v2_group.is_file() or not zarr_v2_attrs.is_file():
271-
raise ValueError(
272-
f"Zarr v2 directory must contain both .zgroup and .zattrs files: {resolved_path}"
273-
)
271+
# anndata before 0.8 wrote no root .zattrs; when there is one it must
272+
# be readable.
273+
if not zarr_v2_group.is_file():
274+
raise ValueError(f"Zarr v2 directory must contain a .zgroup file: {resolved_path}")
274275
try:
275276
group_metadata = json.loads(zarr_v2_group.read_text(encoding="utf-8"))
276-
attributes = json.loads(zarr_v2_attrs.read_text(encoding="utf-8"))
277+
attributes = (
278+
json.loads(zarr_v2_attrs.read_text(encoding="utf-8"))
279+
if zarr_v2_attrs.is_file()
280+
else {}
281+
)
277282
except (OSError, UnicodeError, json.JSONDecodeError) as error:
278283
raise ValueError(
279284
f"Zarr v2 root metadata must be readable UTF-8 JSON: {resolved_path}"
@@ -931,16 +936,26 @@ def from_file(
931936
path = Path(path).resolve()
932937

933938
source_type = _classify_anndata_path(path)
939+
store: Any
934940
if source_type == "zarr":
941+
import zarr
942+
935943
adata = ad.read_zarr(path)
936944
logger.info("Loaded zarr store eagerly: %s", path)
945+
store = zarr.open_group(str(path), mode="r")
937946
else:
938947
adata = ad.read_h5ad(path, backed="r")
939948
logger.info(
940949
"Loaded h5ad file: %s (is_backed=%s)",
941950
path,
942951
bool(adata.isbacked),
943952
)
953+
store = adata.file._file if adata.isbacked else None
954+
# Older writers (SeuratDisk) list only some columns in column-order,
955+
# and anndata reads only those; the web app's reader shows them all.
956+
restored = [] if store is None else restore_unlisted_columns(adata, store)
957+
if restored:
958+
logger.info("Read columns the file stores but does not list: %s", restored)
944959

945960
# Create adapter and store non-sensitive source info. A failed adapter
946961
# construction must not leave a backed file handle open.
61.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)