spatialdata-js-util 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spatialdata_js_util/__init__.py +107 -0
- spatialdata_js_util/cli.py +803 -0
- spatialdata_js_util/codecs/__init__.py +67 -0
- spatialdata_js_util/codecs/backends.py +250 -0
- spatialdata_js_util/codecs/chunks.py +40 -0
- spatialdata_js_util/codecs/encoding.py +154 -0
- spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
- spatialdata_js_util/codecs/names.py +26 -0
- spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
- spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
- spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
- spatialdata_js_util/codecs/vendor/__init__.py +1 -0
- spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
- spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
- spatialdata_js_util/codecs/zarr_codec.py +149 -0
- spatialdata_js_util/errors.py +5 -0
- spatialdata_js_util/images.py +849 -0
- spatialdata_js_util/index_permutations.py +311 -0
- spatialdata_js_util/points.py +445 -0
- spatialdata_js_util/points_zarr.py +799 -0
- spatialdata_js_util/provenance.py +17 -0
- spatialdata_js_util/pyramids.py +318 -0
- spatialdata_js_util/runners.py +458 -0
- spatialdata_js_util/store.py +305 -0
- spatialdata_js_util/tables.py +310 -0
- spatialdata_js_util/tui/__init__.py +0 -0
- spatialdata_js_util/tui/app.py +112 -0
- spatialdata_js_util/tui/models.py +45 -0
- spatialdata_js_util/tui/screens.py +1255 -0
- spatialdata_js_util/tui/widgets.py +50 -0
- spatialdata_js_util/verify.py +338 -0
- spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
- spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
- spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
- spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
- spatialdata_js_util-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Filesystem-level helpers for SpatialData Zarr stores.
|
|
2
|
+
|
|
3
|
+
These operate on the store layout directly rather than through `spatialdata`, so
|
|
4
|
+
that copy-then-rewrite workflows can touch one element without materialising the
|
|
5
|
+
whole object — and so the reader-facing parts of this package stay importable
|
|
6
|
+
without the heavy write dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Sequence
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import pyarrow.dataset as ds
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_json(path: str | Path) -> dict[str, Any]:
|
|
22
|
+
return json.loads(Path(path).read_text())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def json_bytes(value: dict[str, Any]) -> bytes:
|
|
26
|
+
return json.dumps(value, indent=2, sort_keys=True).encode("utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_json(path: str | Path, value: dict[str, Any]) -> None:
|
|
30
|
+
target = Path(path)
|
|
31
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
target.write_bytes(json_bytes(value))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def drop_consolidated_metadata(group_path: str | Path) -> bool:
|
|
36
|
+
"""Remove a group's own inline consolidated metadata, if it has any.
|
|
37
|
+
|
|
38
|
+
A nested block is a cache of the metadata below it. Rewriting anything under
|
|
39
|
+
the group leaves that cache describing the old encoding, and readers trust
|
|
40
|
+
the cache over the files — so an array can be rewritten correctly and still
|
|
41
|
+
fail to load. The store root's listing is refreshed separately and remains
|
|
42
|
+
the authoritative one.
|
|
43
|
+
|
|
44
|
+
Returns whether a block was actually removed.
|
|
45
|
+
"""
|
|
46
|
+
meta_path = Path(group_path) / "zarr.json"
|
|
47
|
+
if not meta_path.is_file():
|
|
48
|
+
return False
|
|
49
|
+
doc = read_json(meta_path)
|
|
50
|
+
if "consolidated_metadata" not in doc:
|
|
51
|
+
return False
|
|
52
|
+
doc.pop("consolidated_metadata")
|
|
53
|
+
write_json(meta_path, doc)
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _implicit_group(zarr_format: int) -> dict[str, Any]:
|
|
58
|
+
return {"attributes": {}, "node_type": "group", "zarr_format": zarr_format}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _collect_consolidated_metadata(store_path: Path, *, zarr_format: int = 3) -> dict[str, Any]:
|
|
62
|
+
"""Map every node under *store_path* to its metadata document.
|
|
63
|
+
|
|
64
|
+
Entries are filled in for intermediate groups that have no `zarr.json` of
|
|
65
|
+
their own. Some writers create element directories without writing metadata
|
|
66
|
+
for the collection that contains them — `points/transcripts/zarr.json` but no
|
|
67
|
+
`points/zarr.json`. Listing the child without its parent leaves an orphan
|
|
68
|
+
that zarr cannot attach to the hierarchy, and it rejects the *whole* store:
|
|
69
|
+
`zarr.open_group` then raises `GroupNotFoundError` at the root, so the store
|
|
70
|
+
stops opening entirely rather than merely missing one element.
|
|
71
|
+
"""
|
|
72
|
+
metadata: dict[str, Any] = {}
|
|
73
|
+
for meta_path in store_path.rglob("zarr.json"):
|
|
74
|
+
if meta_path == store_path / "zarr.json":
|
|
75
|
+
continue
|
|
76
|
+
rel = meta_path.parent.relative_to(store_path).as_posix()
|
|
77
|
+
metadata[rel] = read_json(meta_path)
|
|
78
|
+
|
|
79
|
+
_fill_implicit_parents(metadata, zarr_format)
|
|
80
|
+
return metadata
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _fill_implicit_parents(metadata: dict[str, Any], zarr_format: int) -> None:
|
|
84
|
+
"""Add a group entry for every ancestor path that has none, in place.
|
|
85
|
+
|
|
86
|
+
Listing `points/transcripts` without `points` leaves an orphan that zarr
|
|
87
|
+
cannot attach to the hierarchy, and it rejects the *whole* store rather than
|
|
88
|
+
the one element — so every writer of this map has to close the gap.
|
|
89
|
+
"""
|
|
90
|
+
for rel in list(metadata):
|
|
91
|
+
parts = rel.split("/")
|
|
92
|
+
for depth in range(1, len(parts)):
|
|
93
|
+
parent = "/".join(parts[:depth])
|
|
94
|
+
if parent not in metadata:
|
|
95
|
+
metadata[parent] = _implicit_group(zarr_format)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def consolidated_metadata_orphans(metadata: dict[str, Any]) -> list[str]:
|
|
99
|
+
"""Entries whose parent path is absent — these make a store unreadable."""
|
|
100
|
+
return sorted(
|
|
101
|
+
key for key in metadata if "/" in key and key.rsplit("/", 1)[0] not in metadata
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def refresh_consolidated_metadata(store_path: str | Path) -> None:
|
|
106
|
+
"""Rewrite the store root's inline consolidated metadata from disk.
|
|
107
|
+
|
|
108
|
+
Required after any structural rewrite: readers trust the consolidated copy,
|
|
109
|
+
so a stale one hides newly written arrays or reports old codecs.
|
|
110
|
+
"""
|
|
111
|
+
root = Path(store_path)
|
|
112
|
+
root_path = root / "zarr.json"
|
|
113
|
+
root_meta = read_json(root_path)
|
|
114
|
+
metadata = _collect_consolidated_metadata(
|
|
115
|
+
root, zarr_format=int(root_meta.get("zarr_format", 3))
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
orphans = consolidated_metadata_orphans(metadata)
|
|
119
|
+
if orphans:
|
|
120
|
+
# Never write metadata that would make the store fail to open.
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Refusing to write consolidated metadata with orphaned entries for {root}: "
|
|
123
|
+
f"{', '.join(orphans)}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
root_meta["consolidated_metadata"] = {
|
|
127
|
+
"kind": "inline",
|
|
128
|
+
"must_understand": False,
|
|
129
|
+
"metadata": metadata,
|
|
130
|
+
}
|
|
131
|
+
write_json(root_path, root_meta)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def list_element_keys(zarr_path: str | Path, collection: str) -> list[str]:
|
|
135
|
+
"""List element names under a top-level store collection (images, tables, …)."""
|
|
136
|
+
root = Path(zarr_path) / collection
|
|
137
|
+
if not root.is_dir():
|
|
138
|
+
return []
|
|
139
|
+
return sorted(
|
|
140
|
+
child.name
|
|
141
|
+
for child in root.iterdir()
|
|
142
|
+
if child.is_dir() and ((child / "zarr.json").is_file() or (child / ".zgroup").is_file())
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def zarr_format_of(path: str | Path) -> int:
|
|
147
|
+
"""Return the zarr format (2 or 3) a group on disk was written in.
|
|
148
|
+
|
|
149
|
+
AnnData groups inside a store must be rewritten in the format they were read
|
|
150
|
+
in; mixing formats within one store breaks readers.
|
|
151
|
+
"""
|
|
152
|
+
node = Path(path)
|
|
153
|
+
if (node / "zarr.json").is_file():
|
|
154
|
+
return int(read_json(node / "zarr.json").get("zarr_format", 3))
|
|
155
|
+
if (node / ".zgroup").is_file():
|
|
156
|
+
return int(read_json(node / ".zgroup").get("zarr_format", 2))
|
|
157
|
+
raise FileNotFoundError(f"No zarr group metadata found at {node}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _points_root(zarr_path: Path) -> Path:
|
|
161
|
+
return zarr_path / "points"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def validate_points_key(points_key: str) -> str:
|
|
165
|
+
key = points_key.strip()
|
|
166
|
+
if not key:
|
|
167
|
+
raise ValueError("Points element name cannot be empty.")
|
|
168
|
+
if Path(key).name != key or key in {".", ".."}:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
"Points element name must be a single name under points/, not a path."
|
|
171
|
+
)
|
|
172
|
+
return key
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def list_points_keys(zarr_path: str | Path) -> list[str]:
|
|
176
|
+
root = _points_root(Path(zarr_path))
|
|
177
|
+
if not root.is_dir():
|
|
178
|
+
return []
|
|
179
|
+
return sorted(
|
|
180
|
+
child.name
|
|
181
|
+
for child in root.iterdir()
|
|
182
|
+
if child.is_dir() and (child / "zarr.json").is_file()
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def read_points_element_attrs(zarr_path: str | Path, points_key: str) -> dict[str, Any]:
|
|
187
|
+
element_json = _points_root(Path(zarr_path)) / points_key / "zarr.json"
|
|
188
|
+
if not element_json.is_file():
|
|
189
|
+
raise FileNotFoundError(f"Points element not found: points/{points_key}")
|
|
190
|
+
attrs = read_json(element_json).get("attributes", {})
|
|
191
|
+
spatialdata_attrs = attrs.get("spatialdata_attrs", {})
|
|
192
|
+
if not isinstance(spatialdata_attrs, dict):
|
|
193
|
+
spatialdata_attrs = {}
|
|
194
|
+
return {
|
|
195
|
+
"axes": attrs.get("axes", []),
|
|
196
|
+
"feature_key": spatialdata_attrs.get("feature_key"),
|
|
197
|
+
"instance_key": spatialdata_attrs.get("instance_key"),
|
|
198
|
+
"version": spatialdata_attrs.get("version"),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def points_parquet_path(zarr_path: str | Path, points_key: str) -> Path:
|
|
203
|
+
return _points_root(Path(zarr_path)) / validate_points_key(points_key) / "points.parquet"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def experimental_points_output_path(zarr_path: str | Path, points_key: str) -> Path:
|
|
207
|
+
return Path(zarr_path) / "points.experimental" / validate_points_key(points_key) / "points.parquet"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def copy_points_element_metadata(
|
|
211
|
+
zarr_path: str | Path,
|
|
212
|
+
*,
|
|
213
|
+
source_key: str,
|
|
214
|
+
dest_key: str,
|
|
215
|
+
) -> None:
|
|
216
|
+
source = _points_root(Path(zarr_path)) / validate_points_key(source_key) / "zarr.json"
|
|
217
|
+
dest = _points_root(Path(zarr_path)) / validate_points_key(dest_key) / "zarr.json"
|
|
218
|
+
if not source.is_file():
|
|
219
|
+
raise FileNotFoundError(f"Points element metadata not found: {source}")
|
|
220
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
shutil.copy2(source, dest)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _points_element_consolidated_entry(
|
|
225
|
+
zarr_path: Path,
|
|
226
|
+
points_key: str,
|
|
227
|
+
*,
|
|
228
|
+
metadata: dict[str, Any] | None = None,
|
|
229
|
+
) -> dict[str, Any]:
|
|
230
|
+
if metadata is not None:
|
|
231
|
+
entry = metadata.get(f"points/{points_key}")
|
|
232
|
+
if isinstance(entry, dict):
|
|
233
|
+
return deepcopy(entry)
|
|
234
|
+
element_json = _points_root(zarr_path) / points_key / "zarr.json"
|
|
235
|
+
element_doc = read_json(element_json)
|
|
236
|
+
return {
|
|
237
|
+
"attributes": element_doc.get("attributes", {}),
|
|
238
|
+
"node_type": element_doc.get("node_type", "group"),
|
|
239
|
+
"zarr_format": element_doc.get("zarr_format", 3),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def read_store_consolidated_metadata(zarr_path: str | Path) -> dict[str, Any]:
|
|
244
|
+
root_json = Path(zarr_path) / "zarr.json"
|
|
245
|
+
if not root_json.is_file():
|
|
246
|
+
raise FileNotFoundError(f"Missing store metadata: {root_json}")
|
|
247
|
+
doc = read_json(root_json)
|
|
248
|
+
consolidated = doc.get("consolidated_metadata")
|
|
249
|
+
if not isinstance(consolidated, dict):
|
|
250
|
+
raise ValueError(f"Store has no consolidated metadata: {root_json}")
|
|
251
|
+
metadata = consolidated.get("metadata")
|
|
252
|
+
if not isinstance(metadata, dict):
|
|
253
|
+
raise ValueError(f"Store consolidated metadata has no metadata map: {root_json}")
|
|
254
|
+
return metadata
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def register_points_elements_in_consolidated_metadata(
|
|
258
|
+
zarr_path: str | Path,
|
|
259
|
+
element_keys: Sequence[str],
|
|
260
|
+
*,
|
|
261
|
+
template_key: str,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Register sibling points elements in the store root consolidated metadata."""
|
|
264
|
+
store_path = Path(zarr_path)
|
|
265
|
+
root_json = store_path / "zarr.json"
|
|
266
|
+
doc = read_json(root_json)
|
|
267
|
+
consolidated = doc.get("consolidated_metadata")
|
|
268
|
+
if not isinstance(consolidated, dict):
|
|
269
|
+
consolidated = {"kind": "inline", "metadata": {}}
|
|
270
|
+
doc["consolidated_metadata"] = consolidated
|
|
271
|
+
metadata = consolidated.get("metadata")
|
|
272
|
+
if not isinstance(metadata, dict):
|
|
273
|
+
metadata = {}
|
|
274
|
+
consolidated["metadata"] = metadata
|
|
275
|
+
|
|
276
|
+
template_entry = _points_element_consolidated_entry(
|
|
277
|
+
store_path,
|
|
278
|
+
template_key,
|
|
279
|
+
metadata=metadata,
|
|
280
|
+
)
|
|
281
|
+
for key in element_keys:
|
|
282
|
+
metadata[f"points/{key}"] = deepcopy(template_entry)
|
|
283
|
+
|
|
284
|
+
# Adding `points/<key>` to a listing with no `points` entry orphans it, and
|
|
285
|
+
# zarr then refuses the whole store — the same failure `refresh_consolidated_
|
|
286
|
+
# metadata` guards against, reachable here because this writes the map
|
|
287
|
+
# directly. A source store missing the parent is exactly the case this module
|
|
288
|
+
# exists to handle, so it cannot be assumed away.
|
|
289
|
+
_fill_implicit_parents(metadata, int(doc.get("zarr_format", 3)))
|
|
290
|
+
orphans = consolidated_metadata_orphans(metadata)
|
|
291
|
+
if orphans:
|
|
292
|
+
raise ValueError(
|
|
293
|
+
f"Refusing to write consolidated metadata with orphaned entries for {store_path}: "
|
|
294
|
+
f"{', '.join(orphans)}"
|
|
295
|
+
)
|
|
296
|
+
write_json(root_json, doc)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def read_points_dataframe(parquet_path: str | Path) -> pd.DataFrame:
|
|
300
|
+
path = Path(parquet_path)
|
|
301
|
+
if not path.exists():
|
|
302
|
+
raise FileNotFoundError(f"Points Parquet not found: {path}")
|
|
303
|
+
# `ds.dataset` handles a single file and a partitioned directory alike.
|
|
304
|
+
table = ds.dataset(path, format="parquet").to_table()
|
|
305
|
+
return table.to_pandas()
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Convert SpatialData table matrices to CSC.
|
|
2
|
+
|
|
3
|
+
Browser clients read expression data one *variable* at a time — colour cells by
|
|
4
|
+
one gene, then another. In CSR (AnnData's default on disk) a single gene's values
|
|
5
|
+
are scattered across every row's slice, so one gene costs a read of essentially
|
|
6
|
+
the whole matrix. In CSC that gene is one contiguous run of `indptr[j]:indptr[j+1]`,
|
|
7
|
+
so it is a single ranged read.
|
|
8
|
+
|
|
9
|
+
The conversion is lossless and reversible: only the sparse layout changes, not
|
|
10
|
+
the values, and `scipy`/`anndata` read either layout transparently. It is
|
|
11
|
+
therefore safe to apply to a store that other tools also read.
|
|
12
|
+
|
|
13
|
+
Only the matrices themselves are rewritten. An earlier version re-serialised the
|
|
14
|
+
whole AnnData, which quietly re-encoded `obs` and `var` with the installed
|
|
15
|
+
AnnData's current conventions — turning the index into a `nullable-string-array`
|
|
16
|
+
group and sharding the arrays — so readers that had been fine with the store
|
|
17
|
+
suddenly could not find the variable names. Everything written here is therefore
|
|
18
|
+
scoped to the matrix being converted, and pinned to the older encodings.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import shutil
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Any, Iterator, Sequence
|
|
28
|
+
|
|
29
|
+
from .errors import WriterCommandError
|
|
30
|
+
from .provenance import package_version
|
|
31
|
+
from .store import (
|
|
32
|
+
drop_consolidated_metadata,
|
|
33
|
+
list_element_keys,
|
|
34
|
+
refresh_consolidated_metadata,
|
|
35
|
+
write_json,
|
|
36
|
+
zarr_format_of,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from anndata import AnnData
|
|
41
|
+
|
|
42
|
+
#: Matrix keys converted by default: `X` plus every entry of `layers`.
|
|
43
|
+
DEFAULT_MATRIX_KEYS = ("X", "layers")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class CscConversion:
|
|
48
|
+
store_path: Path
|
|
49
|
+
manifest_path: Path | None
|
|
50
|
+
manifest: dict[str, Any]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sparse_format(matrix: Any) -> str | None:
|
|
54
|
+
"""Return the scipy sparse format string ('csr', 'csc', …) or None if dense."""
|
|
55
|
+
return getattr(matrix, "format", None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_csc(matrix: Any, *, densify: bool = False) -> Any:
|
|
59
|
+
"""Return *matrix* in CSC layout.
|
|
60
|
+
|
|
61
|
+
Already-CSC input is returned unchanged. Dense input is only converted when
|
|
62
|
+
*densify* is set, because making a dense matrix sparse can multiply its size
|
|
63
|
+
rather than shrink it.
|
|
64
|
+
"""
|
|
65
|
+
import scipy.sparse as sp
|
|
66
|
+
|
|
67
|
+
fmt = _sparse_format(matrix)
|
|
68
|
+
if fmt == "csc":
|
|
69
|
+
return matrix
|
|
70
|
+
if fmt is not None:
|
|
71
|
+
return matrix.tocsc()
|
|
72
|
+
if densify:
|
|
73
|
+
return sp.csc_matrix(matrix)
|
|
74
|
+
return matrix
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def anndata_to_csc(
|
|
78
|
+
adata: "AnnData",
|
|
79
|
+
*,
|
|
80
|
+
layers: bool = True,
|
|
81
|
+
densify: bool = False,
|
|
82
|
+
) -> dict[str, str]:
|
|
83
|
+
"""Convert `adata.X` (and optionally `adata.layers`) to CSC in place.
|
|
84
|
+
|
|
85
|
+
Returns a per-matrix report of ``{key: "csr->csc" | "already-csc" | …}``.
|
|
86
|
+
"""
|
|
87
|
+
report: dict[str, str] = {}
|
|
88
|
+
|
|
89
|
+
before = _sparse_format(adata.X)
|
|
90
|
+
converted = to_csc(adata.X, densify=densify)
|
|
91
|
+
if converted is not adata.X:
|
|
92
|
+
adata.X = converted
|
|
93
|
+
report["X"] = _describe(before, _sparse_format(adata.X))
|
|
94
|
+
|
|
95
|
+
if layers:
|
|
96
|
+
for key in list(adata.layers.keys()):
|
|
97
|
+
# anndata >=0.12 exposes `X` as a `None`-keyed layer alias; converting
|
|
98
|
+
# it again would double-report (and rewrite) the matrix handled above.
|
|
99
|
+
if key is None:
|
|
100
|
+
continue
|
|
101
|
+
layer_before = _sparse_format(adata.layers[key])
|
|
102
|
+
layer_converted = to_csc(adata.layers[key], densify=densify)
|
|
103
|
+
if layer_converted is not adata.layers[key]:
|
|
104
|
+
adata.layers[key] = layer_converted
|
|
105
|
+
report[f"layers/{key}"] = _describe(
|
|
106
|
+
layer_before, _sparse_format(adata.layers[key])
|
|
107
|
+
)
|
|
108
|
+
return report
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _describe(before: str | None, after: str | None) -> str:
|
|
112
|
+
if before is None and after is None:
|
|
113
|
+
return "dense (unchanged)"
|
|
114
|
+
if before == after:
|
|
115
|
+
return f"already-{after}"
|
|
116
|
+
return f"{before or 'dense'}->{after}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
#: AnnData settings pinned while writing, so a matrix we rewrite is encoded the
|
|
120
|
+
#: way the rest of the store already is. Names absent from the installed AnnData
|
|
121
|
+
#: are skipped.
|
|
122
|
+
#:
|
|
123
|
+
#: `auto_shard_zarr_v3` wraps arrays in `sharding_indexed`, and
|
|
124
|
+
#: `allow_write_nullable_strings` stores a string column as a
|
|
125
|
+
#: `nullable-string-array` *group* of `values`/`mask` instead of a plain string
|
|
126
|
+
#: array. Both are newer AnnData defaults that older readers — including the
|
|
127
|
+
#: JS runtime this package targets — do not understand.
|
|
128
|
+
_PINNED_WRITE_SETTINGS: dict[str, Any] = {
|
|
129
|
+
"auto_shard_zarr_v3": False,
|
|
130
|
+
"allow_write_nullable_strings": False,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@contextmanager
|
|
135
|
+
def _anndata_write_settings(*, zarr_format: int) -> Iterator[None]:
|
|
136
|
+
import anndata as ad
|
|
137
|
+
|
|
138
|
+
settings = ad.settings
|
|
139
|
+
pinned = {**_PINNED_WRITE_SETTINGS, "zarr_write_format": zarr_format}
|
|
140
|
+
previous = {
|
|
141
|
+
name: getattr(settings, name) for name in pinned if hasattr(settings, name)
|
|
142
|
+
}
|
|
143
|
+
try:
|
|
144
|
+
for name, value in pinned.items():
|
|
145
|
+
if name in previous:
|
|
146
|
+
setattr(settings, name, value)
|
|
147
|
+
yield
|
|
148
|
+
finally:
|
|
149
|
+
for name, value in previous.items():
|
|
150
|
+
setattr(settings, name, value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _convert_group_matrix(group: Any, name: str, *, densify: bool) -> str:
|
|
154
|
+
"""Convert one matrix child of a zarr group in place, reporting what changed."""
|
|
155
|
+
from anndata.io import read_elem, write_elem
|
|
156
|
+
|
|
157
|
+
matrix = read_elem(group[name])
|
|
158
|
+
before = _sparse_format(matrix)
|
|
159
|
+
converted = to_csc(matrix, densify=densify)
|
|
160
|
+
if converted is not matrix:
|
|
161
|
+
write_elem(group, name, converted)
|
|
162
|
+
return _describe(before, _sparse_format(converted))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _convert_table_matrices(
|
|
166
|
+
table_path: Path, *, layers: bool, densify: bool
|
|
167
|
+
) -> dict[str, str]:
|
|
168
|
+
"""Rewrite a table's matrices in place, touching nothing else.
|
|
169
|
+
|
|
170
|
+
Only the matrix children are rewritten. Re-serialising the whole AnnData
|
|
171
|
+
would re-encode `obs`, `var`, `uns` and friends with whatever conventions the
|
|
172
|
+
installed AnnData currently prefers, silently changing parts of the store the
|
|
173
|
+
conversion has no business touching.
|
|
174
|
+
"""
|
|
175
|
+
import zarr
|
|
176
|
+
|
|
177
|
+
# Consolidated metadata makes the group read-only; the store root's copy is
|
|
178
|
+
# refreshed once at the end of the conversion instead.
|
|
179
|
+
root = zarr.open_group(str(table_path), mode="a", use_consolidated=False)
|
|
180
|
+
report: dict[str, str] = {}
|
|
181
|
+
|
|
182
|
+
with _anndata_write_settings(zarr_format=zarr_format_of(table_path)):
|
|
183
|
+
# `X` is optional in AnnData — a table may carry only `layers`, or only
|
|
184
|
+
# annotations. Converting what is there beats refusing the whole table.
|
|
185
|
+
if "X" in root:
|
|
186
|
+
report["X"] = _convert_group_matrix(root, "X", densify=densify)
|
|
187
|
+
else:
|
|
188
|
+
report["X"] = "absent"
|
|
189
|
+
if layers and "layers" in root:
|
|
190
|
+
layers_group = root["layers"]
|
|
191
|
+
for name in sorted(layers_group.keys()):
|
|
192
|
+
report[f"layers/{name}"] = _convert_group_matrix(
|
|
193
|
+
layers_group, name, densify=densify
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# The table group may carry its own consolidated listing, which now describes
|
|
197
|
+
# the pre-conversion encoding. Readers trust it over the files, so a matrix
|
|
198
|
+
# rewritten correctly would still fail to load.
|
|
199
|
+
drop_consolidated_metadata(table_path)
|
|
200
|
+
return report
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _table_dimensions(table_path: Path) -> tuple[int, int]:
|
|
204
|
+
"""Report `(n_obs, n_vars)`, or `(0, 0)` for a table with no `X`.
|
|
205
|
+
|
|
206
|
+
Only used for the manifest, so an absent matrix is worth reporting as zero
|
|
207
|
+
rather than failing a conversion that has nothing to do with dimensions.
|
|
208
|
+
"""
|
|
209
|
+
import zarr
|
|
210
|
+
|
|
211
|
+
root = zarr.open_group(str(table_path), mode="r", use_consolidated=False)
|
|
212
|
+
if "X" not in root:
|
|
213
|
+
return 0, 0
|
|
214
|
+
shape = root["X"].attrs.get("shape")
|
|
215
|
+
if shape is None: # dense X is a plain array
|
|
216
|
+
shape = root["X"].shape
|
|
217
|
+
return int(shape[0]), int(shape[1])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def convert_store_tables_to_csc(
|
|
221
|
+
source: str | Path,
|
|
222
|
+
dest: str | Path | None = None,
|
|
223
|
+
*,
|
|
224
|
+
tables: Sequence[str] | None = None,
|
|
225
|
+
layers: bool = True,
|
|
226
|
+
densify: bool = False,
|
|
227
|
+
overwrite: bool = False,
|
|
228
|
+
manifest: bool = True,
|
|
229
|
+
) -> CscConversion:
|
|
230
|
+
"""Rewrite a store's table matrices in CSC layout.
|
|
231
|
+
|
|
232
|
+
With *dest*, the store is copied first and the source left untouched; without
|
|
233
|
+
it, tables are rewritten in place. Pass *tables* to restrict which table
|
|
234
|
+
elements are converted (default: all of them).
|
|
235
|
+
"""
|
|
236
|
+
source_path = Path(source)
|
|
237
|
+
if not (source_path / "zarr.json").is_file():
|
|
238
|
+
raise WriterCommandError(f"Not a zarr store (no zarr.json): {source_path}")
|
|
239
|
+
|
|
240
|
+
if dest is None:
|
|
241
|
+
store_path = source_path
|
|
242
|
+
in_place = True
|
|
243
|
+
else:
|
|
244
|
+
store_path = Path(dest)
|
|
245
|
+
in_place = False
|
|
246
|
+
# The overwrite branch below deletes the destination before copying, so a
|
|
247
|
+
# destination that *is* the source — or contains it — would delete the
|
|
248
|
+
# store we are about to read. Checked against resolved paths so a symlink
|
|
249
|
+
# or `.` in the argument cannot slip past.
|
|
250
|
+
resolved_source = source_path.resolve()
|
|
251
|
+
resolved_dest = store_path.resolve()
|
|
252
|
+
if resolved_dest == resolved_source or resolved_dest in resolved_source.parents:
|
|
253
|
+
raise WriterCommandError(
|
|
254
|
+
f"Destination would delete the source store: {store_path}\n"
|
|
255
|
+
"Pass no destination to convert in place."
|
|
256
|
+
)
|
|
257
|
+
if store_path.exists():
|
|
258
|
+
if not overwrite:
|
|
259
|
+
raise WriterCommandError(
|
|
260
|
+
f"Destination already exists: {store_path}\nPass overwrite to replace it."
|
|
261
|
+
)
|
|
262
|
+
shutil.rmtree(store_path)
|
|
263
|
+
shutil.copytree(source_path, store_path)
|
|
264
|
+
|
|
265
|
+
available = list_element_keys(store_path, "tables")
|
|
266
|
+
if not available:
|
|
267
|
+
raise WriterCommandError(f"No table elements found under {store_path / 'tables'}")
|
|
268
|
+
|
|
269
|
+
selected = list(tables) if tables else available
|
|
270
|
+
unknown = [key for key in selected if key not in available]
|
|
271
|
+
if unknown:
|
|
272
|
+
raise WriterCommandError(
|
|
273
|
+
f"Unknown table element(s): {', '.join(unknown)}. Available: {', '.join(available)}"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
reports: list[dict[str, Any]] = []
|
|
277
|
+
for key in selected:
|
|
278
|
+
table_path = store_path / "tables" / key
|
|
279
|
+
n_obs, n_vars = _table_dimensions(table_path)
|
|
280
|
+
matrices = _convert_table_matrices(table_path, layers=layers, densify=densify)
|
|
281
|
+
reports.append(
|
|
282
|
+
{
|
|
283
|
+
"path": f"tables/{key}",
|
|
284
|
+
"n_obs": n_obs,
|
|
285
|
+
"n_vars": n_vars,
|
|
286
|
+
"zarr_format": zarr_format_of(table_path),
|
|
287
|
+
"matrices": matrices,
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
refresh_consolidated_metadata(store_path)
|
|
292
|
+
|
|
293
|
+
manifest_data = {
|
|
294
|
+
"format": "spatialdata-csc-tables/v1",
|
|
295
|
+
"source": str(source_path),
|
|
296
|
+
"output": str(store_path),
|
|
297
|
+
"in_place": in_place,
|
|
298
|
+
"tables": reports,
|
|
299
|
+
"packages": {
|
|
300
|
+
"anndata": package_version("anndata"),
|
|
301
|
+
"scipy": package_version("scipy"),
|
|
302
|
+
"spatialdata": package_version("spatialdata"),
|
|
303
|
+
"spatialdata-js-util": package_version("spatialdata-js-util"),
|
|
304
|
+
"zarr": package_version("zarr"),
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
manifest_path = store_path.with_suffix(".csc-manifest.json") if manifest else None
|
|
308
|
+
if manifest_path is not None:
|
|
309
|
+
write_json(manifest_path, manifest_data)
|
|
310
|
+
return CscConversion(store_path, manifest_path, manifest_data)
|
|
File without changes
|