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/cli.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""spatialscape command line.
|
|
2
|
+
|
|
3
|
+
Every command is also available as ``sscape <command>``. Help strings here are the source for the
|
|
4
|
+
generated CLI reference (``python/scripts/gen_cli_docs.py``), so keep them complete.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
help="Build, check and serve static spatialscape viewer bundles.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
rich_markup_mode="markdown",
|
|
17
|
+
pretty_exceptions_enable=False,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _version_callback(value: bool) -> None:
|
|
22
|
+
if value:
|
|
23
|
+
from importlib.metadata import version
|
|
24
|
+
|
|
25
|
+
typer.echo(f"spatialscape {version('spatialscape')}")
|
|
26
|
+
raise typer.Exit()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback()
|
|
30
|
+
def _root(
|
|
31
|
+
version: bool = typer.Option(False, "--version", "-V", help="Print the version and exit.", callback=_version_callback, is_eager=True),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Build, check and serve static spatialscape viewer bundles."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CONFIG_ARG = typer.Argument(..., help="Path to the dataset yaml. Relative paths inside it resolve against its own folder.", show_default=False)
|
|
37
|
+
OUT_OPT = typer.Option(None, "-o", "--out", help="Bundle directory. Default: `bundles/<id>` next to the yaml.", show_default=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def init(
|
|
42
|
+
paths: list[str] = typer.Argument(..., help="What to describe: R objects (`.rds` / `.rda` holding a SpatialExperiment or SingleCellExperiment) and/or globs of sample files such as `'data/*/adata.h5ad'` (quoted, so the shell does not expand them). One `samples:` entry per argument.", show_default=False),
|
|
43
|
+
out: Path | None = typer.Option(None, "-o", "--out", help="Where to write the yaml. Default: `<id>.yaml` in the current folder.", show_default=False),
|
|
44
|
+
dataset_id: str | None = typer.Option(None, "--id", help="Dataset id: used in `?d=<id>` and as the bundle folder name. Default: the object's file name, or the folder a glob starts in.", show_default=False),
|
|
45
|
+
name: str | None = typer.Option(None, "--name", help="Human-readable dataset name. Default: the id, prettified.", show_default=False),
|
|
46
|
+
platform: str | None = typer.Option(None, "--platform", help="Platform of the spatial samples: `visium`, `visium_hd`, `xenium`, `merfish` or `other`. Default: guessed from the columns (`array_row` / `array_col` mean Visium, or Visium HD above 20,000 observations; Xenium's cell metrics mean Xenium), else `visium`; the yaml says which. Objects and files without spatial coordinates become `snrnaseq` embeddings regardless.", show_default=False),
|
|
47
|
+
table: str | None = typer.Option(None, "--table", help="Table key inside a SpatialData store.", show_default=False),
|
|
48
|
+
):
|
|
49
|
+
"""Write a dataset yaml that doubles as a menu of what the data contains.
|
|
50
|
+
|
|
51
|
+
The yaml gets one `samples:` entry per argument. Inside it, `fields:` already maps the columns that look
|
|
52
|
+
like spatial domains, cell types and the usual QC metrics (total counts, detected genes, mito percent),
|
|
53
|
+
and every other column is listed as a comment with its type and levels, so showing one more annotation is
|
|
54
|
+
uncommenting a line. Optional settings (opening gene, palette, thumbnail, layout) are present as comments.
|
|
55
|
+
The file builds as written; edit it, then run `build`.
|
|
56
|
+
"""
|
|
57
|
+
from .init import write_menu
|
|
58
|
+
|
|
59
|
+
r = write_menu(paths, out=out, dataset_id=dataset_id, name=name, platform=platform, table=table, log=lambda m: typer.secho(m, dim=True))
|
|
60
|
+
typer.echo(f"wrote {r.out.resolve()}")
|
|
61
|
+
if r.samples:
|
|
62
|
+
typer.echo(f" {len(r.samples)} sample{'s' if len(r.samples) != 1 else ''}: {', '.join(r.samples[:8])}{', ...' if len(r.samples) > 8 else ''}")
|
|
63
|
+
typer.echo(f" annotations mapped: {', '.join(r.mapped) or 'none'}; {r.commented} more column{'s' if r.commented != 1 else ''} listed as comments")
|
|
64
|
+
for e in r.entries:
|
|
65
|
+
typer.echo(f" assumed for {e.source}:")
|
|
66
|
+
for line in e.assumed:
|
|
67
|
+
typer.echo(f" {line}")
|
|
68
|
+
typer.echo(f" next: edit {r.out.name} (names, colors, which annotations), then `spatialscape build {r.out}`")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def inspect(
|
|
73
|
+
path: Path = typer.Argument(..., help="An `.h5ad` file, an AnnData Zarr store, a SpatialData Zarr store, or an R `.rds` / `.rda` holding a SpatialExperiment / SingleCellExperiment.", show_default=False),
|
|
74
|
+
table: str | None = typer.Option(None, "--table", help="Table key inside a SpatialData store (required when the store has more than one)."),
|
|
75
|
+
):
|
|
76
|
+
"""Print what a sample file contains, to help edit the dataset yaml.
|
|
77
|
+
|
|
78
|
+
Lists every `obs` column with its type and number of levels (with example values for categoricals),
|
|
79
|
+
the `obsm` keys and their shapes, the layers, and any `uns` keys that look like scale information.
|
|
80
|
+
`init` writes the same information into a yaml you can edit; `inspect` only prints it.
|
|
81
|
+
"""
|
|
82
|
+
import numpy as np
|
|
83
|
+
|
|
84
|
+
if path.suffix.lower() in (".rds", ".rda", ".rdata"):
|
|
85
|
+
from .rds import read_experiment
|
|
86
|
+
|
|
87
|
+
x = read_experiment(path)
|
|
88
|
+
typer.echo(f"{path}: {x.class_name}, {x.n_obs} cells x {x.n_vars} genes; assays: {', '.join(x.assays)}")
|
|
89
|
+
if "sample_id" in x.obs:
|
|
90
|
+
counts = x.obs["sample_id"].astype(str).value_counts()
|
|
91
|
+
typer.echo(f"samples ({len(counts)}): " + ", ".join(f"{k} ({v})" for k, v in counts.items()))
|
|
92
|
+
typer.echo("colData columns:")
|
|
93
|
+
for c in x.obs.columns:
|
|
94
|
+
s = x.obs[c]
|
|
95
|
+
if isinstance(s.dtype, pd.CategoricalDtype) or s.dtype == object:
|
|
96
|
+
n = s.nunique(dropna=True)
|
|
97
|
+
typer.echo(f" {c:<40} categorical {n:>5} levels e.g. {list(map(str, s.dropna().unique()[:6]))}")
|
|
98
|
+
else:
|
|
99
|
+
v = pd.to_numeric(s, errors="coerce").to_numpy(dtype=float)
|
|
100
|
+
typer.echo(f" {c:<40} numeric range {np.nanmin(v):.3g}..{np.nanmax(v):.3g}")
|
|
101
|
+
typer.echo("spatialCoords: " + ("yes" if x.spatial is not None else "no (an snRNA-seq / embedding object)"))
|
|
102
|
+
typer.echo("reducedDims: " + ", ".join(f"{k}{v.shape}" for k, v in x.reduced_dims.items()))
|
|
103
|
+
typer.echo("images: " + (", ".join(f"{i.sample_id}/{i.image_id} (scale {i.scale_factor:.4g}, {'loaded' if i.rgb is not None else 'stored'})" for i in x.images) or "none"))
|
|
104
|
+
typer.echo("rowData columns: " + ", ".join(x.var.columns) + f"; rownames e.g. {', '.join(x.var.index[:3])}")
|
|
105
|
+
if x.assay_name:
|
|
106
|
+
typer.echo(f"Seurat assay drawn as expression: {x.assay_name} (`assay:` picks a layer: {', '.join(x.assays)})")
|
|
107
|
+
for fs in x.feature_sets:
|
|
108
|
+
typer.echo(f"feature set {fs.name}: {len(fs.var):,} features drawn as {fs.prefix!r}... (e.g. {fs.prefix}{fs.var.index[0]})")
|
|
109
|
+
if x.fragments:
|
|
110
|
+
typer.echo("fragment files recorded in the object (paths on the machine that built it):")
|
|
111
|
+
for f in x.fragments:
|
|
112
|
+
typer.echo(f" {f.path}" + (f" ({len(f.cells):,} cells)" if f.cells else ""))
|
|
113
|
+
for note in x.notes:
|
|
114
|
+
typer.echo(f"note: {note}")
|
|
115
|
+
typer.echo(f"next: spatialscape init {path} --id <dataset id>")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
from .readers import read_anndata
|
|
119
|
+
|
|
120
|
+
a = read_anndata(path, table)
|
|
121
|
+
typer.echo(f"{path}: {a.n_obs} obs x {a.n_vars} vars")
|
|
122
|
+
typer.echo("obs columns:")
|
|
123
|
+
for c in a.obs.columns:
|
|
124
|
+
s = a.obs[c]
|
|
125
|
+
if isinstance(s.dtype, object) and str(s.dtype) in ("category", "object", "string", "bool"):
|
|
126
|
+
n = s.nunique(dropna=True)
|
|
127
|
+
ex = list(map(str, s.dropna().unique()[:6]))
|
|
128
|
+
typer.echo(f" {c:<40} categorical {n:>5} levels e.g. {ex}")
|
|
129
|
+
else:
|
|
130
|
+
v = s.to_numpy()
|
|
131
|
+
typer.echo(f" {c:<40} numeric range {np.nanmin(v):.3g}..{np.nanmax(v):.3g}")
|
|
132
|
+
typer.echo("obsm: " + ", ".join(f"{k}{tuple(np.asarray(a.obsm[k]).shape)}" for k in a.obsm))
|
|
133
|
+
typer.echo("layers: " + ", ".join(k for k in a.layers if k))
|
|
134
|
+
hints = [k for k in a.uns if any(t in k.lower() for t in ("scale", "spacing", "unit", "spatial", "pixel"))]
|
|
135
|
+
typer.echo("uns scale hints: " + ", ".join(hints))
|
|
136
|
+
for k in hints:
|
|
137
|
+
v = a.uns[k]
|
|
138
|
+
if not isinstance(v, dict):
|
|
139
|
+
typer.echo(f" {k} = {v}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command()
|
|
143
|
+
def build(
|
|
144
|
+
config: Path = CONFIG_ARG,
|
|
145
|
+
out: Path | None = OUT_OPT,
|
|
146
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show which samples would be built, kept or removed, and why; write nothing."),
|
|
147
|
+
force: bool = typer.Option(False, "--force", help="Rebuild every sample even when nothing changed."),
|
|
148
|
+
no_shard: bool = typer.Option(False, "--no-shard", help="Write one file per gene instead of sharded Zarr. Larger file counts, but works on hosts without HTTP Range support (e.g. GitHub Pages)."),
|
|
149
|
+
quiet: bool = typer.Option(False, "-q", "--quiet", help="Suppress per-sample progress output."),
|
|
150
|
+
):
|
|
151
|
+
"""Build or update a bundle from the dataset yaml. Rerun it after any edit.
|
|
152
|
+
|
|
153
|
+
The first run converts R objects listed under `samples:` (kept in `bundles/.cache/<id>/` next to the
|
|
154
|
+
bundle), builds every sample and writes the dataset-level files. Later runs rebuild only the samples whose
|
|
155
|
+
source files, settings or field declarations changed (compared with `samples/<id>/build.json`), remove
|
|
156
|
+
samples the yaml no longer lists, and always rewrite `manifest.json`, `genes.json`, `features.json` and
|
|
157
|
+
the thumbnails, so renamed fields, palettes and defaults apply in seconds. Ends with the same checks as
|
|
158
|
+
`validate`; exits with status 1 if they report problems.
|
|
159
|
+
"""
|
|
160
|
+
from .build import build_bundle
|
|
161
|
+
from .config import load_config
|
|
162
|
+
|
|
163
|
+
cfg = load_config(config)
|
|
164
|
+
target = (out or cfg.default_bundle_dir).resolve()
|
|
165
|
+
log = (lambda *_: None) if quiet and not dry_run else typer.echo
|
|
166
|
+
log(f"{cfg.id} -> {target}")
|
|
167
|
+
if no_shard and cfg.sharded:
|
|
168
|
+
log("--no-shard given: set `sharded: false` in the yaml to keep it that way for `site build` and later builds")
|
|
169
|
+
res = build_bundle(cfg, target, sharded=False if no_shard else None, force=force, dry_run=dry_run, log=log)
|
|
170
|
+
if dry_run:
|
|
171
|
+
typer.echo("dry run: nothing written")
|
|
172
|
+
return
|
|
173
|
+
for p in res.problems:
|
|
174
|
+
typer.secho(f" ! {p}", fg="red")
|
|
175
|
+
m = res.manifest
|
|
176
|
+
n_built, n_kept, n_gone = len(res.plan.to_build), len(res.plan.reused), len(res.plan.removed)
|
|
177
|
+
parts = [f"{n_built} built"] + ([f"{n_kept} unchanged"] if n_kept else []) + ([f"{n_gone} removed"] if n_gone else [])
|
|
178
|
+
typer.echo(f"built {res.out}: {len(m.samples)} samples ({', '.join(parts)}), {sum(s.nObs for s in m.samples):,} obs")
|
|
179
|
+
typer.echo(f" next: spatialscape serve {res.out.parent} --open")
|
|
180
|
+
raise typer.Exit(1 if res.problems else 0)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command()
|
|
184
|
+
def outlines(
|
|
185
|
+
config: Path = CONFIG_ARG,
|
|
186
|
+
out: Path | None = OUT_OPT,
|
|
187
|
+
sample: list[str] = typer.Option(None, "--sample", help="Only these sample ids (repeatable). Default: every spatial sample."),
|
|
188
|
+
field: list[str] = typer.Option(None, "--field", help="Only these categorical field ids (repeatable). Default: every categorical field the sample carries."),
|
|
189
|
+
smooth_um: float = typer.Option(150.0, "--smooth-um", help="Side of the majority-vote smoothing window, in microns. Larger values merge small islands into their neighbours."),
|
|
190
|
+
min_feature_um: float = typer.Option(300.0, "--min-feature-um", help="Islands and holes smaller than this (per side, in microns) are removed before tracing."),
|
|
191
|
+
):
|
|
192
|
+
"""Re-trace annotation boundaries from the arrays already in a bundle.
|
|
193
|
+
|
|
194
|
+
`build` traces outlines with the default settings. This command redoes them with other sizes: labels are
|
|
195
|
+
rasterized at half the cell spacing, majority-smoothed, cleaned of small features, traced with marching
|
|
196
|
+
squares and simplified. Cells labelled `NA` are treated as unknown and filled by their neighbours. Results
|
|
197
|
+
go to `samples/<id>/outlines/<field>.json` and the manifest; no source data is read.
|
|
198
|
+
"""
|
|
199
|
+
from .build import add_outlines
|
|
200
|
+
from .config import load_config
|
|
201
|
+
|
|
202
|
+
cfg = load_config(config)
|
|
203
|
+
target = out or cfg.default_bundle_dir
|
|
204
|
+
m = add_outlines(cfg, target, sample_ids=sample or None, fields=field or None, smooth_um=smooth_um, min_feature_um=min_feature_um)
|
|
205
|
+
typer.echo(f"outlines written: {[(s.id, s.outlines) for s in m.samples if s.outlines]}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def thumbnails(
|
|
210
|
+
out: Path = typer.Argument(..., help="Bundle directory.", show_default=False),
|
|
211
|
+
hero: str | None = typer.Option(None, "--hero", help="Sample shown on the dataset thumbnail. Default: `thumbnail_sample` from the yaml, else the first spatial sample in layout order."),
|
|
212
|
+
no_per_sample: bool = typer.Option(False, "--no-per-sample", help="Skip the per-sample thumbnails."),
|
|
213
|
+
):
|
|
214
|
+
"""Re-render `thumbnail.png` for the dataset and for each sample.
|
|
215
|
+
|
|
216
|
+
One section is drawn from the bundle's own coordinates, colored by the default annotation
|
|
217
|
+
(or the first categorical field the sample carries). `build` runs this automatically.
|
|
218
|
+
"""
|
|
219
|
+
from .thumbnail import write_thumbnails
|
|
220
|
+
|
|
221
|
+
paths = write_thumbnails(out, per_sample=not no_per_sample, hero_id=hero, log=typer.echo)
|
|
222
|
+
typer.echo(f"wrote {len(paths)} thumbnail(s)")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.command()
|
|
226
|
+
def validate(target: str = typer.Argument(..., help="A bundle directory, or the `http(s)://` URL of a hosted bundle.", show_default=False)):
|
|
227
|
+
"""Check a bundle on disk or over HTTP.
|
|
228
|
+
|
|
229
|
+
Locally: the manifest parses, every array has the expected shape and gene-major chunking, categorical
|
|
230
|
+
codes fit their vocabularies, id blocks, polygon files and images are consistent. For a URL: the manifest
|
|
231
|
+
is fetched with CORS headers present, and one expression chunk is requested with a `Range` header expecting
|
|
232
|
+
`206 Partial Content`. Exits with status 1 when problems are found.
|
|
233
|
+
"""
|
|
234
|
+
from .validate import validate as _validate
|
|
235
|
+
|
|
236
|
+
problems = _validate(target)
|
|
237
|
+
for p in problems:
|
|
238
|
+
typer.secho(f" ! {p}", fg="red")
|
|
239
|
+
if problems:
|
|
240
|
+
raise typer.Exit(1)
|
|
241
|
+
typer.secho("ok", fg="green")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.command()
|
|
245
|
+
def serve(
|
|
246
|
+
directory: Path = typer.Argument(".", help="Folder of bundles (each reachable at `/<folder-name>`), a single bundle, or a site written by `site build`."),
|
|
247
|
+
port: int | None = typer.Option(None, "--port", help="TCP port. Default 8787, or the next free port when that one is busy."),
|
|
248
|
+
host: str = typer.Option("127.0.0.1", "--host", help="Interface to bind. Use `0.0.0.0` to reach the server from other machines."),
|
|
249
|
+
open_browser: bool = typer.Option(False, "--open", help="Open the viewer in the default browser once the server is up."),
|
|
250
|
+
):
|
|
251
|
+
"""Open your bundles in the viewer locally: one origin for the app and the data.
|
|
252
|
+
|
|
253
|
+
Serves the viewer that ships with this package at `/`, a `datasets.json` generated from every bundle in
|
|
254
|
+
the folder (so the landing gallery lists them all), and the bundles themselves with CORS and HTTP Range
|
|
255
|
+
headers. A folder that already contains `index.html` (the output of `site build`) is served as-is.
|
|
256
|
+
A development server, not meant for production traffic.
|
|
257
|
+
"""
|
|
258
|
+
from .serve import serve as _serve
|
|
259
|
+
|
|
260
|
+
_serve(directory.resolve(), port=port or 8787, host=host, open_browser=open_browser, port_chosen=port is not None)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
site_app = typer.Typer(help="The gallery: describe it in `site.yaml`, build it into a folder you can upload.", no_args_is_help=True)
|
|
264
|
+
app.add_typer(site_app, name="site")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@site_app.command("init")
|
|
268
|
+
def site_init(
|
|
269
|
+
items: list[Path] = typer.Argument(None, help="Dataset yamls and/or built bundle folders to list. Default: every `*.yaml` next to the output that looks like a dataset yaml, plus `bundles/*` there.", show_default=False),
|
|
270
|
+
out: Path = typer.Option("site.yaml", "-o", "--out", help="Where to write the site yaml."),
|
|
271
|
+
title: str | None = typer.Option(None, "--title", help="Gallery title.", show_default=False),
|
|
272
|
+
):
|
|
273
|
+
"""Write a starter `site.yaml` listing your datasets.
|
|
274
|
+
|
|
275
|
+
Only needed for a gallery of several datasets, a title and intro, a navigation bar, paper links, tags or
|
|
276
|
+
"coming soon" cards; a single dataset can be served and published straight from its bundle. The optional
|
|
277
|
+
settings are present as comments.
|
|
278
|
+
"""
|
|
279
|
+
from .site import write_site_template
|
|
280
|
+
|
|
281
|
+
out = out.expanduser()
|
|
282
|
+
found = list(items or [])
|
|
283
|
+
if not found:
|
|
284
|
+
base = out.parent
|
|
285
|
+
listed: set[str] = set()
|
|
286
|
+
for y in sorted(base.glob("*.yaml")):
|
|
287
|
+
did = _dataset_id_of(y) if y.resolve() != out.resolve() else None
|
|
288
|
+
if did:
|
|
289
|
+
found.append(y)
|
|
290
|
+
listed.add(did)
|
|
291
|
+
found += [m.parent for m in sorted((base / "bundles").glob("*/manifest.json")) if m.parent.name not in listed]
|
|
292
|
+
if not found:
|
|
293
|
+
raise ValueError("nothing to list: pass the dataset yamls or bundle folders to include, e.g. `spatialscape site init amygdala.yaml dlpfc.yaml`")
|
|
294
|
+
p = write_site_template(found, out, title=title)
|
|
295
|
+
typer.echo(f"wrote {p.resolve()} ({len(found)} dataset(s))\n next: edit it, then `spatialscape site build {p}`")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _dataset_id_of(y: Path) -> str | None:
|
|
299
|
+
"""The id of a dataset yaml, or None for anything else (a site.yaml, an unrelated file)."""
|
|
300
|
+
import yaml
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
doc = yaml.safe_load(y.read_text())
|
|
304
|
+
except Exception:
|
|
305
|
+
return None
|
|
306
|
+
return str(doc["id"]) if isinstance(doc, dict) and "samples" in doc and "id" in doc else None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@site_app.command("build")
|
|
310
|
+
def site_build(
|
|
311
|
+
target: Path = typer.Argument(..., help="`site.yaml`, or a folder of built bundles for a gallery without any settings.", show_default=False),
|
|
312
|
+
out: Path | None = typer.Option(None, "-o", "--out", help="Site directory to write. Default: `site/` next to the target.", show_default=False),
|
|
313
|
+
link: bool = typer.Option(False, "--link", help="Symlink bundle folders into the site instead of copying them (local previews of large data)."),
|
|
314
|
+
):
|
|
315
|
+
"""Write a folder you can upload to any static host.
|
|
316
|
+
|
|
317
|
+
From `site.yaml`: builds every dataset listed under `config:` whose data or yaml changed, copies the
|
|
318
|
+
viewer into `OUT`, places every bundle at `OUT/<id>/` (or points at `data_url`), and writes `OUT/datasets.json`
|
|
319
|
+
from the yaml and the bundles' manifests: names, descriptions, platforms, counts, thumbnails, paper links,
|
|
320
|
+
tags and placeholder cards. From a folder of bundles: the same without any settings. Preview with
|
|
321
|
+
`spatialscape serve OUT`.
|
|
322
|
+
"""
|
|
323
|
+
from .site import build_site, build_site_from_config
|
|
324
|
+
|
|
325
|
+
target = target.expanduser()
|
|
326
|
+
if target.is_file():
|
|
327
|
+
out_dir = build_site_from_config(target, out, link=link, log=typer.echo)
|
|
328
|
+
elif target.is_dir():
|
|
329
|
+
out_dir = build_site(out or target.resolve().parent / "site", [target], link=link, log=typer.echo)
|
|
330
|
+
else:
|
|
331
|
+
raise FileNotFoundError(str(target))
|
|
332
|
+
typer.echo(f"site ready: {out_dir}\n preview: spatialscape serve {out_dir}\n deploy: upload the folder to GitHub Pages, Cloudflare Pages, Netlify, S3 + CloudFront, or any static host")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@app.command()
|
|
336
|
+
def synth(
|
|
337
|
+
out: Path = typer.Argument(..., help="Directory for the generated inputs.", show_default=False),
|
|
338
|
+
cells: int = typer.Option(2000, "--cells", help="Cells in the first synthetic section (the second has 40% more)."),
|
|
339
|
+
genes: int = typer.Option(50, "--genes", help="Number of synthetic genes."),
|
|
340
|
+
):
|
|
341
|
+
"""Write a tiny synthetic dataset for tests and demos.
|
|
342
|
+
|
|
343
|
+
Produces two fake tissue sections (one log-normalized, one as counts with a flip and rotation), PNG images,
|
|
344
|
+
cell polygons, an embedding sample, and a `dataset.yaml` that ties them together.
|
|
345
|
+
"""
|
|
346
|
+
from .synthetic import make_synthetic
|
|
347
|
+
|
|
348
|
+
cfg = make_synthetic(out, cells, genes)
|
|
349
|
+
typer.echo(f"wrote {cfg}\n next: spatialscape build {cfg}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _describe(e: BaseException) -> str:
|
|
353
|
+
"""One readable message per error class; the traceback stays behind SPATIALSCAPE_DEBUG=1."""
|
|
354
|
+
from pydantic import ValidationError
|
|
355
|
+
from yaml import YAMLError
|
|
356
|
+
|
|
357
|
+
if isinstance(e, ValidationError):
|
|
358
|
+
lines = [f"{'.'.join(str(x) for x in err['loc']) or '<root>'}: {err['msg']}" for err in e.errors()]
|
|
359
|
+
return "the yaml is not valid:\n " + "\n ".join(lines)
|
|
360
|
+
if isinstance(e, YAMLError):
|
|
361
|
+
return f"the yaml could not be parsed: {e}"
|
|
362
|
+
if isinstance(e, FileNotFoundError):
|
|
363
|
+
return f"file not found: {e.filename or e}"
|
|
364
|
+
if isinstance(e, KeyError) and e.args:
|
|
365
|
+
return str(e.args[0])
|
|
366
|
+
if isinstance(e, (ValueError, OSError)):
|
|
367
|
+
return str(e)
|
|
368
|
+
return f"{type(e).__name__}: {e}"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def main(argv: list[str] | None = None) -> None:
|
|
372
|
+
"""Console entry point: run the Typer app and turn expected failures into one-line errors."""
|
|
373
|
+
import os
|
|
374
|
+
import sys
|
|
375
|
+
|
|
376
|
+
rv = None
|
|
377
|
+
try:
|
|
378
|
+
rv = app(args=argv, standalone_mode=False, prog_name="spatialscape")
|
|
379
|
+
except typer.Exit as e:
|
|
380
|
+
sys.exit(e.exit_code)
|
|
381
|
+
except typer.Abort:
|
|
382
|
+
typer.secho("aborted", fg="red", err=True)
|
|
383
|
+
sys.exit(130)
|
|
384
|
+
except Exception as e:
|
|
385
|
+
# usage errors (Typer ships its own click, so match by shape rather than class)
|
|
386
|
+
if hasattr(e, "show") and hasattr(e, "exit_code"):
|
|
387
|
+
e.show() # type: ignore[attr-defined]
|
|
388
|
+
sys.exit(e.exit_code) # type: ignore[attr-defined]
|
|
389
|
+
if os.environ.get("SPATIALSCAPE_DEBUG"):
|
|
390
|
+
raise
|
|
391
|
+
typer.secho(f"error: {_describe(e)}", fg="red", err=True)
|
|
392
|
+
typer.secho("set SPATIALSCAPE_DEBUG=1 for the full traceback", dim=True, err=True)
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
if isinstance(rv, int) and rv: # outside standalone mode Typer returns a command's exit status instead of raising it
|
|
395
|
+
sys.exit(rv)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
main()
|