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.
Files changed (40) hide show
  1. spatialdata_js_util/__init__.py +107 -0
  2. spatialdata_js_util/cli.py +803 -0
  3. spatialdata_js_util/codecs/__init__.py +67 -0
  4. spatialdata_js_util/codecs/backends.py +250 -0
  5. spatialdata_js_util/codecs/chunks.py +40 -0
  6. spatialdata_js_util/codecs/encoding.py +154 -0
  7. spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
  8. spatialdata_js_util/codecs/names.py +26 -0
  9. spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
  10. spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
  11. spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
  12. spatialdata_js_util/codecs/vendor/__init__.py +1 -0
  13. spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
  14. spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
  15. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
  16. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
  17. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
  18. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
  19. spatialdata_js_util/codecs/zarr_codec.py +149 -0
  20. spatialdata_js_util/errors.py +5 -0
  21. spatialdata_js_util/images.py +849 -0
  22. spatialdata_js_util/index_permutations.py +311 -0
  23. spatialdata_js_util/points.py +445 -0
  24. spatialdata_js_util/points_zarr.py +799 -0
  25. spatialdata_js_util/provenance.py +17 -0
  26. spatialdata_js_util/pyramids.py +318 -0
  27. spatialdata_js_util/runners.py +458 -0
  28. spatialdata_js_util/store.py +305 -0
  29. spatialdata_js_util/tables.py +310 -0
  30. spatialdata_js_util/tui/__init__.py +0 -0
  31. spatialdata_js_util/tui/app.py +112 -0
  32. spatialdata_js_util/tui/models.py +45 -0
  33. spatialdata_js_util/tui/screens.py +1255 -0
  34. spatialdata_js_util/tui/widgets.py +50 -0
  35. spatialdata_js_util/verify.py +338 -0
  36. spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
  37. spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
  38. spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
  39. spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
  40. 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