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,195 @@
1
+ """Read AnnData (h5ad / zarr v2 / zarr v3) and SpatialData tables into a uniform object."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import anndata as ad
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from .config import SampleSpec
13
+
14
+
15
+ @dataclass
16
+ class SampleInput:
17
+ adata: ad.AnnData
18
+ coords: np.ndarray # (n_obs, 2) float64 in source units
19
+ gene_names: np.ndarray # (n_vars,) str
20
+ name_column: str | None = None # var column the names came from (symbols for an Ensembl-indexed object)
21
+ uns: dict[str, Any] = field(default_factory=dict)
22
+
23
+
24
+ MEMORY: dict[str, ad.AnnData] = {} # objects handed to the build without a file (`spatialscape.show(adata)`); path "memory:<key>"
25
+
26
+
27
+ def read_anndata(path: Path, table: str | None = None, *, layer: str | None = None, obsm_keys: list[str] | None = None) -> ad.AnnData:
28
+ """An AnnData from an h5ad, an AnnData Zarr store or a SpatialData table. With `layer` given, only that matrix,
29
+ obs, var, uns and the `obsm_keys` are read: a processed object's other layers, graphs and embeddings stay on
30
+ disk instead of multiplying the build's memory."""
31
+ if str(path).startswith("memory:"):
32
+ key = str(path)[len("memory:") :]
33
+ if key not in MEMORY:
34
+ raise KeyError(f"in-memory object {key!r} is no longer registered; call spatialscape.show() again")
35
+ return MEMORY[key]
36
+ path = Path(path)
37
+ if path.is_file():
38
+ if path.suffix != ".h5ad":
39
+ raise ValueError(f"unsupported file type: {path}")
40
+ if layer is None:
41
+ return ad.read_h5ad(path)
42
+ import h5py
43
+
44
+ with h5py.File(path, "r") as f:
45
+ return _read_parts(f, layer, obsm_keys or [])
46
+ if not path.is_dir():
47
+ raise FileNotFoundError(path)
48
+ target = _table_dir(path, table) if (path / "tables").is_dir() else path # SpatialData store or AnnData Zarr
49
+ if layer is None:
50
+ return ad.read_zarr(target)
51
+ import zarr
52
+
53
+ return _read_parts(zarr.open_group(str(target), mode="r"), layer, obsm_keys or [])
54
+
55
+
56
+ def _read_parts(g, layer: str, obsm_keys: list[str]) -> ad.AnnData:
57
+ from anndata.io import read_elem
58
+
59
+ if layer == "X":
60
+ if "X" not in g:
61
+ raise KeyError("the object has no X")
62
+ X = read_elem(g["X"])
63
+ else:
64
+ if "layers" not in g or layer not in g["layers"]:
65
+ raise KeyError(f"layer {layer!r} not found; layers: {list(g['layers'].keys()) if 'layers' in g else []}")
66
+ X = read_elem(g["layers"][layer])
67
+ obs = read_elem(g["obs"])
68
+ var = read_elem(g["var"]) if "var" in g else pd.DataFrame(index=[str(i) for i in range(X.shape[1])])
69
+ keys = list(obsm_keys)
70
+ if "obsm" in g: # polygon stacks (n_obs, n_vertices, 2) are auto-detected by the build, so they come along
71
+ for k in g["obsm"]:
72
+ shape = getattr(g["obsm"][k], "shape", None)
73
+ if k not in keys and shape is not None and len(shape) == 3 and shape[-1] == 2:
74
+ keys.append(k)
75
+ obsm = {k: read_elem(g["obsm"][k]) for k in keys if "obsm" in g and k in g["obsm"]}
76
+ try:
77
+ uns = read_elem(g["uns"]) if "uns" in g else {}
78
+ except Exception: # an uns entry anndata cannot read must not stop a build; nothing here needs it but scale factors
79
+ uns = {}
80
+ return ad.AnnData(X=X, obs=obs, var=var, obsm=obsm, uns=uns)
81
+
82
+
83
+ def get_coords(adata: ad.AnnData, spec: str) -> np.ndarray:
84
+ if spec.startswith("obsm/"):
85
+ key = spec[len("obsm/") :]
86
+ if key not in adata.obsm:
87
+ raise KeyError(f"obsm['{key}'] not found; available: {list(adata.obsm.keys())}")
88
+ arr = adata.obsm[key]
89
+ arr = arr.to_numpy() if isinstance(arr, pd.DataFrame) else np.asarray(arr)
90
+ return np.asarray(arr[:, :2], dtype=np.float64)
91
+ if "," in spec:
92
+ xs, ys = (s.strip() for s in spec.split(",", 1))
93
+ cols = []
94
+ for s in (xs, ys):
95
+ if not s.startswith("obs/"):
96
+ raise ValueError(f"coords column spec must be obs/<col>, got {s}")
97
+ cols.append(np.asarray(adata.obs[s[4:]], dtype=np.float64))
98
+ return np.stack(cols, axis=1)
99
+ raise ValueError(f"unrecognised coords spec: {spec!r}")
100
+
101
+
102
+ SYMBOL_COLUMNS = ("gene_name", "gene_symbol", "symbol", "Symbol", "gene_symbols", "feature_name", "features")
103
+
104
+
105
+ def symbol_column(adata: ad.AnnData) -> str | None:
106
+ """The var column to name genes by when var_names are Ensembl ids (what people search for is the symbol)."""
107
+ idx = pd.Index(adata.var_names.astype(str))
108
+ head = idx[:2000]
109
+ if len(head) == 0 or head.str.match(r"^ENS[A-Z]*G\d+").mean() < 0.9:
110
+ return None
111
+ return next((c for c in SYMBOL_COLUMNS if c in adata.var.columns), None)
112
+
113
+
114
+ def gene_names(adata: ad.AnnData, column: str | None) -> np.ndarray:
115
+ """Feature names in var order: `column` when given, else the symbol column for Ensembl-indexed objects, else
116
+ var_names. A missing symbol keeps its id; duplicates get .1, .2 suffixes."""
117
+ column = column or symbol_column(adata)
118
+ ids = pd.Index(adata.var_names.astype(str))
119
+ if column:
120
+ col = adata.var[column].astype(object)
121
+ names = pd.Index(np.where(col.isna() | (col.astype(str).str.strip() == ""), ids, col.astype(str)).astype(str))
122
+ else:
123
+ names = ids
124
+ if names.has_duplicates:
125
+ names = ad.utils.make_index_unique(names, join=".")
126
+ return np.asarray(names, dtype=str)
127
+
128
+
129
+ def load_sample(spec: SampleSpec) -> SampleInput:
130
+ wanted = [spec.coords[len("obsm/") :]] if spec.coords.startswith("obsm/") else []
131
+ if spec.polygons is not None and spec.polygons.obsm:
132
+ wanted.append(spec.polygons.obsm)
133
+ adata = read_anndata(spec.path, spec.table, layer=spec.expression.layer, obsm_keys=wanted)
134
+ if spec.subset is not None:
135
+ col = spec.subset.column
136
+ if col not in adata.obs:
137
+ raise KeyError(f"sample {spec.id}: subset column {col!r} not found; obs columns: {', '.join(map(str, adata.obs.columns))}")
138
+ keep = adata.obs[col].astype(str).isin([str(v) for v in spec.subset.values]).to_numpy()
139
+ if not keep.any():
140
+ raise ValueError(f"sample {spec.id}: subset {col} in {spec.subset.values} matches no observations")
141
+ adata = adata[keep].copy()
142
+ coords = get_coords(adata, spec.coords)
143
+ if coords.shape[0] != adata.n_obs:
144
+ raise ValueError("coords length does not match n_obs")
145
+ uns = dict(adata.uns) if adata.uns is not None else {}
146
+ name_col = spec.gene_column or symbol_column(adata)
147
+ return SampleInput(adata=adata, coords=coords, gene_names=gene_names(adata, name_col), uns=uns, name_column=name_col)
148
+
149
+
150
+ @dataclass
151
+ class Peek:
152
+ """What `init` and `inspect` need from a sample file without reading its matrix."""
153
+
154
+ obs: pd.DataFrame
155
+ obsm: list[str]
156
+ layers: list[str]
157
+ n_vars: int
158
+
159
+
160
+ def _table_dir(path: Path, table: str | None) -> Path:
161
+ tables = path / "tables"
162
+ if table is None:
163
+ keys = sorted(p.name for p in tables.iterdir() if p.is_dir())
164
+ if len(keys) != 1:
165
+ raise ValueError(f"SpatialData store has tables {keys}; set `table:`")
166
+ table = keys[0]
167
+ return tables / table
168
+
169
+
170
+ def _peek_group(g, read_elem) -> Peek:
171
+ obs = read_elem(g["obs"])
172
+ obsm = list(g["obsm"].keys()) if "obsm" in g else []
173
+ layers = [k for k in (g["layers"].keys() if "layers" in g else []) if k]
174
+ n_vars = len(read_elem(g["var"])) if "var" in g else 0
175
+ return Peek(obs=obs, obsm=obsm, layers=layers, n_vars=n_vars)
176
+
177
+
178
+ def peek(path: Path, table: str | None = None) -> Peek:
179
+ """Read `obs` plus the `obsm` and layer keys of an h5ad / AnnData Zarr / SpatialData table; the matrix stays on disk."""
180
+ from anndata.io import read_elem
181
+
182
+ path = Path(path)
183
+ if path.is_file():
184
+ if path.suffix != ".h5ad":
185
+ raise ValueError(f"unsupported file type: {path}")
186
+ import h5py
187
+
188
+ with h5py.File(path, "r") as f:
189
+ return _peek_group(f, read_elem)
190
+ if not path.is_dir():
191
+ raise FileNotFoundError(path)
192
+ import zarr
193
+
194
+ target = _table_dir(path, table) if (path / "tables").is_dir() else path
195
+ return _peek_group(zarr.open_group(str(target), mode="r"), read_elem)
@@ -0,0 +1,83 @@
1
+ """Registry (datasets.json) entries derived from bundles on disk.
2
+
3
+ The viewer's landing gallery reads `datasets.json` next to its `index.html`. `spatialscape serve` generates
4
+ one on the fly for every bundle in the served folder; `spatialscape site build` writes one from `site.yaml`
5
+ (or, for a plain folder of bundles, from their manifests alone).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .manifest import Manifest
15
+
16
+
17
+ def find_bundles(root: Path) -> list[Path]:
18
+ """Bundle directories directly under `root` (those holding a manifest.json), plus `root` itself if it is one."""
19
+ root = Path(root)
20
+ if (root / "manifest.json").exists():
21
+ return [root]
22
+ return sorted(p for p in root.iterdir() if p.is_dir() and (p / "manifest.json").exists())
23
+
24
+
25
+ def bundle_entry(bundle: Path, url: str) -> dict[str, Any]:
26
+ """Registry entry for one bundle, read from its manifest and gene list."""
27
+ m = Manifest.model_validate_json((bundle / "manifest.json").read_text())
28
+ genes_path = bundle / "genes.json"
29
+ n_genes = len(json.loads(genes_path.read_text())) if genes_path.exists() else None
30
+ platforms: list[str] = []
31
+ for s in m.samples:
32
+ p = "snrnaseq" if s.kind == "embedding" else s.platform
33
+ if p not in platforms:
34
+ platforms.append(p)
35
+ entry: dict[str, Any] = {
36
+ "id": m.id,
37
+ "name": m.name,
38
+ "description": m.description or "",
39
+ "platforms": platforms,
40
+ "samples": len(m.samples),
41
+ "cells": sum(s.nObs for s in m.samples),
42
+ "url": url,
43
+ "status": "live",
44
+ }
45
+ if n_genes is not None:
46
+ entry["genes"] = n_genes
47
+ return entry
48
+
49
+
50
+ def placeholder_entry(dataset_id: str, name: str, description: str | None = None, platforms: list[str] | None = None) -> dict[str, Any]:
51
+ """A complete "coming soon" card: every key the gallery reads is present, so the viewer never sees a half entry."""
52
+ return {
53
+ "id": dataset_id,
54
+ "name": name,
55
+ "description": description or "",
56
+ "platforms": list(platforms or []),
57
+ "samples": 0,
58
+ "cells": 0,
59
+ "url": "",
60
+ "status": "coming soon",
61
+ }
62
+
63
+
64
+ def build_registry(bundles: list[Path], *, url_for: Any, title: str | None = None, intro: str | None = None) -> dict[str, Any]:
65
+ """A datasets.json document from bundles alone. `url_for(bundle_dir, manifest_id) -> str` decides where each is served."""
66
+ datasets = []
67
+ for b in bundles:
68
+ try:
69
+ m_id = json.loads((b / "manifest.json").read_text()).get("id") or b.name
70
+ datasets.append(bundle_entry(b, url_for(b, m_id)))
71
+ except Exception as e: # one stale or half-written bundle must not take the gallery down with it
72
+ print(f"registry: skipping {b}: {str(e).splitlines()[0][:160]}", file=sys.stderr)
73
+ doc: dict[str, Any] = {"title": title or "spatialscape"}
74
+ if intro is not None:
75
+ doc["intro"] = intro
76
+ doc["datasets"] = datasets
77
+ return doc
78
+
79
+
80
+ def packaged_app_dir() -> Path | None:
81
+ """The viewer build shipped inside the package, or None for a source checkout without one."""
82
+ d = Path(__file__).resolve().parent / "_app"
83
+ return d if (d / "index.html").exists() else None
spatialscape/serve.py ADDED
@@ -0,0 +1,262 @@
1
+ """Development server: the viewer app plus data bundles from one origin, with CORS and HTTP Range.
2
+
3
+ Layout served at http://host:port/
4
+ / the viewer (packaged with the CLI); if the served folder is itself a built site
5
+ (has index.html) it is served as-is instead
6
+ /datasets.json the folder's own registry if present, else one generated from the bundles found
7
+ /<bundle>/... each bundle directory in the served folder (range requests supported)
8
+ /assets/... the viewer's static assets
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import json
14
+ import os
15
+ import posixpath
16
+ import re
17
+ import threading
18
+ import time
19
+ import webbrowser
20
+ from functools import partial
21
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
22
+ from pathlib import Path
23
+
24
+ from .registry import build_registry, find_bundles, packaged_app_dir
25
+
26
+ RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)")
27
+
28
+
29
+ class RangeCORSHandler(SimpleHTTPRequestHandler):
30
+ app_dir: Path | None = None
31
+ data_dir: Path
32
+ session: bool = False # `spatialscape.show()`: answers /_api/ping and stores the viewer's lasso selections and regions
33
+ _selections: dict[str, dict] = {}
34
+ _regions: dict[str, dict] = {}
35
+ _registry_cache: tuple[float, bytes] | None = None
36
+ _registry_lock = threading.Lock()
37
+
38
+ def end_headers(self):
39
+ self.send_header("Access-Control-Allow-Origin", "*")
40
+ self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")
41
+ self.send_header("Access-Control-Allow-Headers", "Range, Content-Type")
42
+ self.send_header("Access-Control-Allow-Private-Network", "true")
43
+ self.send_header("Access-Control-Expose-Headers", "Content-Range, Content-Length, Accept-Ranges, ETag")
44
+ self.send_header("Accept-Ranges", "bytes")
45
+ self.send_header("Timing-Allow-Origin", "*")
46
+ self.send_header("Cache-Control", "no-cache")
47
+ super().end_headers()
48
+
49
+ def do_OPTIONS(self):
50
+ self.send_response(204)
51
+ self.end_headers()
52
+
53
+ def log_message(self, fmt, *args): # quieter
54
+ if os.environ.get("SSCAPE_SERVE_VERBOSE"):
55
+ super().log_message(fmt, *args)
56
+
57
+ # ---- routing: data folder first, then the packaged viewer
58
+ def list_directory(self, path):
59
+ """Only the root of a folder served without a viewer lists its bundles (there is nothing else to show
60
+ there); inside bundles nothing is browsed, they are addressed through manifest.json."""
61
+ if self.app_dir is None and Path(path).resolve() == self.data_dir.resolve():
62
+ return super().list_directory(path)
63
+ self.send_error(404, "no directory listings")
64
+ return None
65
+
66
+ def translate_path(self, path: str) -> str:
67
+ rel = posixpath.normpath(path.split("?", 1)[0].split("#", 1)[0])
68
+ data = Path(super().translate_path(rel)) # inside data_dir (SimpleHTTPRequestHandler blocks traversal)
69
+ is_root = rel.strip("/") == ""
70
+ if data.is_file() or (data.is_dir() and not is_root):
71
+ return str(data) # a bundle file, or a bundle folder listing
72
+ if self.app_dir is not None:
73
+ rel_clean = rel.lstrip("/") or "index.html"
74
+ candidate = (self.app_dir / rel_clean).resolve()
75
+ if candidate.is_relative_to(self.app_dir) and candidate.is_file():
76
+ return str(candidate) # the viewer at / and its assets
77
+ return str(data)
78
+
79
+ def do_GET(self):
80
+ if self._is_registry_request():
81
+ self._send_registry(head=False)
82
+ return
83
+ if self.session and self.path.startswith("/_api/"):
84
+ self._api_get()
85
+ return
86
+ super().do_GET()
87
+
88
+ def do_POST(self):
89
+ route = self.path.split("?", 1)[0]
90
+ if not (self.session and route in ("/_api/selection", "/_api/regions")):
91
+ self.send_error(405)
92
+ return
93
+ try:
94
+ n = int(self.headers.get("Content-Length") or 0)
95
+ doc = json.loads(self.rfile.read(n) or b"{}")
96
+ if not isinstance(doc, dict):
97
+ raise ValueError("body must be a JSON object")
98
+ key = str(doc.get("dataset") or "")
99
+ except (ValueError, TypeError):
100
+ self.send_error(400)
101
+ return
102
+ if route == "/_api/selection":
103
+ type(self)._selections[key] = {"version": doc.get("version", 0), "samples": doc.get("samples") or {}, "time": time.time()}
104
+ else:
105
+ type(self)._regions[key] = {"version": doc.get("version", 0), "groups": doc.get("groups") or [], "codes": doc.get("codes") or {}, "time": time.time()}
106
+ self.send_response(204)
107
+ self.end_headers()
108
+
109
+ def _api_get(self) -> None:
110
+ route, _, query = self.path.partition("?")
111
+ if route == "/_api/ping":
112
+ body = b'{"session": true}'
113
+ elif route in ("/_api/selection", "/_api/regions"):
114
+ key = dict(p.split("=", 1) for p in query.split("&") if "=" in p).get("d", "")
115
+ if route == "/_api/selection":
116
+ body = json.dumps(type(self)._selections.get(key) or {"version": 0, "samples": {}}).encode()
117
+ else:
118
+ body = json.dumps(type(self)._regions.get(key) or {"version": 0, "groups": [], "codes": {}}).encode()
119
+ else:
120
+ self.send_error(404)
121
+ return
122
+ self.send_response(200)
123
+ self.send_header("Content-Type", "application/json")
124
+ self.send_header("Content-Length", str(len(body)))
125
+ self.end_headers()
126
+ self.wfile.write(body)
127
+
128
+ def do_HEAD(self):
129
+ if self._is_registry_request():
130
+ self._send_registry(head=True)
131
+ return
132
+ super().do_HEAD()
133
+
134
+ def _is_registry_request(self) -> bool:
135
+ return self.path.split("?", 1)[0] == "/datasets.json" and not (self.data_dir / "datasets.json").exists()
136
+
137
+ def _send_registry(self, *, head: bool) -> None:
138
+ body = self._registry_bytes()
139
+ self.send_response(200)
140
+ self.send_header("Content-Type", "application/json")
141
+ self.send_header("Content-Length", str(len(body)))
142
+ self.end_headers()
143
+ if not head:
144
+ self.wfile.write(body)
145
+
146
+ def _registry_bytes(self) -> bytes:
147
+ cls = type(self)
148
+ with cls._registry_lock:
149
+ now = time.time()
150
+ if cls._registry_cache and now - cls._registry_cache[0] < 2.0:
151
+ return cls._registry_cache[1]
152
+ bundles = find_bundles(self.data_dir)
153
+ doc = build_registry(
154
+ bundles,
155
+ url_for=lambda b, _id: b.name if b != self.data_dir else ".",
156
+ title="spatialscape",
157
+ intro=f"Bundles in {self.data_dir.name or self.data_dir}/ on this machine. Open any of them, or paste another bundle URL below.",
158
+ )
159
+ if self.session:
160
+ doc["api"] = "/_api" # the viewer posts lasso selections and regions here (spatialscape.show)
161
+ body = json.dumps(doc).encode()
162
+ cls._registry_cache = (now, body)
163
+ return body
164
+
165
+ # ---- byte ranges
166
+ def send_head(self):
167
+ rng = self.headers.get("Range")
168
+ path = self.translate_path(self.path)
169
+ if not rng or os.path.isdir(path) or not os.path.exists(path):
170
+ return super().send_head()
171
+ m = RANGE_RE.match(rng)
172
+ if not m or "," in rng: # multiple ranges: answer with the whole file (200), which every client accepts
173
+ return super().send_head()
174
+ size = os.path.getsize(path)
175
+ start_s, end_s = m.groups()
176
+ if start_s == "" and end_s == "":
177
+ return super().send_head()
178
+ if start_s == "": # suffix range: last N bytes
179
+ n = int(end_s)
180
+ start, end = max(0, size - n), size - 1
181
+ else:
182
+ start = int(start_s)
183
+ end = int(end_s) if end_s else size - 1
184
+ end = min(end, size - 1)
185
+ if start >= size or end < start:
186
+ self.send_response(416)
187
+ self.send_header("Content-Range", f"bytes */{size}")
188
+ self.end_headers()
189
+ return None
190
+ f = open(path, "rb") # noqa: SIM115 - streamed and closed by the handler
191
+ f.seek(start)
192
+ self.send_response(206)
193
+ self.send_header("Content-Type", self.guess_type(path))
194
+ self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
195
+ self.send_header("Content-Length", str(end - start + 1))
196
+ self.end_headers()
197
+ return _LimitedFile(f, end - start + 1)
198
+
199
+
200
+ class _LimitedFile:
201
+ def __init__(self, f, n):
202
+ self.f, self.n = f, n
203
+
204
+ def read(self, k=-1):
205
+ if self.n <= 0:
206
+ return b""
207
+ k = self.n if k < 0 else min(k, self.n)
208
+ data = self.f.read(k)
209
+ self.n -= len(data)
210
+ return data
211
+
212
+ def close(self):
213
+ self.f.close()
214
+
215
+
216
+ def make_server(directory: Path, port: int = 8787, host: str = "127.0.0.1", app_dir: Path | None = None, session: bool = False) -> ThreadingHTTPServer:
217
+ """A ready-to-run server. `app_dir=None` means: use the packaged viewer unless `directory` is itself a built site.
218
+ `session=True` adds the /_api routes `spatialscape.show()` uses to read lasso selections back."""
219
+ directory = Path(directory).resolve()
220
+ if (directory / "index.html").exists():
221
+ app_dir = None # a built site: serve it as-is
222
+ elif app_dir is None:
223
+ app_dir = packaged_app_dir()
224
+ extensions = {**SimpleHTTPRequestHandler.extensions_map, ".json": "application/json", ".bw": "application/octet-stream", ".bed": "text/plain", "": "application/octet-stream"}
225
+ handler = type("Handler", (RangeCORSHandler,), {"app_dir": app_dir, "data_dir": directory, "session": session, "_selections": {}, "_regions": {}, "extensions_map": extensions})
226
+ factory = partial(handler, directory=str(directory))
227
+ return ThreadingHTTPServer((host, port), factory)
228
+
229
+
230
+ def _port_free(host: str, port: int) -> bool:
231
+ import socket
232
+
233
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
234
+ return sock.connect_ex((host, port)) != 0
235
+
236
+
237
+ def serve(directory: Path, port: int = 8787, host: str = "127.0.0.1", open_browser: bool = False, port_chosen: bool = False) -> None:
238
+ """Start the server. With the default port, a busy port is skipped to the next free one; an explicit `--port` is honoured or refused."""
239
+ if not _port_free(host, port):
240
+ if port_chosen:
241
+ raise OSError(f"port {port} is already in use; stop that server or pass another --port")
242
+ wanted = port
243
+ while not _port_free(host, port) and port < wanted + 20:
244
+ port += 1
245
+ if not _port_free(host, port):
246
+ raise OSError(f"ports {wanted}-{port} are all in use; pass --port")
247
+ print(f"port {wanted} is in use; using {port}", flush=True)
248
+ httpd = make_server(directory, port=port, host=host)
249
+ handler_cls = httpd.RequestHandlerClass.func # type: ignore[attr-defined]
250
+ url = f"http://{host}:{port}/"
251
+ directory = Path(directory).resolve()
252
+ n = len(find_bundles(directory))
253
+ if handler_cls.app_dir is not None:
254
+ print(f"viewer + {n} bundle(s) from {directory}\n open {url} (Ctrl-C stops the server)", flush=True)
255
+ elif (directory / "index.html").exists():
256
+ print(f"serving site {directory}\n open {url} (Ctrl-C stops the server)", flush=True)
257
+ else:
258
+ print(f"serving {n} bundle(s) from {directory} at {url} (no viewer in this install; open them in a hosted viewer with ?d=<url>)", flush=True)
259
+ if open_browser:
260
+ threading.Timer(0.6, lambda: webbrowser.open(url)).start()
261
+ with contextlib.suppress(KeyboardInterrupt):
262
+ httpd.serve_forever()