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
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()