spatialscape 0.3.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 (58) hide show
  1. spatialscape/__init__.py +13 -0
  2. spatialscape/_app/assets/array-utils-flat-BL8Cekj1.js +13 -0
  3. spatialscape/_app/assets/blosc-DEOJs6D5.js +18 -0
  4. spatialscape/_app/assets/blosc-Df7TknHH.js +18 -0
  5. spatialscape/_app/assets/chunk-INHXZS53-DAOLzMPX.js +2 -0
  6. spatialscape/_app/assets/chunk-INHXZS53-DfBSTIxr.js +2 -0
  7. spatialscape/_app/assets/deflate-B2AbjNo_.js +2 -0
  8. spatialscape/_app/assets/expression-E359vKZW.js +368 -0
  9. spatialscape/_app/assets/get-attribute-from-layouts-Dd2gixpo.js +2 -0
  10. spatialscape/_app/assets/index-BKPnXJSt.css +1 -0
  11. spatialscape/_app/assets/index-Gxpj1e1n.js +8454 -0
  12. spatialscape/_app/assets/jpeg-b0MwBR_9.js +2 -0
  13. spatialscape/_app/assets/lerc-ujbbHc0C.js +2 -0
  14. spatialscape/_app/assets/lz4-BFJkNc3o.js +16 -0
  15. spatialscape/_app/assets/lz4-DNE3IovK.js +16 -0
  16. spatialscape/_app/assets/lzw-DfRkoFB0.js +2 -0
  17. spatialscape/_app/assets/packbits-BDCGuk_Z.js +2 -0
  18. spatialscape/_app/assets/pako.esm-Ch1S5qKo.js +2 -0
  19. spatialscape/_app/assets/raw-vNZ-QFXZ.js +2 -0
  20. spatialscape/_app/assets/webgl-D2Fkccwd.js +345 -0
  21. spatialscape/_app/assets/webimage-DDU0IC05.js +2 -0
  22. spatialscape/_app/assets/worker-CUy87eWB.js +8 -0
  23. spatialscape/_app/assets/zstd-CGD_7wnU.js +16 -0
  24. spatialscape/_app/assets/zstd-qXhZ_1DN.js +16 -0
  25. spatialscape/_app/favicon.svg +161 -0
  26. spatialscape/_app/index.html +23 -0
  27. spatialscape/build.py +630 -0
  28. spatialscape/cli.py +399 -0
  29. spatialscape/config.py +570 -0
  30. spatialscape/convert.py +321 -0
  31. spatialscape/discover.py +98 -0
  32. spatialscape/expression.py +300 -0
  33. spatialscape/fingerprint.py +152 -0
  34. spatialscape/genes.py +251 -0
  35. spatialscape/geometry.py +132 -0
  36. spatialscape/images.py +240 -0
  37. spatialscape/init.py +578 -0
  38. spatialscape/manifest.py +162 -0
  39. spatialscape/obs.py +48 -0
  40. spatialscape/outlines.py +145 -0
  41. spatialscape/polygons.py +85 -0
  42. spatialscape/rds.py +609 -0
  43. spatialscape/rdsread.py +556 -0
  44. spatialscape/readers.py +195 -0
  45. spatialscape/registry.py +83 -0
  46. spatialscape/serve.py +262 -0
  47. spatialscape/session.py +474 -0
  48. spatialscape/site.py +274 -0
  49. spatialscape/synthetic.py +142 -0
  50. spatialscape/thumbnail.py +95 -0
  51. spatialscape/tracks.py +551 -0
  52. spatialscape/validate.py +193 -0
  53. spatialscape/vocab.py +188 -0
  54. spatialscape-0.3.0.dist-info/METADATA +68 -0
  55. spatialscape-0.3.0.dist-info/RECORD +58 -0
  56. spatialscape-0.3.0.dist-info/WHEEL +4 -0
  57. spatialscape-0.3.0.dist-info/entry_points.txt +3 -0
  58. spatialscape-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,474 @@
