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.
- spatialscape/__init__.py +13 -0
- spatialscape/_app/assets/array-utils-flat-BL8Cekj1.js +13 -0
- spatialscape/_app/assets/blosc-DEOJs6D5.js +18 -0
- spatialscape/_app/assets/blosc-Df7TknHH.js +18 -0
- spatialscape/_app/assets/chunk-INHXZS53-DAOLzMPX.js +2 -0
- spatialscape/_app/assets/chunk-INHXZS53-DfBSTIxr.js +2 -0
- spatialscape/_app/assets/deflate-B2AbjNo_.js +2 -0
- spatialscape/_app/assets/expression-E359vKZW.js +368 -0
- spatialscape/_app/assets/get-attribute-from-layouts-Dd2gixpo.js +2 -0
- spatialscape/_app/assets/index-BKPnXJSt.css +1 -0
- spatialscape/_app/assets/index-Gxpj1e1n.js +8454 -0
- spatialscape/_app/assets/jpeg-b0MwBR_9.js +2 -0
- spatialscape/_app/assets/lerc-ujbbHc0C.js +2 -0
- spatialscape/_app/assets/lz4-BFJkNc3o.js +16 -0
- spatialscape/_app/assets/lz4-DNE3IovK.js +16 -0
- spatialscape/_app/assets/lzw-DfRkoFB0.js +2 -0
- spatialscape/_app/assets/packbits-BDCGuk_Z.js +2 -0
- spatialscape/_app/assets/pako.esm-Ch1S5qKo.js +2 -0
- spatialscape/_app/assets/raw-vNZ-QFXZ.js +2 -0
- spatialscape/_app/assets/webgl-D2Fkccwd.js +345 -0
- spatialscape/_app/assets/webimage-DDU0IC05.js +2 -0
- spatialscape/_app/assets/worker-CUy87eWB.js +8 -0
- spatialscape/_app/assets/zstd-CGD_7wnU.js +16 -0
- spatialscape/_app/assets/zstd-qXhZ_1DN.js +16 -0
- spatialscape/_app/favicon.svg +161 -0
- spatialscape/_app/index.html +23 -0
- spatialscape/build.py +630 -0
- spatialscape/cli.py +399 -0
- spatialscape/config.py +570 -0
- spatialscape/convert.py +321 -0
- spatialscape/discover.py +98 -0
- spatialscape/expression.py +300 -0
- spatialscape/fingerprint.py +152 -0
- spatialscape/genes.py +251 -0
- spatialscape/geometry.py +132 -0
- spatialscape/images.py +240 -0
- spatialscape/init.py +578 -0
- spatialscape/manifest.py +162 -0
- spatialscape/obs.py +48 -0
- spatialscape/outlines.py +145 -0
- spatialscape/polygons.py +85 -0
- spatialscape/rds.py +609 -0
- spatialscape/rdsread.py +556 -0
- spatialscape/readers.py +195 -0
- spatialscape/registry.py +83 -0
- spatialscape/serve.py +262 -0
- spatialscape/session.py +474 -0
- spatialscape/site.py +274 -0
- spatialscape/synthetic.py +142 -0
- spatialscape/thumbnail.py +95 -0
- spatialscape/tracks.py +551 -0
- spatialscape/validate.py +193 -0
- spatialscape/vocab.py +188 -0
- spatialscape-0.3.0.dist-info/METADATA +68 -0
- spatialscape-0.3.0.dist-info/RECORD +58 -0
- spatialscape-0.3.0.dist-info/WHEEL +4 -0
- spatialscape-0.3.0.dist-info/entry_points.txt +3 -0
- spatialscape-0.3.0.dist-info/licenses/LICENSE +21 -0
spatialscape/readers.py
ADDED
|
@@ -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)
|
spatialscape/registry.py
ADDED
|
@@ -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()
|