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/config.py ADDED
@@ -0,0 +1,570 @@
1
+ """Input schema: the dataset.yaml a contributor writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob as _glob
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ import yaml
12
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator
13
+
14
+ from .manifest import LayoutMode, Platform, SampleKind
15
+
16
+
17
+ class _Model(BaseModel):
18
+ model_config = ConfigDict(extra="forbid")
19
+
20
+
21
+ class MicronsSpec(_Model):
22
+ """How to convert the coordinate units of `coords` into microns.
23
+
24
+ Exactly one of the explicit options may be set. With none set, `auto` is used:
25
+ xenium/merfish -> already microns; visium -> uns["spot_nn_spacing_level0_px"] or
26
+ uns["spatial"][...]["scalefactors"]; embedding -> rescaled to `extent`.
27
+ """
28
+
29
+ already_microns: bool = False
30
+ microns_per_unit: float | None = None
31
+ spot_diameter_fullres: float | None = None # Visium: 55 um spot diameter in coord units
32
+ spot_spacing: float | None = None # Visium: 100 um center-to-center in coord units
33
+ scalefactors_json: Path | None = None # SpaceRanger scalefactors_json.json (fullres coords)
34
+
35
+ @model_validator(mode="after")
36
+ def _one_of(self):
37
+ n = sum(
38
+ [
39
+ self.already_microns,
40
+ self.microns_per_unit is not None,
41
+ self.spot_diameter_fullres is not None,
42
+ self.spot_spacing is not None,
43
+ self.scalefactors_json is not None,
44
+ ]
45
+ )
46
+ if n > 1:
47
+ raise ValueError("microns: set at most one option")
48
+ return self
49
+
50
+
51
+ class TransformSpec(_Model):
52
+ flip: Literal["none", "x", "y"] = "none"
53
+ rotate: Literal[0, 90, 180, 270] = 0
54
+
55
+
56
+ class ExpressionSpec(_Model):
57
+ layer: str = "X"
58
+ normalized: Literal["auto", "lognorm", "counts"] = "auto"
59
+ keep_f16: bool = False
60
+
61
+
62
+ class ImageChannelSpec(_Model):
63
+ name: str
64
+ color: str = "#ffffff"
65
+ window: tuple[float, float] | None = None
66
+
67
+
68
+ class ImageSpec(_Model):
69
+ id: str = "image"
70
+ name: str = "Image"
71
+ path: Path
72
+ kind: Literal["rgb", "multichannel"] = "rgb"
73
+ # um per level-0 pixel; "auto" means the image shares the coordinate frame of `coords`
74
+ pixel_size: float | Literal["auto"] = "auto"
75
+ # image pixels per coordinate unit (e.g. tissue_hires_scalef when coords are fullres px)
76
+ pixels_per_unit: float = 1.0
77
+ translate: tuple[float, float] = (0.0, 0.0) # um, applied after everything else
78
+ channels: list[ImageChannelSpec] | None = None
79
+ default_opacity: float = 1.0
80
+ max_size: int | None = None # optionally downsample level 0 to at most this many px per side
81
+ crop: bool = True # keep only the part of the image around the points (3% margin); slide scans reach far past the capture area
82
+ mask: bool = False # blank the image beyond `mask_margin` of the sample's own points: for one slide image that several sections share
83
+ mask_margin: float = 150.0 # um
84
+
85
+
86
+ class PolygonSpec(_Model):
87
+ """Per-cell boundary polygons, from a long-format parquet file (`path`: one row per vertex) or from an
88
+ `obsm` array of shape (n_obs, n_vertices, 2) inside the object. Vertex coordinates share the frame of
89
+ `coords`; set `affine` when the boundary file is in a different frame (e.g. the object's coordinates were
90
+ transposed after segmentation)."""
91
+
92
+ path: Path | None = None
93
+ obsm: str | None = None
94
+ id_column: str = "cell_id"
95
+ x_column: str = "vertex_x"
96
+ y_column: str = "vertex_y"
97
+ max_vertices: int = 24
98
+ # (a, b, c, d, e, f): x' = a*x + b*y + c ; y' = d*x + e*y + f, applied to the raw vertices first.
99
+ # transpose = [0, 1, 0, 1, 0, 0]; anti-transpose with extents = [0, -1, Ymax, -1, 0, Xmax].
100
+ affine: tuple[float, float, float, float, float, float] | None = None
101
+
102
+ @model_validator(mode="after")
103
+ def _one_source(self):
104
+ if (self.path is None) == (self.obsm is None):
105
+ raise ValueError("polygons: set exactly one of `path` or `obsm`")
106
+ return self
107
+
108
+
109
+ def is_remote(p: object) -> bool:
110
+ """`ssh://host/path`: read over ssh at build time, nothing stored locally."""
111
+ return isinstance(p, str) and p.startswith("ssh://")
112
+
113
+
114
+ def _file_or_url(v: str | Path) -> Path | str:
115
+ return v if is_remote(v) else Path(v)
116
+
117
+
118
+ class BigWigsSpec(_Model):
119
+ """Tracks another tool exported (ArchR `getGroupBW`, Signac `ExportGroupBW`, deepTools `bamCoverage` per group):
120
+ one bigWig per category of each field, named by a path template with `{label}` or listed label by label."""
121
+
122
+ by: dict[str, str | dict[str, Path]] # field id -> "GroupBigWigs/{label}.bw" or {category label: path}
123
+ all: Path | None = None # a bigWig of every cell, when there is one
124
+
125
+ @model_validator(mode="after")
126
+ def _templates(self):
127
+ for f, v in self.by.items():
128
+ if isinstance(v, str) and "{label}" not in v:
129
+ raise ValueError(f"tracks.bigwigs.by.{f}: a path template needs `{{label}}` (or list the files per category label)")
130
+ return self
131
+
132
+
133
+ class TracksSpec(_Model):
134
+ """Genome tracks for an ATAC sample, shown by the viewer's genome panel: pseudobulk accessibility per category of
135
+ each field in `by`. Built from Cell Ranger style fragment files (insertions per million, binned; one file, or
136
+ one per run for an object pooled from several 10x runs) or copied from bigWigs another tool exported, plus the
137
+ sample's peaks and optional gene models and peak-gene links. Needs `pip install "spatialscape[tracks]"`."""
138
+
139
+ fragments: str | Path | dict[str, str | Path] | None = None # fragments.tsv.gz, or run id -> file for an object pooled from runs; a file may be `ssh://host/path` (streamed, never stored)
140
+ bigwigs: BigWigsSpec | None = None # tracks that already exist, instead of `fragments`
141
+ by: list[str] = Field(default_factory=list) # categorical field ids to aggregate by (with `bigwigs`: its fields)
142
+ genome: str = "hg38" # assembly id (hg38, mm10, ...): names the RefSeq gene models and the default chromosome sizes
143
+ bin: int | None = Field(default=None, ge=1) # bp per bin: 25 for fragments; read from the files for bigwigs
144
+ normalize: Literal["nfrags", "tss", "cells"] = "nfrags" # per group: per million insertions; per million insertions at gene TSSs (robust to background, ArchR's ReadsInTSS); per nucleus
145
+ tss_window: int = Field(default=50, ge=1) # bp either side of a TSS that count as "at the TSS" for `normalize: tss`
146
+ chrom_sizes: Path | None = None # two-column chrom\tsize file; default: the genome's primary chromosomes
147
+ sample_column: str | None = None # obs column naming the run each observation came from (required with several fragment files)
148
+ barcode_column: str | None = None # obs column holding the fragment-file barcode; default: the observation names, minus a run prefix or suffix
149
+ units: str | None = None # what the tracks measure, for the panel's scale: "insertions per million" for fragments, "signal" for bigwigs
150
+ genes: str | None = "refseq" # gene models: a GTF, UCSC genePred table or BED; `refseq` downloads UCSC's table for `genome` once; `none` for no gene track
151
+ links: Path | None = None # BEDPE of peak-gene links, drawn as arcs
152
+
153
+ @model_validator(mode="after")
154
+ def _one_source(self):
155
+ if (self.fragments is None) == (self.bigwigs is None):
156
+ raise ValueError("tracks: give either `fragments` or `bigwigs`")
157
+ if isinstance(self.fragments, dict):
158
+ if not self.fragments:
159
+ raise ValueError("tracks.fragments: no files")
160
+ if not self.sample_column:
161
+ raise ValueError("tracks: several fragment files need `sample_column`, the obs column naming each observation's run")
162
+ if self.bigwigs is not None:
163
+ extra = [f for f in self.by if f not in self.bigwigs.by]
164
+ if extra:
165
+ raise ValueError(f"tracks.by lists {extra} but `bigwigs.by` has no files for them")
166
+ if not self.by:
167
+ self.by = list(self.bigwigs.by)
168
+ elif not self.by:
169
+ raise ValueError("tracks: `by` names the categorical fields to aggregate by")
170
+ return self
171
+
172
+ @property
173
+ def genes_file(self) -> Path | None:
174
+ """The gene-model file, or None for the `refseq` / `none` keywords."""
175
+ return None if self.genes is None or self.genes in ("refseq", "none") else Path(self.genes)
176
+
177
+ @property
178
+ def fragment_files(self) -> list[tuple[str | None, Path | str]]:
179
+ """(run id, file) pairs; the run is None for a single file. A remote file stays an `ssh://host/path` string."""
180
+ if isinstance(self.fragments, dict):
181
+ return [(str(k), _file_or_url(v)) for k, v in self.fragments.items()]
182
+ return [(None, _file_or_url(self.fragments))] if self.fragments is not None else []
183
+
184
+ @property
185
+ def input_files(self) -> list[Path | str]:
186
+ """Every file the tracks are built from, for the build fingerprint: fragment files (remote ones as their
187
+ URL), bigWigs (a template is globbed), chromosome sizes, gene models and links."""
188
+ files: list[Path | str | None] = [p for _, p in self.fragment_files] + [self.chrom_sizes, self.genes_file, self.links]
189
+ if self.bigwigs is not None:
190
+ files.append(self.bigwigs.all)
191
+ for v in self.bigwigs.by.values():
192
+ if isinstance(v, dict):
193
+ files.extend(v.values())
194
+ else:
195
+ files.extend(sorted(_glob.glob(str(v).replace("{label}", "*"))))
196
+ return [f if is_remote(f) else Path(f) for f in files if f is not None]
197
+
198
+
199
+ class SubsetSpec(_Model):
200
+ """Keep only the observations whose `column` takes one of `values`, so one object can appear as several
201
+ samples (one panel per broad cell class, one per condition, ...)."""
202
+
203
+ column: str
204
+ values: list[str]
205
+
206
+
207
+ class FieldMap(_Model):
208
+ """Long form of a field mapping: `{column, scale}`. `scale` multiplies a numeric column (e.g. 100 to turn a
209
+ ratio into a percent) so one field can be shared by samples that store the same quantity in different units."""
210
+
211
+ column: str
212
+ scale: float = 1.0
213
+
214
+
215
+ class SampleSpec(_Model):
216
+ """One sample. In dataset.yaml an entry may instead carry `glob:` and templated strings
217
+ ("{name}", "{stem}", "{dir}", "{i}") that expand into one SampleSpec per match; `platforms.<platform>`
218
+ and `defaults` blocks fill in keys the entry omits."""
219
+
220
+ id: str
221
+ name: str | None = None
222
+ platform: Platform
223
+ kind: SampleKind = "spatial"
224
+ group: str | None = None
225
+ path: Path
226
+ table: str | None = None # SpatialData table key
227
+ coords: str = "obsm/spatial" # "obsm/<key>" or "obs/<xcol>,obs/<ycol>"
228
+ expression: ExpressionSpec = Field(default_factory=ExpressionSpec)
229
+ microns: MicronsSpec = Field(default_factory=MicronsSpec)
230
+ transform: TransformSpec = Field(default_factory=TransformSpec)
231
+ # field id -> obs column (or {column, scale}), or "auto" to expose every categorical (<= 200 levels) and
232
+ # QC-like numeric column. Long-form entries are normalized into `fields` + `field_scales`.
233
+ fields: dict[str, str] | Literal["auto"] = Field(default_factory=dict)
234
+ field_scales: dict[str, float] = Field(default_factory=dict)
235
+ # explicit list, or "auto" to pick up image.ome.zarr / *.ome.zarr / spatial/tissue_hires_image.png next to the data
236
+ images: list[ImageSpec] | Literal["auto"] = "auto"
237
+ polygons: PolygonSpec | None = None
238
+ subset: SubsetSpec | None = None # rows to keep; the default is every observation in the file
239
+ tracks: TracksSpec | None = None # genome tracks from a fragment file (ATAC)
240
+ extent: float = 5000.0 # embedding samples: rescale longest side to this many "um"
241
+ point_radius: float | None = None # um; default depends on platform
242
+ seed: int = 7
243
+ gene_column: str | None = None # var column holding symbols; default var_names
244
+
245
+ @model_validator(mode="before")
246
+ @classmethod
247
+ def _normalize_field_maps(cls, data):
248
+ if not isinstance(data, dict) or not isinstance(data.get("fields"), dict):
249
+ return data
250
+ fields: dict[str, str] = {}
251
+ scales: dict[str, float] = dict(data.get("field_scales") or {})
252
+ for fid, v in data["fields"].items():
253
+ if isinstance(v, dict):
254
+ fm = FieldMap.model_validate(v)
255
+ fields[fid] = fm.column
256
+ if fm.scale != 1.0:
257
+ scales[fid] = fm.scale
258
+ else:
259
+ fields[fid] = v
260
+ return {**data, "fields": fields, "field_scales": scales}
261
+
262
+
263
+ class FieldSpec(_Model):
264
+ id: str
265
+ name: str | None = None
266
+ type: Literal["categorical", "continuous"] = "categorical"
267
+ vocabulary: str | None = None # defaults to id
268
+ categories: list[str] | None = None # explicit order; others appended first-seen
269
+ aliases: dict[str, str] = Field(default_factory=dict)
270
+ palette_key: str | None = None # key inside the palette file; defaults to vocabulary
271
+ colormap: str | None = None
272
+ range: tuple[float, float] | None = None
273
+ description: str | None = None
274
+
275
+
276
+ class FeatureGroupSpec(_Model):
277
+ """Features whose var names match `pattern` are listed under their own tab instead of the gene list."""
278
+
279
+ id: str
280
+ name: str
281
+ pattern: str # regex searched in var names, e.g. "^RCTD: "
282
+ strip: str | None = None # regex removed from the label; defaults to pattern
283
+ units: str | None = None # e.g. "weight"
284
+
285
+
286
+ class LayoutSpec(_Model):
287
+ mode: LayoutMode = "grid"
288
+ order: list[str] | None = None
289
+ gutter_fraction: float = 0.1
290
+ columns: int | None = Field(default=None, ge=1) # grid: a fixed number of columns (default: fitted to the window)
291
+
292
+
293
+ class DefaultColorSpec(_Model):
294
+ gene: str | None = None
295
+ field: str | None = None
296
+
297
+ @model_validator(mode="after")
298
+ def _one(self):
299
+ if (self.gene is None) == (self.field is None):
300
+ raise ValueError("default_color: set exactly one of gene / field")
301
+ return self
302
+
303
+
304
+ R_SUFFIXES = (".rds", ".rda", ".rdata")
305
+ R_ENTRY_KEYS = ("sample_col", "assay", "embedding", "object")
306
+
307
+
308
+ def is_r_object(path: Any) -> bool:
309
+ return isinstance(path, (str, Path)) and str(path).lower().endswith(R_SUFFIXES)
310
+
311
+
312
+ class RObjectEntry(_Model):
313
+ """A `samples:` entry whose `path` is an R object (`.rds`, `.rda`, `.RData`) holding a SpatialExperiment,
314
+ SingleCellExperiment or Seurat object. `build` converts it once into a cache next to the bundle and expands it
315
+ into one sample per `sample_col` value (objects with spatialCoords) or one embedding sample (objects without).
316
+ Every other key of the entry (platform, templated id / name / group, fields, microns, ...) applies to those
317
+ samples. For a Seurat object `assay` names a layer of the gene assay (`data`, `counts`; `logcounts` means
318
+ `data`), and its other assays become prefixed features (`peak: `, `activity: `, `motif: `). `overrides:`
319
+ (`{<sample>: {...}}`, kept in `template`) changes single samples: `{ Br9902_1: { images: [] } }`."""
320
+
321
+ path: Path
322
+ sample_col: str = "sample_id"
323
+ assay: str = "logcounts"
324
+ embedding: str | None = None # reducedDims entry / Seurat reduction for objects without spatialCoords; default: a UMAP
325
+ object: str | None = None # which object to take from an .rda that holds several
326
+ template: dict[str, Any] = Field(default_factory=dict) # the rest of the entry, applied to the expanded samples
327
+ index: int = 0 # position in the yaml's `samples:` list
328
+
329
+
330
+ class DatasetConfig(_Model):
331
+ """Top-level dataset.yaml. `samples` are expanded (globs, templates, defaults) by `load_config`."""
332
+
333
+ id: str
334
+ name: str
335
+ description: str | None = None
336
+ default_gene: str | None = None
337
+ default_color: DefaultColorSpec | None = None
338
+ layout: LayoutSpec = Field(default_factory=LayoutSpec)
339
+ palette: Path | None = None
340
+ colormaps: list[str] | None = None
341
+ shard_genes: int = 512
342
+ sharded: bool = True # sharded Zarr (one range request per gene; needs HTTP Range) or one file per gene (GitHub Pages)
343
+ thumbnail_sample: str | None = None # sample shown on the dataset's gallery card (default: first in layout order)
344
+ feature_groups: list[FeatureGroupSpec] = Field(default_factory=list)
345
+ # Optional declarations (display names, explicit category order, aliases, palette keys). Fields that
346
+ # samples reference without a declaration are declared automatically at build time.
347
+ fields: list[FieldSpec] = Field(default_factory=list)
348
+ defaults: dict[str, Any] = Field(default_factory=dict) # merged into every sample entry
349
+ platforms: dict[str, dict[str, Any]] = Field(default_factory=dict) # merged into samples of that platform
350
+ samples: list[SampleSpec]
351
+
352
+ # set by load_config: where the yaml lives, its raw content (for re-expansion) and its R object entries
353
+ _path: Path | None = PrivateAttr(default=None)
354
+ _raw: dict | None = PrivateAttr(default=None)
355
+ _r_objects: list[RObjectEntry] = PrivateAttr(default_factory=list)
356
+
357
+ @property
358
+ def path(self) -> Path | None:
359
+ return self._path
360
+
361
+ @property
362
+ def raw(self) -> dict:
363
+ return self._raw or {}
364
+
365
+ @property
366
+ def r_objects(self) -> list[RObjectEntry]:
367
+ return self._r_objects
368
+
369
+ @property
370
+ def default_bundle_dir(self) -> Path:
371
+ """`bundles/<id>` next to the yaml: where `build` writes when `-o` is not given."""
372
+ if self._path is None:
373
+ raise ValueError("this config was not loaded from a file; pass an output directory")
374
+ return self._path.parent / "bundles" / self.id
375
+
376
+ @model_validator(mode="after")
377
+ def _unique_ids(self):
378
+ ids = [s.id for s in self.samples]
379
+ if len(set(ids)) != len(ids):
380
+ raise ValueError(f"sample ids must be unique: {sorted({i for i in ids if ids.count(i) > 1})}")
381
+ fids = [f.id for f in self.fields]
382
+ if len(set(fids)) != len(fids):
383
+ raise ValueError("field ids must be unique")
384
+ return self
385
+
386
+ def field_spec(self, fid: str) -> FieldSpec | None:
387
+ return next((f for f in self.fields if f.id == fid), None)
388
+
389
+
390
+ def _resolve_paths(cfg: DatasetConfig, base: Path) -> DatasetConfig:
391
+ """Paths are relative to the config file; `~` and `${ENV_VAR}` are expanded so configs stay portable."""
392
+
393
+ def res(p: Path | None) -> Path | None:
394
+ if p is None:
395
+ return None
396
+ p = Path(os.path.expandvars(str(p))).expanduser()
397
+ return p if p.is_absolute() else (base / p).resolve()
398
+
399
+ cfg.palette = res(cfg.palette)
400
+ for s in cfg.samples:
401
+ s.path = res(s.path)
402
+ s.microns.scalefactors_json = res(s.microns.scalefactors_json)
403
+ if isinstance(s.images, list):
404
+ for im in s.images:
405
+ im.path = res(im.path)
406
+ if s.polygons and s.polygons.path is not None:
407
+ s.polygons.path = res(s.polygons.path)
408
+ if s.tracks is not None:
409
+ t = s.tracks
410
+ if isinstance(t.fragments, dict):
411
+ t.fragments = {k: os.path.expandvars(v) if is_remote(v) else res(Path(v)) for k, v in t.fragments.items()}
412
+ elif is_remote(t.fragments):
413
+ t.fragments = os.path.expandvars(t.fragments) # ssh://${CLUSTER_USER}@host/... keeps logins out of the yaml
414
+ elif t.fragments is not None:
415
+ t.fragments = res(Path(t.fragments))
416
+ if t.bigwigs is not None:
417
+ t.bigwigs.all = res(t.bigwigs.all)
418
+ t.bigwigs.by = {
419
+ f: str(res(Path(v))) if isinstance(v, str) else {lab: res(pth) for lab, pth in v.items()} for f, v in t.bigwigs.by.items()
420
+ }
421
+ t.chrom_sizes = res(t.chrom_sizes)
422
+ if t.genes_file is not None:
423
+ t.genes = str(res(t.genes_file))
424
+ t.links = res(t.links)
425
+ return cfg
426
+
427
+
428
+ def _deep_merge(base: dict, over: dict) -> dict:
429
+ out = dict(base)
430
+ for k, v in over.items():
431
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
432
+ out[k] = _deep_merge(out[k], v)
433
+ else:
434
+ out[k] = v
435
+ return out
436
+
437
+
438
+ def _render(value: Any, vars_: dict[str, str]) -> Any:
439
+ if isinstance(value, str):
440
+ try:
441
+ return value.format(**vars_)
442
+ except (KeyError, IndexError, ValueError):
443
+ return value
444
+ if isinstance(value, list):
445
+ return [_render(v, vars_) for v in value]
446
+ if isinstance(value, dict):
447
+ return {k: _render(v, vars_) for k, v in value.items()}
448
+ return value
449
+
450
+
451
+ def _template_vars(p: Path, i: int) -> dict[str, str]:
452
+ stem = p.name
453
+ for suf in (".h5ad", ".zarr", ".h5"):
454
+ if stem.endswith(suf):
455
+ stem = stem[: -len(suf)]
456
+ return {
457
+ "path": str(p),
458
+ "dir": str(p.parent),
459
+ "name": p.parent.name if p.is_dir() or p.suffix else p.name,
460
+ "stem": stem,
461
+ "i": str(i),
462
+ }
463
+
464
+
465
+ def _merged_entry(entry: dict, raw: dict) -> dict:
466
+ """`defaults` < `platforms.<platform>` < the entry itself."""
467
+ defaults = raw.get("defaults") or {}
468
+ platforms = raw.get("platforms") or {}
469
+ merged = _deep_merge(defaults, {})
470
+ merged = _deep_merge(merged, platforms.get(entry.get("platform", merged.get("platform", "")), {}))
471
+ return _deep_merge(merged, entry)
472
+
473
+
474
+ def expand_samples(raw: dict, base: Path) -> list[dict]:
475
+ """Expand glob entries and apply `defaults` / `platforms` blocks. Returns plain dicts for SampleSpec.
476
+ Entries whose `path` is an R object are skipped here; `build` converts and expands them (see convert.py)."""
477
+ out: list[dict] = []
478
+ for entry in raw.get("samples") or []:
479
+ entry = dict(entry)
480
+ pattern = entry.pop("glob", None)
481
+ if pattern is None and is_r_object(entry.get("path")):
482
+ continue
483
+ overrides = _check_overrides(entry.pop("overrides", None))
484
+ if pattern is None and overrides:
485
+ raise ValueError("`overrides` goes with `glob` (or an R object): a single-file entry takes its settings directly")
486
+ if pattern is None:
487
+ matches = [None]
488
+ else:
489
+ pat = str(Path(os.path.expandvars(str(pattern))).expanduser())
490
+ if not Path(pat).is_absolute():
491
+ pat = str(base / pat)
492
+ matches = sorted(_glob.glob(pat, recursive=True))
493
+ if not matches:
494
+ raise ValueError(f"glob matched nothing: {pattern} (paths in dataset.yaml are relative to its folder, {base})")
495
+ names = [_template_vars(Path(m), i)["name"] for i, m in enumerate(matches) if m is not None]
496
+ flat = len(set(names)) < len(names) # samples/A.h5ad, samples/B.h5ad: the folder name is shared, the stem is the sample
497
+ used: set[str] = set()
498
+ for i, m in enumerate(matches):
499
+ e = dict(entry)
500
+ if m is not None:
501
+ e["path"] = m
502
+ vars_ = _template_vars(Path(m), i)
503
+ e = _render(e, vars_)
504
+ e.setdefault("id", re.sub(r"[^A-Za-z0-9_.-]+", "_", vars_["stem" if flat else "name"]))
505
+ # an override is keyed by the sample's `{name}` ({stem} for flat layouts) or by its id
506
+ key = next((k for k in (vars_["stem" if flat else "name"], vars_["name"], vars_["stem"], e["id"]) if k in overrides), None)
507
+ if key is not None:
508
+ used.add(key)
509
+ e = _deep_merge(e, overrides[key])
510
+ merged = _merged_entry(e, raw)
511
+ if m is not None:
512
+ merged = _render(merged, _template_vars(Path(m), i))
513
+ out.append(merged)
514
+ unused = [k for k in overrides if k not in used]
515
+ if unused:
516
+ have = sorted({_template_vars(Path(m), i)["stem" if flat else "name"] for i, m in enumerate(matches) if m is not None})
517
+ raise ValueError(f"overrides name no sample: {', '.join(unused)} (samples of {pattern}: {', '.join(have[:12])}{', ...' if len(have) > 12 else ''})")
518
+ return out
519
+
520
+
521
+ def _check_overrides(value: Any) -> dict[str, dict]:
522
+ """`overrides: {<sample>: {<sample keys>}}`; the keys are checked when the merged samples are validated."""
523
+ if value is None:
524
+ return {}
525
+ if not isinstance(value, dict) or not all(isinstance(v, dict) for v in value.values()):
526
+ raise ValueError("`overrides` must map sample names to settings, e.g. { Br9902_1: { images: [] } }")
527
+ for k, v in value.items():
528
+ if "glob" in v or "path" in v or "overrides" in v:
529
+ raise ValueError(f"overrides.{k}: `path`, `glob` and `overrides` cannot be overridden")
530
+ return {str(k): v for k, v in value.items()}
531
+
532
+
533
+ def r_object_entries(raw: dict, base: Path) -> list[RObjectEntry]:
534
+ """The `samples:` entries that point at R objects, with their templates checked against the sample schema."""
535
+ out: list[RObjectEntry] = []
536
+ for i, entry in enumerate(raw.get("samples") or []):
537
+ if not isinstance(entry, dict) or "glob" in entry or not is_r_object(entry.get("path")):
538
+ continue
539
+ entry = dict(entry)
540
+ r_keys = {k: entry.pop(k) for k in R_ENTRY_KEYS if k in entry}
541
+ path = Path(os.path.expandvars(str(entry.pop("path")))).expanduser()
542
+ path = path if path.is_absolute() else (base / path).resolve()
543
+ # validate the template now (platform, fields, microns, ...) so typos surface before a long conversion
544
+ probe = _merged_entry({**entry, "path": "probe/adata.h5ad", "id": "probe"}, raw)
545
+ probe.setdefault("platform", "other")
546
+ _check_overrides(probe.pop("overrides", None))
547
+ SampleSpec.model_validate(_render(probe, _template_vars(Path("probe/adata.h5ad"), 0)))
548
+ out.append(RObjectEntry(path=path, template=entry, index=i, **r_keys))
549
+ return out
550
+
551
+
552
+ def config_from_raw(raw: dict, path: Path, *, original: dict | None = None) -> DatasetConfig:
553
+ """Validate an already-parsed yaml document. `path` is the yaml's location (paths resolve against its folder)."""
554
+ base = path.parent
555
+ r_entries = r_object_entries(raw, base)
556
+ cfg = DatasetConfig.model_validate({**raw, "samples": expand_samples(raw, base)})
557
+ cfg = _resolve_paths(cfg, base)
558
+ cfg._path = path
559
+ cfg._raw = original if original is not None else raw
560
+ cfg._r_objects = r_entries
561
+ return cfg
562
+
563
+
564
+ def load_config(path: str | Path) -> DatasetConfig:
565
+ path = Path(path).expanduser().resolve()
566
+ with open(path) as fh:
567
+ raw = yaml.safe_load(fh)
568
+ if not isinstance(raw, dict):
569
+ raise ValueError(f"{path}: expected a mapping (id, name, samples, ...) at the top level")
570
+ return config_from_raw(dict(raw), path)