1
+ """`spatialscape.show(...)`: look at an object from the session you are in, no site needed.
2
+
3
+ import spatialscape
4
+ s = spatialscape.show(adata, color="cell_type") # builds once, starts a local server, shows the viewer
5
+ cells = s.selection() # the cells inside the last lasso, as obs names
6
+ sub = adata[cells] # straight into a DE test
7
+
8
+ Accepts an AnnData in memory, a path (h5ad, zarr, SpatialData, .rds / .rda), a dataset yaml or a built bundle.
9
+ An in-memory object is built straight from memory into ~/.cache/spatialscape/show/<hash>, where the hash covers
10
+ the data and the settings, so showing the same object again is instant. One server thread per Python process
11
+ serves every bundle shown so far (the gallery lists them); the viewer posts each lasso selection back to it, and
12
+ `selection()` maps the selection onto the rows of your object.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import contextlib
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ import threading
24
+ import time
25
+ import urllib.request
26
+ import webbrowser
27
+ from collections.abc import Callable, Iterator
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+
34
+ Log = Callable[[str], None]
35
+ EMBEDDING_RE = re.compile(r"umap|tsne|t-sne|embedding|draw_graph|pca", re.I)
36
+
37
+ _SERVER: dict[str, Any] = {} # one per process: httpd, thread, url, root
38
+ _LOCK = threading.Lock()
39
+
40
+
41
+ def cache_root() -> Path:
42
+ from .genes import cache_dir
43
+
44
+ return cache_dir() / "show"
45
+
46
+
47
+ # ---------------------------------------------------------------- what to show
48
+ def _safe(name: str) -> str:
49
+ s = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(name)).strip("_")
50
+ return s or "sample"
51
+
52
+
53
+ def _hash_bytes(h, x) -> None:
54
+ if isinstance(x, np.ndarray):
55
+ h.update(np.ascontiguousarray(x).tobytes())
56
+ else:
57
+ h.update(str(x).encode())
58
+
59
+
60
+ def _hash_adata(adata, params: dict, *, obs_columns: list[str], obsm_key: str | None, layer: str) -> str:
61
+ """A digest of the data that matters for the viewer: names, the used obs columns, coordinates and a strided
62
+ sample of the matrix. Cheap enough to run on every call; strict enough that an edited object rebuilds."""
63
+ h = hashlib.sha1()
64
+ _hash_bytes(h, json.dumps(params, sort_keys=True, default=str))
65
+ _hash_bytes(h, adata.shape)
66
+ if getattr(adata, "isbacked", False): # on disk: the file identity stands in for the matrix
67
+ p = Path(str(adata.filename))
68
+ st = p.stat() if p.exists() else None
69
+ _hash_bytes(h, f"backed:{p}:{st.st_size if st else 0}:{st.st_mtime_ns if st else 0}")
70
+ _hash_bytes(h, pd.util.hash_pandas_object(pd.Series(np.asarray(adata.obs_names, dtype=str)), index=False).to_numpy())
71
+ _hash_bytes(h, pd.util.hash_pandas_object(pd.Series(np.asarray(adata.var_names, dtype=str)), index=False).to_numpy())
72
+ for col in obs_columns:
73
+ if col in adata.obs:
74
+ _hash_bytes(h, pd.util.hash_pandas_object(adata.obs[col].astype(str), index=False).to_numpy())
75
+ if obsm_key is not None and obsm_key in adata.obsm:
76
+ _hash_bytes(h, np.asarray(adata.obsm[obsm_key])[:, :2].astype(np.float64))
77
+ if getattr(adata, "isbacked", False):
78
+ return h.hexdigest()[:16]
79
+ X = adata.X if layer == "X" else adata.layers[layer]
80
+ if hasattr(X, "nnz") and hasattr(X, "data"):
81
+ _hash_bytes(h, X.nnz)
82
+ _hash_bytes(h, np.asarray(X.data[:: max(1, X.nnz // 200_000)]))
83
+ if hasattr(X, "indptr"):
84
+ _hash_bytes(h, np.asarray(X.indptr[:: max(1, len(X.indptr) // 50_000)]))
85
+ else: # dense (or array-like): a strided sample of rows, never the whole matrix
86
+ step = max(1, adata.n_obs // 500)
87
+ arr = np.asarray(X[::step])
88
+ _hash_bytes(h, arr.ravel()[:: max(1, arr.size // 200_000)])
89
+ return h.hexdigest()[:16]
90
+
91
+
92
+ def _coords_for(adata, coords: str | None) -> str:
93
+ if coords is not None:
94
+ return coords if coords.startswith(("obsm/", "obs/")) else f"obsm/{coords}"
95
+ if "spatial" in adata.obsm:
96
+ return "obsm/spatial"
97
+ emb = next((k for k in adata.obsm if EMBEDDING_RE.search(str(k))), None)
98
+ if emb is None:
99
+ raise ValueError(f"no coordinates found: pass coords='obsm/<key>' (obsm has {list(adata.obsm.keys())})")
100
+ return f"obsm/{emb}"
101
+
102
+
103
+ def _config_for_adata(adata, *, key: str, name: str, coords: str, platform: str | None, sample: str | None, fields, color: str | None, layer: str, normalized: str):
104
+ """A DatasetConfig for one in-memory object: `init`'s column heuristics, one sample per value of `sample`."""
105
+ from .config import DatasetConfig, ExpressionSpec, SampleSpec, SubsetSpec
106
+ from .discover import field_id
107
+ from .init import describe_columns, suggest_fields
108
+
109
+ obs = adata.obs
110
+ spatial = "spatial" in coords.lower()
111
+ if platform is None:
112
+ platform = ("visium" if "spatial" in (adata.uns or {}) else "other") if spatial else "snrnaseq"
113
+ fmap: Any
114
+ if fields == "auto":
115
+ fmap = "auto"
116
+ elif isinstance(fields, dict):
117
+ fmap = dict(fields)
118
+ else:
119
+ fmap = dict(suggest_fields(describe_columns(obs), skip={sample} if sample else None).mapped)
120
+ for col in fields or []:
121
+ if col not in obs:
122
+ raise KeyError(f"fields: obs column {col!r} not found")
123
+ fmap[field_id(col)] = col
124
+ default_color: dict | None = None
125
+ default_gene: str | None = None
126
+ if color is not None:
127
+ if color in obs.columns:
128
+ if fmap != "auto" and color not in fmap.values():
129
+ fmap[field_id(color)] = color
130
+ default_color = {"field": field_id(color) if fmap == "auto" else next(f for f, c in fmap.items() if c == color)}
131
+ elif color in adata.var_names:
132
+ default_gene = color
133
+ else:
134
+ raise KeyError(f"color={color!r} is neither an obs column nor a gene")
135
+ elif fmap != "auto" and fmap:
136
+ first_cat = next((f for f, c in fmap.items() if isinstance(c, str) and not pd.api.types.is_numeric_dtype(obs[c])), None)
137
+ if first_cat:
138
+ default_color = {"field": first_cat}
139
+ base: dict[str, Any] = {"platform": platform, "kind": "spatial" if spatial else "embedding", "path": Path(f"memory:{key}"), "coords": coords, "expression": ExpressionSpec(layer=layer, normalized=normalized), "fields": fmap, "images": []}
140
+ specs: list[SampleSpec] = []
141
+ rows: dict[str, np.ndarray] = {}
142
+ if sample is None:
143
+ specs.append(SampleSpec(id="all", name=name, **base))
144
+ rows["all"] = np.arange(adata.n_obs)
145
+ else:
146
+ if sample not in obs:
147
+ raise KeyError(f"sample={sample!r} is not an obs column")
148
+ values = obs[sample].astype(str).to_numpy()
149
+ for v in pd.unique(values):
150
+ sid = _safe(v)
151
+ specs.append(SampleSpec(id=sid, name=str(v), group=str(v), subset=SubsetSpec(column=sample, values=[str(v)]), **base))
152
+ rows[sid] = np.flatnonzero(values == v)
153
+ cfg = DatasetConfig(id=key, name=name, default_color=default_color, default_gene=default_gene, samples=specs)
154
+ return cfg, rows
155
+
156
+
157
+ # ---------------------------------------------------------------- one builder per bundle folder
158
+ def _alive(pid: int) -> bool:
159
+ if sys.platform == "win32": # os.kill(pid, 0) would terminate the process there; trust the lock's age instead
160
+ return True
161
+ try:
162
+ os.kill(pid, 0)
163
+ except ProcessLookupError:
164
+ return False
165
+ except PermissionError:
166
+ return True
167
+ return True
168
+
169
+
170
+ @contextlib.contextmanager
171
+ def _build_lock(out: Path, log: Log, *, timeout: float = 7200) -> Iterator[bool]:
172
+ """Two sessions showing the same object build the same folder; the second waits for the first instead of
173
+ writing into it. An O_EXCL lock file holds the owner's pid; a lock whose owner is gone (or, on Windows,
174
+ older than the timeout) is stale. Yields whether we waited, so the caller re-checks what exists."""
175
+ lock = out.with_name(out.name + ".lock")
176
+ lock.parent.mkdir(parents=True, exist_ok=True)
177
+ t0 = time.monotonic()
178
+ waited = False
179
+ while True:
180
+ try:
181
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
182
+ os.write(fd, str(os.getpid()).encode())
183
+ os.close(fd)
184
+ break
185
+ except FileExistsError:
186
+ try:
187
+ pid = int(lock.read_text().strip() or 0)
188
+ age = time.time() - lock.stat().st_mtime
189
+ except (OSError, ValueError):
190
+ pid, age = 0, 0.0
191
+ if (pid and not _alive(pid)) or age > timeout:
192
+ claim = lock.with_name(f"{lock.name}.stale{os.getpid()}")
193
+ try:
194
+ os.replace(lock, claim) # atomic: only one waiter wins the stale lock, the others see it gone and retry
195
+ claim.unlink(missing_ok=True)
196
+ except FileNotFoundError:
197
+ pass
198
+ continue
199
+ if time.monotonic() - t0 > timeout:
200
+ raise TimeoutError(f"another process has been building {out} for over {timeout:.0f} s; remove {lock} if it is stale") from None
201
+ if not waited:
202
+ log(f"another process is building this object (pid {pid or '?'}); waiting for it")
203
+ waited = True
204
+ time.sleep(0.5)
205
+ try:
206
+ yield waited
207
+ finally:
208
+ lock.unlink(missing_ok=True)
209
+
210
+
211
+ # ---------------------------------------------------------------- the server thread
212
+ def _ensure_server(root: Path, host: str = "127.0.0.1", port: int | None = None) -> str:
213
+ from .serve import make_server
214
+
215
+ with _LOCK:
216
+ cur = _SERVER
217
+ if cur and cur["root"] == root and cur["thread"].is_alive():
218
+ return cur["url"]
219
+ httpd = make_server(root, port=port or 0, host=host, session=True)
220
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True, name="spatialscape-show")
221
+ thread.start()
222
+ url = f"http://{host}:{httpd.server_address[1]}/"
223
+ _SERVER.update(httpd=httpd, thread=thread, url=url, root=root)
224
+ return url
225
+
226
+
227
+ def stop() -> None:
228
+ """Stop this process's viewer server (bundles stay in the cache)."""
229
+ with _LOCK:
230
+ if _SERVER:
231
+ _SERVER["httpd"].shutdown()
232
+ _SERVER.clear()
233
+
234
+
235
+ # ---------------------------------------------------------------- the session
236
+ class Session:
237
+ """A shown dataset: its URL, and the way back from a lasso to your rows."""
238
+
239
+ def __init__(self, *, url: str, api: str, key: str, bundle: Path, adata=None, rows: dict[str, np.ndarray] | None = None, height: int = 650):
240
+ self.url = url
241
+ self.api = api
242
+ self.key = key
243
+ self.bundle = Path(bundle)
244
+ self.adata = adata
245
+ self.rows = rows
246
+ self.height = height
247
+ self._orders: dict[str, np.ndarray] = {}
248
+ self._ids: dict[str, np.ndarray] = {}
249
+
250
+ def __repr__(self) -> str:
251
+ return f"spatialscape.Session({self.url})"
252
+
253
+ def _repr_html_(self) -> str:
254
+ return f'<iframe src="{self.url}" style="width:100%;height:{self.height}px;border:0;border-radius:8px;background:#0e1116" allow="clipboard-write"></iframe>'
255
+
256
+ def open(self) -> None:
257
+ """Open the viewer in the default browser (outside notebooks)."""
258
+ webbrowser.open(self.url)
259
+
260
+ def _obs(self, sid: str, name: str) -> np.ndarray:
261
+ import zarr
262
+
263
+ return np.asarray(zarr.open_group(str(self.bundle / "samples" / sid / "obs.zarr"), mode="r")[name][:])
264
+
265
+ def _ids_of(self, sid: str) -> np.ndarray:
266
+ """Cell ids in bundle order (stored as JSON blocks, the way the viewer reads them)."""
267
+ d = self.bundle / "samples" / sid / "ids"
268
+ blocks = sorted(d.glob("*.json"), key=lambda p: int(p.stem))
269
+ return np.asarray([i for b in blocks for i in json.loads(b.read_text())], dtype=str)
270
+
271
+ def raw_selection(self) -> dict:
272
+ """What the viewer last posted: {version, samples: {sample id: bundle-order indices}}."""
273
+ with urllib.request.urlopen(f"{self.api}/selection?d={self.key}", timeout=5) as r:
274
+ return json.loads(r.read() or b"{}")
275
+
276
+ def raw_regions(self) -> dict:
277
+ """The regions drawn in the viewer: {version, groups: [{name, color}], codes: {sample id: base64 uint16}}."""
278
+ with urllib.request.urlopen(f"{self.api}/regions?d={self.key}", timeout=5) as r:
279
+ return json.loads(r.read() or b"{}")
280
+
281
+ def save_regions(self, path) -> Path:
282
+ """Write the drawing (groups, colours, polygons in each section's coordinates) as JSON, the same file the
283
+ viewer's *save* button makes; load it again with load_regions() or `show(..., regions=path)`."""
284
+ doc = self.raw_regions()
285
+ out = Path(path).expanduser()
286
+ out.write_text(json.dumps({"spatialscape_regions": 1, "dataset": self.key, "groups": doc.get("groups") or []}))
287
+ return out
288
+
289
+ def load_regions(self, path) -> int:
290
+ """Hand a saved drawing to the session; the viewer picks it up when it (re)opens this dataset. Returns the
291
+ number of areas. Cell membership follows once the viewer has recomputed it, so call regions() after that."""
292
+ doc = json.loads(Path(path).expanduser().read_text())
293
+ groups = doc.get("groups") if isinstance(doc, dict) else None
294
+ if not isinstance(groups, list):
295
+ raise ValueError(f"{path}: not a regions file (expected {{groups: [...]}})")
296
+ body = json.dumps({"dataset": self.key, "version": 0, "groups": groups, "codes": {}}).encode()
297
+ req = urllib.request.Request(f"{self.api}/regions", data=body, headers={"Content-Type": "application/json"}, method="POST")
298
+ with urllib.request.urlopen(req, timeout=5):
299
+ pass
300
+ return sum(len(g.get("polygons") or []) for g in groups)
301
+
302
+ def regions(self) -> pd.Series | dict[str, str]:
303
+ """The region each cell was drawn into (the Regions panel of the viewer), as a pandas Series indexed by obs
304
+ names (None outside every region) for an in-memory object, or {id: region} for data shown from a path."""
305
+ doc = self.raw_regions()
306
+ names = [g["name"] for g in doc.get("groups") or []]
307
+ per_sample = {sid: np.frombuffer(base64.b64decode(b64), dtype="<u2") for sid, b64 in (doc.get("codes") or {}).items()}
308
+ if self.adata is not None and self.rows is not None:
309
+ out = pd.Series([None] * self.adata.n_obs, index=self.adata.obs_names, dtype=object, name="region")
310
+ for sid, codes in per_sample.items():
311
+ if sid not in self.rows:
312
+ continue
313
+ if sid not in self._orders:
314
+ self._orders[sid] = self._obs(sid, "order")
315
+ hit = np.flatnonzero(codes < len(names))
316
+ rows = self.rows[sid][self._orders[sid][hit]]
317
+ out.iloc[rows] = [names[c] for c in codes[hit]]
318
+ return out
319
+ result: dict[str, str] = {}
320
+ for sid, codes in per_sample.items():
321
+ if sid not in self._ids:
322
+ self._ids[sid] = self._ids_of(sid)
323
+ for i in np.flatnonzero(codes < len(names)):
324
+ result[str(self._ids[sid][i])] = names[codes[i]]
325
+ return result
326
+
327
+ def selection(self, *, as_mask: bool = False):
328
+ """The cells inside the last lasso. For an in-memory object: their obs names (a pandas Index you can index
329
+ the object with), or with `as_mask=True` a boolean array over its rows. For data shown from a path: the ids."""
330
+ doc = self.raw_selection()
331
+ per_sample = doc.get("samples") or {}
332
+ if self.adata is not None and self.rows is not None:
333
+ parts = []
334
+ for sid, idx in per_sample.items():
335
+ if sid not in self.rows or not idx:
336
+ continue
337
+ if sid not in self._orders:
338
+ self._orders[sid] = self._obs(sid, "order")
339
+ parts.append(self.rows[sid][self._orders[sid][np.asarray(idx, dtype=np.int64)]])
340
+ rows = np.sort(np.concatenate(parts)) if parts else np.zeros(0, dtype=np.int64)
341
+ if as_mask:
342
+ mask = np.zeros(self.adata.n_obs, dtype=bool)
343
+ mask[rows] = True
344
+ return mask
345
+ names = self.adata.obs_names[rows]
346
+ if names.has_duplicates or (len(rows) and not self.adata.obs_names.is_unique):
347
+ import warnings
348
+
349
+ warnings.warn("obs names are not unique across the object (barcodes repeat between samples); index with selection(as_mask=True) instead", stacklevel=2)
350
+ return names
351
+ names: list[str] = []
352
+ for sid, idx in per_sample.items():
353
+ if not idx:
354
+ continue
355
+ if sid not in self._ids:
356
+ self._ids[sid] = self._ids_of(sid)
357
+ names.extend(self._ids[sid][np.asarray(idx, dtype=np.int64)].tolist())
358
+ return names
359
+
360
+
361
+ # ---------------------------------------------------------------- show()
362
+ def show(
363
+ data,
364
+ *,
365
+ color: str | None = None,
366
+ coords: str | None = None,
367
+ sample: str | None = None,
368
+ platform: str | None = None,
369
+ fields: list[str] | dict[str, str] | str | None = None,
370
+ layer: str = "X",
371
+ normalized: str = "auto",
372
+ name: str | None = None,
373
+ height: int = 650,
374
+ port: int | None = None,
375
+ open_browser: bool = False,
376
+ regions=None,
377
+ verbose: bool = False,
378
+ log: Log = print,
379
+ ) -> Session:
380
+ """Show an object in the viewer and return a Session (displays inline in notebooks; `.url` elsewhere).
381
+
382
+ data: an AnnData, or a path (h5ad / zarr / SpatialData / .rds / .rda / dataset yaml / built bundle).
383
+ color: an obs column or a gene to color by at first. coords: obsm key (default: spatial, else the UMAP-like
384
+ one). sample: obs column that splits the object into one panel per value. fields: obs columns to expose
385
+ besides the ones picked automatically, a {field id: column} map, or "auto" for every usable column.
386
+ regions: a drawing saved by save_regions() or the viewer's *save* button, restored when the viewer opens.
387
+ """
388
+ from .build import build_dataset
389
+
390
+ lines: list[str] = []
391
+ blog: Log = log if verbose else lines.append
392
+ root = cache_root()
393
+ root.mkdir(parents=True, exist_ok=True)
394
+ t0 = time.perf_counter()
395
+ rows: dict[str, np.ndarray] | None = None
396
+ adata = None
397
+ if hasattr(data, "obs") and hasattr(data, "X"): # an AnnData
398
+ from . import readers
399
+
400
+ adata = data
401
+ coords = _coords_for(adata, coords)
402
+ params = {"coords": coords, "sample": sample, "platform": platform, "fields": fields, "color": color, "layer": layer, "normalized": normalized, "recipe": 2}
403
+ name = name or f"AnnData {adata.n_obs:,} cells x {adata.n_vars:,} features"
404
+ # the menu decides which obs columns are shown; they must be part of the hash, or re-clustering shows stale labels
405
+ cfg, rows = _config_for_adata(adata, key="pending", name=name, coords=coords, platform=platform, sample=sample, fields=fields, color=color, layer=layer, normalized=normalized)
406
+ used_cols = sorted({c for c in [sample, color] if c} | {c for sp in cfg.samples for c in (list(map(str, adata.obs.columns)) if sp.fields == "auto" else sp.fields.values())})
407
+ key = _hash_adata(adata, params, obs_columns=used_cols, obsm_key=coords[5:] if coords.startswith("obsm/") else None, layer=layer)
408
+ cfg.id = key
409
+ for sp in cfg.samples:
410
+ sp.path = Path(f"memory:{key}")
411
+ out = root / key
412
+ with _build_lock(out, log):
413
+ built = not (out / "manifest.json").exists()
414
+ if built:
415
+ readers.MEMORY[key] = adata
416
+ try:
417
+ build_dataset(cfg, out, log=blog)
418
+ finally:
419
+ readers.MEMORY.pop(key, None)
420
+ np.savez(out / "rows.npz", **rows)
421
+ else:
422
+ rows = dict(np.load(out / "rows.npz"))
423
+ else:
424
+ key, out, built = _show_path(Path(str(data)).expanduser(), root, color=color, coords=coords, sample=sample, platform=platform, log=blog)
425
+ url_root = _ensure_server(root, port=port)
426
+ url = f"{url_root}?d={key}"
427
+ if open_browser:
428
+ threading.Timer(0.4, lambda: webbrowser.open(url)).start()
429
+ what = "built" if built else "cached"
430
+ log(f"{what} in {time.perf_counter() - t0:.1f} s → {url}" + ("" if built else " (same data as before: no rebuild)"))
431
+ session = Session(url=url, api=f"{url_root}_api", key=key, bundle=out, adata=adata, rows=rows, height=height)
432
+ if regions is not None:
433
+ n = session.load_regions(regions)
434
+ log(f"regions: {n} area{'s' if n != 1 else ''} from {regions}, restored when the viewer opens")
435
+ return session
436
+
437
+
438
+ def _show_path(path: Path, root: Path, *, color, coords, sample, platform, log: Log) -> tuple[str, Path, bool]:
439
+ """A file, store, R object, dataset yaml or built bundle: `init`'s menu when needed, then an incremental build
440
+ keyed by the path, so a rebuilt file shows its new contents and an unchanged one is instant."""
441
+ from .build import build_bundle
442
+ from .config import load_config
443
+ from .init import write_menu
444
+
445
+ path = path.resolve()
446
+ if not path.exists():
447
+ raise FileNotFoundError(str(path))
448
+ if (path / "manifest.json").exists(): # a built bundle: link it into the served folder under its own name
449
+ key = _safe(path.name)
450
+ link = root / key
451
+ if link.is_symlink() or link.exists():
452
+ if link.resolve() != path:
453
+ link.unlink()
454
+ link.symlink_to(path, target_is_directory=True)
455
+ else:
456
+ link.symlink_to(path, target_is_directory=True)
457
+ return key, path, False
458
+ key = hashlib.sha1(str(path).encode()).hexdigest()[:12]
459
+ work = root / f"{key}.src"
460
+ work.mkdir(parents=True, exist_ok=True)
461
+ if path.suffix in (".yaml", ".yml"):
462
+ cfg = load_config(path)
463
+ else:
464
+ menu = work / "dataset.yaml"
465
+ if not menu.exists():
466
+ write_menu([str(path)], out=menu, dataset_id=key, name=path.name, platform=platform, log=log)
467
+ cfg = load_config(menu)
468
+ if color or coords or sample:
469
+ log("note: color/coords/sample apply to in-memory objects; for files edit the menu yaml the first call wrote: " + str(menu))
470
+ out = root / key
471
+ with _build_lock(out, log):
472
+ res = build_bundle(cfg, out, log=log)
473
+ built = any(a.build for a in res.plan.actions)
474
+ return key, out, built