brkraw 0.3.11__py3-none-any.whl → 0.5.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 (113) hide show
  1. brkraw/__init__.py +9 -3
  2. brkraw/apps/__init__.py +12 -0
  3. brkraw/apps/addon/__init__.py +30 -0
  4. brkraw/apps/addon/core.py +35 -0
  5. brkraw/apps/addon/dependencies.py +402 -0
  6. brkraw/apps/addon/installation.py +500 -0
  7. brkraw/apps/addon/io.py +21 -0
  8. brkraw/apps/hook/__init__.py +25 -0
  9. brkraw/apps/hook/core.py +636 -0
  10. brkraw/apps/loader/__init__.py +10 -0
  11. brkraw/apps/loader/core.py +622 -0
  12. brkraw/apps/loader/formatter.py +288 -0
  13. brkraw/apps/loader/helper.py +797 -0
  14. brkraw/apps/loader/info/__init__.py +11 -0
  15. brkraw/apps/loader/info/scan.py +85 -0
  16. brkraw/apps/loader/info/scan.yaml +90 -0
  17. brkraw/apps/loader/info/study.py +69 -0
  18. brkraw/apps/loader/info/study.yaml +156 -0
  19. brkraw/apps/loader/info/transform.py +92 -0
  20. brkraw/apps/loader/types.py +220 -0
  21. brkraw/cli/__init__.py +5 -0
  22. brkraw/cli/commands/__init__.py +2 -0
  23. brkraw/cli/commands/addon.py +327 -0
  24. brkraw/cli/commands/config.py +205 -0
  25. brkraw/cli/commands/convert.py +903 -0
  26. brkraw/cli/commands/hook.py +348 -0
  27. brkraw/cli/commands/info.py +74 -0
  28. brkraw/cli/commands/init.py +214 -0
  29. brkraw/cli/commands/params.py +106 -0
  30. brkraw/cli/commands/prune.py +288 -0
  31. brkraw/cli/commands/session.py +371 -0
  32. brkraw/cli/hook_args.py +80 -0
  33. brkraw/cli/main.py +83 -0
  34. brkraw/cli/utils.py +60 -0
  35. brkraw/core/__init__.py +13 -0
  36. brkraw/core/config.py +380 -0
  37. brkraw/core/entrypoints.py +25 -0
  38. brkraw/core/formatter.py +367 -0
  39. brkraw/core/fs.py +495 -0
  40. brkraw/core/jcamp.py +600 -0
  41. brkraw/core/layout.py +451 -0
  42. brkraw/core/parameters.py +781 -0
  43. brkraw/core/zip.py +1121 -0
  44. brkraw/dataclasses/__init__.py +14 -0
  45. brkraw/dataclasses/node.py +139 -0
  46. brkraw/dataclasses/reco.py +33 -0
  47. brkraw/dataclasses/scan.py +61 -0
  48. brkraw/dataclasses/study.py +131 -0
  49. brkraw/default/__init__.py +3 -0
  50. brkraw/default/pruner_specs/deid4share.yaml +42 -0
  51. brkraw/default/rules/00_default.yaml +4 -0
  52. brkraw/default/specs/metadata_dicom.yaml +236 -0
  53. brkraw/default/specs/metadata_transforms.py +92 -0
  54. brkraw/resolver/__init__.py +7 -0
  55. brkraw/resolver/affine.py +539 -0
  56. brkraw/resolver/datatype.py +69 -0
  57. brkraw/resolver/fid.py +90 -0
  58. brkraw/resolver/helpers.py +36 -0
  59. brkraw/resolver/image.py +188 -0
  60. brkraw/resolver/nifti.py +370 -0
  61. brkraw/resolver/shape.py +235 -0
  62. brkraw/schema/__init__.py +3 -0
  63. brkraw/schema/context_map.yaml +62 -0
  64. brkraw/schema/meta.yaml +57 -0
  65. brkraw/schema/niftiheader.yaml +95 -0
  66. brkraw/schema/pruner.yaml +55 -0
  67. brkraw/schema/remapper.yaml +128 -0
  68. brkraw/schema/rules.yaml +154 -0
  69. brkraw/specs/__init__.py +10 -0
  70. brkraw/specs/hook/__init__.py +12 -0
  71. brkraw/specs/hook/logic.py +31 -0
  72. brkraw/specs/hook/validator.py +22 -0
  73. brkraw/specs/meta/__init__.py +5 -0
  74. brkraw/specs/meta/validator.py +156 -0
  75. brkraw/specs/pruner/__init__.py +15 -0
  76. brkraw/specs/pruner/logic.py +361 -0
  77. brkraw/specs/pruner/validator.py +119 -0
  78. brkraw/specs/remapper/__init__.py +27 -0
  79. brkraw/specs/remapper/logic.py +924 -0
  80. brkraw/specs/remapper/validator.py +314 -0
  81. brkraw/specs/rules/__init__.py +6 -0
  82. brkraw/specs/rules/logic.py +263 -0
  83. brkraw/specs/rules/validator.py +103 -0
  84. brkraw-0.5.0.dist-info/METADATA +81 -0
  85. brkraw-0.5.0.dist-info/RECORD +88 -0
  86. {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info}/WHEEL +1 -2
  87. brkraw-0.5.0.dist-info/entry_points.txt +13 -0
  88. brkraw/lib/__init__.py +0 -4
  89. brkraw/lib/backup.py +0 -641
  90. brkraw/lib/bids.py +0 -0
  91. brkraw/lib/errors.py +0 -125
  92. brkraw/lib/loader.py +0 -1220
  93. brkraw/lib/orient.py +0 -194
  94. brkraw/lib/parser.py +0 -48
  95. brkraw/lib/pvobj.py +0 -301
  96. brkraw/lib/reference.py +0 -245
  97. brkraw/lib/utils.py +0 -471
  98. brkraw/scripts/__init__.py +0 -0
  99. brkraw/scripts/brk_backup.py +0 -106
  100. brkraw/scripts/brkraw.py +0 -744
  101. brkraw/ui/__init__.py +0 -0
  102. brkraw/ui/config.py +0 -17
  103. brkraw/ui/main_win.py +0 -214
  104. brkraw/ui/previewer.py +0 -225
  105. brkraw/ui/scan_info.py +0 -72
  106. brkraw/ui/scan_list.py +0 -73
  107. brkraw/ui/subj_info.py +0 -128
  108. brkraw-0.3.11.dist-info/METADATA +0 -25
  109. brkraw-0.3.11.dist-info/RECORD +0 -28
  110. brkraw-0.3.11.dist-info/entry_points.txt +0 -3
  111. brkraw-0.3.11.dist-info/top_level.txt +0 -2
  112. tests/__init__.py +0 -0
  113. {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,924 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from collections.abc import Mapping
5
+ import inspect
6
+ import re
7
+ from types import ModuleType
8
+ from typing import Any, Callable, Optional, List, Dict, Tuple, Set, Union, Iterable
9
+ import yaml
10
+ from .validator import validate_spec, validate_map_data
11
+
12
+ _MISSING = object()
13
+
14
+
15
+ def _load_transforms_from_source(src: str) -> Dict[str, Callable[[Any], Any]]:
16
+ """Execute a Python snippet and extract public callables.
17
+
18
+ Args:
19
+ src: Python source code that defines transform callables.
20
+
21
+ Returns:
22
+ A mapping of transform names to callables defined in the snippet.
23
+ """
24
+ mod = ModuleType("spec_transforms")
25
+ exec(src, mod.__dict__)
26
+ return {
27
+ name: obj
28
+ for name, obj in mod.__dict__.items()
29
+ if callable(obj) and not name.startswith("_") and not inspect.isclass(obj)
30
+ }
31
+
32
+ def _normalize_transforms_source(
33
+ transforms_source: Any,
34
+ *,
35
+ base_dir: Path,
36
+ ) -> List[Path]:
37
+ if transforms_source is None:
38
+ return []
39
+ sources: List[str]
40
+ if isinstance(transforms_source, str):
41
+ sources = [transforms_source]
42
+ elif isinstance(transforms_source, list) and all(isinstance(item, str) for item in transforms_source):
43
+ sources = transforms_source
44
+ else:
45
+ raise ValueError("transforms_source must be a string or list of strings.")
46
+ paths: List[Path] = []
47
+ for item in sources:
48
+ src = Path(item)
49
+ if not src.is_absolute():
50
+ src = (base_dir / src).resolve()
51
+ paths.append(src)
52
+ return paths
53
+
54
+
55
+ def _collect_transforms_paths(spec: Dict[str, Any], spec_path: Path) -> List[Path]:
56
+ paths: List[Path] = []
57
+ meta = spec.get("__meta__")
58
+ if isinstance(meta, dict) and meta.get("transforms_source"):
59
+ paths.extend(_normalize_transforms_source(meta.get("transforms_source"), base_dir=spec_path.parent))
60
+ for value in spec.values():
61
+ if not isinstance(value, dict):
62
+ continue
63
+ child_meta = value.get("__meta__")
64
+ if isinstance(child_meta, dict) and child_meta.get("transforms_source"):
65
+ paths.extend(
66
+ _normalize_transforms_source(child_meta.get("transforms_source"), base_dir=spec_path.parent)
67
+ )
68
+ return paths
69
+
70
+
71
+ def _load_spec_data(spec_path: Path, stack: Set[Path]) -> Tuple[Dict[str, Any], List[Path]]:
72
+ spec_path = spec_path.resolve()
73
+ if spec_path in stack:
74
+ raise ValueError(f"Circular spec include detected: {spec_path}")
75
+ if spec_path.suffix not in (".yaml", ".yml"):
76
+ raise ValueError("Spec file must be a .yaml/.yml file.")
77
+ stack.add(spec_path)
78
+ try:
79
+ spec = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
80
+ if not isinstance(spec, dict):
81
+ raise ValueError("Spec file must contain a mapping.")
82
+ transforms_paths = _collect_transforms_paths(spec, spec_path)
83
+ include_list: List[str] = []
84
+ meta = spec.get("__meta__")
85
+ include_mode = "override"
86
+ if isinstance(meta, dict) and meta.get("include_mode"):
87
+ include_mode = str(meta["include_mode"])
88
+ if include_mode not in {"override", "strict"}:
89
+ raise ValueError("include_mode must be 'override' or 'strict'.")
90
+ if isinstance(meta, dict) and "include" in meta:
91
+ include = meta.get("include")
92
+ if isinstance(include, str):
93
+ include_list = [include]
94
+ elif isinstance(include, list) and all(isinstance(item, str) for item in include):
95
+ include_list = include
96
+ else:
97
+ raise ValueError("__meta__.include must be a string or list of strings.")
98
+
99
+ merged: Dict[str, Any] = {}
100
+ for item in include_list:
101
+ inc_path = Path(item)
102
+ if not inc_path.is_absolute():
103
+ inc_path = (spec_path.parent / inc_path).resolve()
104
+ inc_spec, inc_transforms = _load_spec_data(inc_path, stack)
105
+ transforms_paths.extend(inc_transforms)
106
+ for key, value in inc_spec.items():
107
+ if key == "__meta__":
108
+ continue
109
+ if include_mode == "strict" and key in merged:
110
+ raise ValueError(f"Spec include conflict for key {key!r} in {spec_path}")
111
+ merged[key] = value
112
+
113
+ for key, value in spec.items():
114
+ if key == "__meta__":
115
+ continue
116
+ if include_mode == "strict" and key in merged:
117
+ raise ValueError(f"Spec include conflict for key {key!r} in {spec_path}")
118
+ merged[key] = value
119
+
120
+ if isinstance(meta, dict):
121
+ meta_clean = dict(meta)
122
+ meta_clean.pop("include", None)
123
+ meta_clean.pop("include_mode", None)
124
+ merged["__meta__"] = meta_clean
125
+
126
+ return merged, transforms_paths
127
+ finally:
128
+ stack.remove(spec_path)
129
+
130
+
131
+ def load_spec(
132
+ spec_source: Union[str, Path],
133
+ *,
134
+ validate: bool = False,
135
+ ) -> Tuple[Dict[str, Any], Dict[str, Callable]]:
136
+ """Load spec YAML plus optional transforms from files.
137
+
138
+ Args:
139
+ spec_source: YAML file path.
140
+ validate: If True, validate the spec before returning.
141
+
142
+ Returns:
143
+ Tuple of (spec, transforms) where transforms is a name->callable mapping.
144
+
145
+ Notes:
146
+ Transforms are loaded from ``__meta__.transforms_source`` when present,
147
+ including sources referenced via ``__meta__.include``.
148
+ """
149
+ spec_path = Path(spec_source)
150
+ spec, transforms_paths = _load_spec_data(spec_path, set())
151
+ transforms_path: List[Path] = []
152
+ if transforms_paths:
153
+ seen: Set[Path] = set()
154
+ for item in transforms_paths:
155
+ if item not in seen:
156
+ transforms_path.append(item)
157
+ seen.add(item)
158
+ if validate:
159
+ validate_spec(spec, transforms_source=transforms_path)
160
+
161
+ transforms: Dict[str, Callable] = {}
162
+ _attach_spec_path_meta(spec, spec_path)
163
+ if not transforms_path:
164
+ return spec, transforms
165
+
166
+ for path in transforms_path:
167
+ transforms_text = path.read_text(encoding="utf-8")
168
+ transforms.update(_load_transforms_from_source(transforms_text))
169
+ return spec, transforms
170
+
171
+
172
+ def _attach_spec_path_meta(spec: Dict[str, Any], spec_path: Path) -> None:
173
+ meta = spec.get("__meta__")
174
+ if isinstance(meta, dict):
175
+ meta["__spec_path__"] = str(spec_path)
176
+ for value in spec.values():
177
+ if not isinstance(value, dict):
178
+ continue
179
+ child_meta = value.get("__meta__")
180
+ if isinstance(child_meta, dict):
181
+ child_meta["__spec_path__"] = str(spec_path)
182
+
183
+
184
+ def _get_params_from_map(params_map: Mapping[str, Any], file: str, reco_id: Optional[int]):
185
+ if file == "subject":
186
+ return params_map.get("subject")
187
+ params = params_map.get(file)
188
+ if params is None:
189
+ return None
190
+ if file in ("visu_pars", "reco") and isinstance(params, Mapping):
191
+ if reco_id is None:
192
+ if len(params) == 1:
193
+ return next(iter(params.values()))
194
+ return None
195
+ return params.get(reco_id)
196
+ return params
197
+
198
+
199
+ def _get_params(source, file: str, reco_id: Optional[int]):
200
+ """Return the parameter container for a requested file type.
201
+
202
+ Args:
203
+ source: Scan-like object, Study-like object, or mapping of parameters.
204
+ file: Parameter file identifier (method, acqp, visu_pars, reco, subject).
205
+ reco_id: Optional reconstruction id for reco/visu_pars.
206
+
207
+ Returns:
208
+ Parameter container or None if the file type is unsupported.
209
+ """
210
+ if isinstance(source, Mapping):
211
+ return _get_params_from_map(source, file, reco_id)
212
+ if file == "subject":
213
+ return getattr(source, "subject", None)
214
+ if file == "method":
215
+ return source.method
216
+ if file == "acqp":
217
+ return source.acqp
218
+ if file == "visu_pars":
219
+ return source.get_reco(reco_id or 1).visu_pars
220
+ if file == "reco":
221
+ return source.get_reco(reco_id or 1).reco
222
+ return None
223
+
224
+
225
+ def _resolve_value(
226
+ source,
227
+ sources,
228
+ transforms: Dict[str, Callable],
229
+ result_ctx: Dict[str, Any],
230
+ ids: Dict[str, Optional[int]],
231
+ ):
232
+ """Resolve the first available value from a list of source descriptors.
233
+
234
+ Args:
235
+ source: Scan-like object or mapping of parameter containers.
236
+ sources: Iterable of dicts with file/key(/reco_id) selectors or inline inputs.
237
+ transforms: Transform registry for post-processing.
238
+ result_ctx: Current output context for "ref" lookups.
239
+
240
+ Returns:
241
+ The first matching value, or None if nothing is found.
242
+ """
243
+ for src in sources:
244
+ if "inputs" in src:
245
+ inputs = _resolve_inputs(source, src["inputs"], transforms, result_ctx, ids)
246
+ if "transform" in src:
247
+ return _apply_inputs_transform(inputs, transforms, src["transform"])
248
+ return inputs
249
+ params = _get_params(source, src["file"], src.get("reco_id"))
250
+ if params is None:
251
+ continue
252
+ key = src["key"]
253
+ if hasattr(params, key):
254
+ return getattr(params, key)
255
+ if isinstance(params, Mapping):
256
+ if key in params:
257
+ return params[key]
258
+ elif hasattr(params, "keys"):
259
+ if key in params.keys():
260
+ return params[key]
261
+ return None
262
+
263
+
264
+ def _set_nested(d: Dict[str, Any], dotted: str, value: Any) -> None:
265
+ """Assign a value into a nested dict using dotted keys.
266
+
267
+ Args:
268
+ d: Target dictionary to mutate.
269
+ dotted: Dotted key path like "a.b.c".
270
+ value: Value to set at the leaf key.
271
+ """
272
+ cur = d
273
+ parts = dotted.split(".")
274
+ for key in parts[:-1]:
275
+ if key not in cur or not isinstance(cur[key], dict):
276
+ cur[key] = {}
277
+ cur = cur[key]
278
+ cur[parts[-1]] = value
279
+
280
+
281
+ def _get_nested(d: Dict[str, Any], dotted: str) -> Any:
282
+ """Fetch a nested value from a dict using dotted keys.
283
+
284
+ Args:
285
+ d: Dictionary to traverse.
286
+ dotted: Dotted key path like "a.b.c".
287
+
288
+ Returns:
289
+ The nested value or None if the path does not exist.
290
+ """
291
+ cur: Any = d
292
+ for key in dotted.split("."):
293
+ if not isinstance(cur, dict) or key not in cur:
294
+ return None
295
+ cur = cur[key]
296
+ return cur
297
+
298
+
299
+ def _apply_transform_chain(value: Any, transforms: Dict[str, Callable], names: Any) -> Any:
300
+ """Apply one or more transform functions to a value.
301
+
302
+ Args:
303
+ value: Input value.
304
+ transforms: Mapping of transform names to callables.
305
+ names: Transform name or list of names; falsy means no-op.
306
+
307
+ Returns:
308
+ The transformed value.
309
+
310
+ Notes:
311
+ Transforms are applied even when ``value`` is ``None``. If a transform
312
+ cannot handle ``None``, it should guard accordingly.
313
+ """
314
+ if not names:
315
+ return value
316
+ chain = names if isinstance(names, list) else [names]
317
+ val = value
318
+ for tname in chain:
319
+ val = transforms[tname](val)
320
+ return val
321
+
322
+
323
+ def _enforce_study_rules(spec: Mapping[str, Any]) -> None:
324
+ has_subject_source = False
325
+ disallowed_files: Set[str] = set()
326
+
327
+ def check_sources(sources: List[Dict[str, Any]]) -> None:
328
+ nonlocal has_subject_source
329
+ for src in sources:
330
+ file = src.get("file")
331
+ if file == "subject":
332
+ has_subject_source = True
333
+ elif file is not None:
334
+ disallowed_files.add(str(file))
335
+
336
+ for _, rule in spec.items():
337
+ if "sources" in rule:
338
+ check_sources(rule.get("sources", []))
339
+ if "inputs" in rule:
340
+ for input_spec in rule.get("inputs", {}).values():
341
+ if "sources" in input_spec:
342
+ check_sources(input_spec.get("sources", []))
343
+
344
+ if disallowed_files:
345
+ raise ValueError(
346
+ "Study remap only supports subject sources; "
347
+ f"found: {sorted(disallowed_files)}."
348
+ )
349
+ if not has_subject_source:
350
+ raise ValueError("Study remap requires at least one subject source.")
351
+
352
+
353
+ def _is_study_like(source: Any) -> bool:
354
+ return hasattr(source, "scans") and hasattr(source, "has_subject")
355
+
356
+
357
+ def _resolve_input(
358
+ source,
359
+ spec: Any,
360
+ transforms: Dict[str, Callable],
361
+ result_ctx: Dict[str, Any],
362
+ ids: Dict[str, Optional[int]],
363
+ ) -> Any:
364
+ """Resolve a single input value based on a spec entry.
365
+
366
+ Args:
367
+ source: Scan-like object or mapping of parameter containers.
368
+ spec: Input spec containing sources/const/ref/default/transform.
369
+ transforms: Transform registry for post-processing.
370
+ result_ctx: Current output context for "ref" lookups.
371
+
372
+ Returns:
373
+ The resolved input value, possibly transformed.
374
+ """
375
+ if isinstance(spec, str):
376
+ if spec.startswith("$"):
377
+ value = _resolve_context_value(spec[1:], result_ctx, ids)
378
+ return None if value is _MISSING else value
379
+ raise ValueError(f"Input shorthand must start with '$': {spec!r}")
380
+ if not isinstance(spec, dict):
381
+ raise ValueError(f"Input spec must be a mapping or $var: {spec!r}")
382
+ if "const" in spec:
383
+ return spec["const"]
384
+ if "ref" in spec:
385
+ return _get_nested(result_ctx, spec["ref"])
386
+
387
+ raw = _resolve_value(source, spec.get("sources", []), transforms, result_ctx, ids)
388
+ if raw is None:
389
+ if "default" in spec:
390
+ raw = spec["default"]
391
+ elif spec.get("required", False):
392
+ raise KeyError(f"Required input missing: {spec}")
393
+ else:
394
+ return None
395
+
396
+ return _apply_transform_chain(raw, transforms, spec.get("transform"))
397
+
398
+
399
+ def _resolve_inputs(
400
+ source,
401
+ inputs_spec: Dict[str, Any],
402
+ transforms: Dict[str, Callable],
403
+ result_ctx: Dict[str, Any],
404
+ ids: Dict[str, Optional[int]],
405
+ ) -> Dict[str, Any]:
406
+ """Resolve a dict of input values for a rule.
407
+
408
+ Args:
409
+ source: Scan-like object or mapping of parameter containers.
410
+ inputs_spec: Mapping of input names to input specs.
411
+ transforms: Transform registry for post-processing.
412
+ result_ctx: Current output context for "ref" lookups.
413
+
414
+ Returns:
415
+ Mapping of input names to resolved values.
416
+ """
417
+ inputs: Dict[str, Any] = {}
418
+ for name, spec in inputs_spec.items():
419
+ inputs[name] = _resolve_input(source, spec, transforms, result_ctx, ids)
420
+ return inputs
421
+
422
+
423
+ def _apply_inputs_transform(
424
+ inputs: Dict[str, Any],
425
+ transforms: Dict[str, Callable],
426
+ name: Union[str, List[str]],
427
+ ) -> Any:
428
+ if isinstance(name, list):
429
+ if not name:
430
+ raise ValueError("Transform chain cannot be empty.")
431
+ head, *tail = name
432
+ value = _apply_inputs_transform(inputs, transforms, head)
433
+ return _apply_transform_chain(value, transforms, tail)
434
+
435
+ transform = transforms[name]
436
+ signature = inspect.signature(transform)
437
+ params = signature.parameters
438
+ var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
439
+ if not var_kw:
440
+ expected = {
441
+ p.name
442
+ for p in params.values()
443
+ if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
444
+ }
445
+ required = {
446
+ p.name
447
+ for p in params.values()
448
+ if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
449
+ and p.default is inspect._empty
450
+ }
451
+ extra = set(inputs.keys()) - expected
452
+ missing = required - set(inputs.keys())
453
+ if extra or missing:
454
+ raise ValueError(
455
+ f"Transform {name!r} kwargs mismatch. "
456
+ f"extra={sorted(extra)} missing={sorted(missing)}"
457
+ )
458
+ return transform(**inputs)
459
+
460
+
461
+ def map_parameters(
462
+ source,
463
+ spec: Mapping[str, Any],
464
+ transforms: Optional[Dict[str, Callable]] = None,
465
+ *,
466
+ validate: bool = False,
467
+ context_map: Optional[Union[str, Path]] = None,
468
+ context: Optional[Mapping[str, Any]] = None,
469
+ ) -> Dict[str, Any]:
470
+ """Map parameters to a nested dict according to spec rules.
471
+
472
+ Args:
473
+ source: Scan/Study-like object or mapping of parameter containers.
474
+ spec: Mapping of output keys to resolution rules.
475
+ transforms: Transform registry used by rules (optional).
476
+ validate: If True, validate the spec before mapping.
477
+ context_map: Optional context map override.
478
+ context: Optional context values (e.g., scan_id/reco_id).
479
+
480
+ Returns:
481
+ Nested dictionary of mapped outputs.
482
+
483
+ Notes:
484
+ Transforms are invoked even when the resolved value is ``None``. Make
485
+ sure transform functions handle ``None`` when missing data is expected.
486
+ """
487
+ if validate:
488
+ validate_spec(spec)
489
+ if _is_study_like(source):
490
+ _enforce_study_rules(spec)
491
+ if transforms is None:
492
+ transforms = {}
493
+ map_data = _load_map_data(spec, context_map=context_map)
494
+ ids = _get_source_ids(source, context=context)
495
+ result: Dict[str, Any] = {}
496
+ for out_key, rule in spec.items():
497
+ if out_key == "__meta__":
498
+ continue
499
+ try:
500
+ if "inputs" in rule:
501
+ inputs = _resolve_inputs(source, rule["inputs"], transforms, result, ids)
502
+ if "transform" in rule:
503
+ val = _apply_inputs_transform(inputs, transforms, rule["transform"])
504
+ else:
505
+ val = inputs
506
+ elif "sources" in rule:
507
+ raw = _resolve_value(source, rule.get("sources", []), transforms, result, ids)
508
+ val = _apply_transform_chain(raw, transforms, rule.get("transform"))
509
+ elif "const" in rule:
510
+ val = _apply_transform_chain(rule.get("const"), transforms, rule.get("transform"))
511
+ elif "ref" in rule:
512
+ val = _apply_transform_chain(_get_nested(result, rule["ref"]), transforms, rule.get("transform"))
513
+ else:
514
+ val = _apply_transform_chain(rule.get("default"), transforms, rule.get("transform"))
515
+
516
+ if "." in out_key:
517
+ _set_nested(result, out_key, val)
518
+ else:
519
+ result[out_key] = val
520
+ except Exception as exc:
521
+ msg = f"Error mapping {out_key!r} with rule {rule!r}: {exc}"
522
+ raise type(exc)(msg) from exc
523
+ if map_data:
524
+ result = _apply_map_rules(result, map_data, source, context=context)
525
+ return result
526
+
527
+
528
+ def load_context_map(path: Union[str, Path]) -> Dict[str, Any]:
529
+ map_data, _ = load_context_map_data(path)
530
+ return map_data
531
+
532
+
533
+ def load_context_map_data(
534
+ path: Union[str, Path],
535
+ ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
536
+ resolved = _resolve_map_path(path, base=None)
537
+ if resolved is None:
538
+ return {}, {}
539
+ data = _read_map_file(resolved)
540
+ return _split_map_data(data)
541
+
542
+
543
+ def load_context_map_meta(path: Union[str, Path]) -> Dict[str, Any]:
544
+ _, meta = load_context_map_data(path)
545
+ return meta
546
+
547
+
548
+ def get_selector_keys(map_data: Mapping[str, Any], *, target: Optional[str] = None) -> List[str]:
549
+ selectors: List[str] = []
550
+ for out_key, raw_rule in map_data.items():
551
+ if not _rule_applies_to_target(raw_rule, target):
552
+ continue
553
+ if _is_selector_rule(raw_rule):
554
+ selectors.append(out_key)
555
+ return selectors
556
+
557
+
558
+ def matches_context_map_selectors(
559
+ result: Union[Mapping[str, Any], Tuple[Mapping[str, Any], Mapping[str, Any]]],
560
+ map_data: Mapping[str, Any],
561
+ *,
562
+ target: Optional[str] = None,
563
+ ) -> bool:
564
+ selector_keys = get_selector_keys(map_data, target=None)
565
+ if not selector_keys:
566
+ return True
567
+ for key in selector_keys:
568
+ if not _selector_value_present(result, key):
569
+ return False
570
+ return True
571
+
572
+
573
+ def _selector_value_present(
574
+ result: Union[Mapping[str, Any], Tuple[Mapping[str, Any], Mapping[str, Any]]],
575
+ out_key: str,
576
+ ) -> bool:
577
+ results: Iterable[Mapping[str, Any]]
578
+ if isinstance(result, tuple):
579
+ results = result
580
+ else:
581
+ results = (result,)
582
+ for item in results:
583
+ found, value = _get_output_value(dict(item), out_key)
584
+ if found and value is not None:
585
+ return True
586
+ return False
587
+
588
+
589
+ def apply_context_map(
590
+ result: Mapping[str, Any],
591
+ map_data: Mapping[str, Any],
592
+ *,
593
+ target: Optional[str],
594
+ context: Optional[Mapping[str, Any]] = None,
595
+ ) -> Dict[str, Any]:
596
+ filtered = _filter_map_data(map_data, target=target)
597
+ if not filtered:
598
+ return dict(result)
599
+ return _apply_map_rules(dict(result), filtered, None, context=context)
600
+
601
+
602
+ def _load_map_data(
603
+ spec: Mapping[str, Any],
604
+ *,
605
+ context_map: Optional[Union[str, Path]],
606
+ ) -> Dict[str, Any]:
607
+ override_path = _resolve_map_path(context_map, base=None)
608
+ if override_path is not None:
609
+ return _read_map_file(override_path)
610
+ return {}
611
+
612
+
613
+ def _resolve_map_path(
614
+ value: Optional[Union[str, Path]],
615
+ *,
616
+ base: Optional[Path],
617
+ ) -> Optional[Path]:
618
+ if not value:
619
+ return None
620
+ path = Path(value).expanduser()
621
+ if not path.is_absolute():
622
+ if base is not None:
623
+ path = (base / path).resolve()
624
+ else:
625
+ path = path.resolve()
626
+ if not path.exists():
627
+ raise FileNotFoundError(path)
628
+ return path
629
+
630
+
631
+ def _read_map_file(path: Path) -> Dict[str, Any]:
632
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
633
+ if data is None:
634
+ return {}
635
+ validate_map_data(data)
636
+ return dict(data) if isinstance(data, Mapping) else {}
637
+
638
+
639
+ def _split_map_data(data: Mapping[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
640
+ meta: Dict[str, Any] = {}
641
+ raw_meta = data.get("__meta__")
642
+ if isinstance(raw_meta, Mapping):
643
+ meta = dict(raw_meta)
644
+ rules = {key: value for key, value in data.items() if key != "__meta__"}
645
+ return rules, meta
646
+
647
+
648
+ def _apply_map_rules(
649
+ result: Dict[str, Any],
650
+ map_data: Dict[str, Any],
651
+ source: Any,
652
+ *,
653
+ context: Optional[Mapping[str, Any]] = None,
654
+ ) -> Dict[str, Any]:
655
+ ids = _get_source_ids(source, context=context)
656
+ base = dict(result)
657
+ for out_key, raw_rule in map_data.items():
658
+ rules = _normalize_map_rules(raw_rule)
659
+ if not rules:
660
+ continue
661
+ found, current = _get_output_value(base, out_key)
662
+ for rule in rules:
663
+ if "when" in rule and not _matches_when(rule["when"], base, ids):
664
+ continue
665
+ new_value, has_value = _resolve_rule_value(rule, current if found else None)
666
+ if not has_value:
667
+ break
668
+ override = bool(rule.get("override", True))
669
+ if override or not found or current is None:
670
+ _set_nested(result, out_key, new_value)
671
+ found = True
672
+ current = new_value
673
+ break
674
+ return result
675
+
676
+
677
+ def _normalize_map_rules(raw_rule: Any) -> List[Dict[str, Any]]:
678
+ if isinstance(raw_rule, list):
679
+ rules = [dict(rule) for rule in raw_rule if isinstance(rule, Mapping)]
680
+ return _expand_case_rules(rules)
681
+ if isinstance(raw_rule, Mapping):
682
+ return _expand_case_rules([dict(raw_rule)])
683
+ raise ValueError("Map rule must be a mapping or list of mappings.")
684
+
685
+
686
+ def _is_selector_rule(raw_rule: Any) -> bool:
687
+ return any(rule.get("selector") for rule in _iter_rule_objects(raw_rule))
688
+
689
+
690
+ def _rule_targets(raw_rule: Any) -> Set[str]:
691
+ targets: Set[str] = set()
692
+ for rule in _iter_rule_objects(raw_rule):
693
+ value = rule.get("target")
694
+ if isinstance(value, str):
695
+ targets.add(value)
696
+ return targets
697
+
698
+
699
+ def _iter_rule_objects(raw_rule: Any) -> Iterable[Mapping[str, Any]]:
700
+ if isinstance(raw_rule, Mapping):
701
+ yield raw_rule
702
+ cases = raw_rule.get("cases")
703
+ if isinstance(cases, list):
704
+ for case in cases:
705
+ yield from _iter_rule_objects(case)
706
+ elif isinstance(raw_rule, list):
707
+ for rule in raw_rule:
708
+ yield from _iter_rule_objects(rule)
709
+
710
+
711
+ def _expand_case_rules(rules: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
712
+ expanded: List[Dict[str, Any]] = []
713
+ for rule in rules:
714
+ expanded.extend(_expand_rule_cases(rule))
715
+ return expanded
716
+
717
+
718
+ def _expand_rule_cases(rule: Dict[str, Any]) -> List[Dict[str, Any]]:
719
+ cases = rule.get("cases")
720
+ if not isinstance(cases, list):
721
+ return [dict(rule)]
722
+ parent = dict(rule)
723
+ parent.pop("cases", None)
724
+ expanded: List[Dict[str, Any]] = []
725
+ for case in cases:
726
+ if not isinstance(case, Mapping):
727
+ continue
728
+ merged = _merge_case_rule(parent, case)
729
+ if "cases" in merged:
730
+ expanded.extend(_expand_rule_cases(merged))
731
+ else:
732
+ expanded.append(merged)
733
+ if _rule_has_value(parent):
734
+ expanded.append(parent)
735
+ return expanded
736
+
737
+
738
+ def _merge_case_rule(parent: Mapping[str, Any], case: Mapping[str, Any]) -> Dict[str, Any]:
739
+ merged = dict(parent)
740
+ parent_when = parent.get("when")
741
+ case_when = case.get("when")
742
+ if isinstance(parent_when, Mapping) and isinstance(case_when, Mapping):
743
+ merged["when"] = {**parent_when, **case_when}
744
+ elif case_when is not None:
745
+ merged["when"] = case_when
746
+ elif parent_when is not None:
747
+ merged["when"] = parent_when
748
+ for key, value in case.items():
749
+ if key == "when":
750
+ continue
751
+ merged[key] = value
752
+ return merged
753
+
754
+
755
+ def _rule_has_value(rule: Mapping[str, Any]) -> bool:
756
+ if "value" in rule:
757
+ return True
758
+ if "values" in rule:
759
+ return isinstance(rule.get("values"), Mapping)
760
+ if "default" in rule and "when" not in rule:
761
+ return True
762
+ rule_type = rule.get("type")
763
+ if rule_type == "const":
764
+ return "value" in rule
765
+ if rule_type == "mapping":
766
+ return isinstance(rule.get("values"), Mapping)
767
+ return False
768
+
769
+
770
+ def _rule_applies_to_target(raw_rule: Any, target: Optional[str]) -> bool:
771
+ if target is None:
772
+ return True
773
+ targets = _rule_targets(raw_rule)
774
+ if not targets:
775
+ return target == "info_spec"
776
+ return target in targets
777
+
778
+
779
+ def _filter_map_data(map_data: Mapping[str, Any], *, target: Optional[str]) -> Dict[str, Any]:
780
+ if target is None:
781
+ return dict(map_data)
782
+ return {
783
+ key: raw_rule
784
+ for key, raw_rule in map_data.items()
785
+ if _rule_applies_to_target(raw_rule, target)
786
+ }
787
+
788
+
789
+ def _get_output_value(result: Dict[str, Any], out_key: str) -> Tuple[bool, Any]:
790
+ if "." in out_key:
791
+ value = _get_nested(result, out_key)
792
+ return (value is not None), value
793
+ if out_key in result:
794
+ return True, result[out_key]
795
+ return False, None
796
+
797
+
798
+ def _get_source_ids(source: Any, *, context: Optional[Mapping[str, Any]] = None) -> Dict[str, Optional[int]]:
799
+ scan_id = getattr(source, "scan_id", None)
800
+ reco_id = getattr(source, "reco_id", None)
801
+ if context:
802
+ if "scan_id" in context:
803
+ scan_id = context.get("scan_id")
804
+ if "reco_id" in context:
805
+ reco_id = context.get("reco_id")
806
+ return {
807
+ "scanid": scan_id,
808
+ "scan_id": scan_id,
809
+ "recoid": reco_id,
810
+ "reco_id": reco_id,
811
+ }
812
+
813
+
814
+ def _matches_when(when: Any, result: Dict[str, Any], ids: Dict[str, Optional[int]]) -> bool:
815
+ if not isinstance(when, Mapping):
816
+ raise ValueError("when must be a mapping.")
817
+ for key, cond in when.items():
818
+ actual = _resolve_context_value(str(key), result, ids)
819
+ if actual is _MISSING:
820
+ return False
821
+ if not _matches_condition(actual, cond):
822
+ return False
823
+ return True
824
+
825
+
826
+ def _resolve_context_value(key: str, result: Dict[str, Any], ids: Dict[str, Optional[int]]) -> Any:
827
+ normalized = key.lower()
828
+ if normalized in ids and ids[normalized] is not None:
829
+ return ids[normalized]
830
+ if "." in key:
831
+ value = _get_nested(result, key)
832
+ return value if value is not None else _MISSING
833
+ if key in result:
834
+ return result[key]
835
+ return _MISSING
836
+
837
+
838
+ def _matches_condition(value: Any, cond: Any) -> bool:
839
+ if isinstance(cond, Mapping):
840
+ for op, expected in cond.items():
841
+ if op == "not":
842
+ if _matches_condition(value, expected):
843
+ return False
844
+ continue
845
+ if op == "in":
846
+ if not isinstance(expected, (list, tuple, set)):
847
+ expected = [expected]
848
+ if isinstance(value, (list, tuple, set)):
849
+ if not any(item in expected for item in value):
850
+ return False
851
+ else:
852
+ if value not in expected:
853
+ return False
854
+ continue
855
+ if op == "regex":
856
+ if not re.search(str(expected), str(value)):
857
+ return False
858
+ continue
859
+ if value != expected:
860
+ return False
861
+ return True
862
+ return value == cond
863
+
864
+
865
+ def _resolve_rule_value(rule: Mapping[str, Any], current: Any) -> Tuple[Any, bool]:
866
+ if "default" in rule and "when" not in rule:
867
+ return rule.get("default"), True
868
+ rule_type = rule.get("type")
869
+ if rule_type is None:
870
+ if "values" in rule:
871
+ rule_type = "mapping"
872
+ elif "value" in rule:
873
+ rule_type = "const"
874
+ if rule_type == "mapping":
875
+ mapping = rule.get("values")
876
+ if not isinstance(mapping, Mapping):
877
+ raise ValueError("map values must be a mapping.")
878
+ has_default = "default" in rule
879
+ default = rule.get("default")
880
+ if current is None and not has_default and None not in mapping:
881
+ return current, False
882
+ return _map_lookup(current, mapping, default, has_default=has_default), True
883
+ if rule_type == "const":
884
+ return rule.get("value"), True
885
+ if "value" in rule:
886
+ return rule.get("value"), True
887
+ if "default" in rule:
888
+ return rule.get("default"), True
889
+ return current, False
890
+
891
+
892
+ def _map_lookup(
893
+ value: Any,
894
+ mapping: Mapping[Any, Any],
895
+ default: Any,
896
+ *,
897
+ has_default: bool,
898
+ ) -> Any:
899
+ if isinstance(value, (list, tuple)):
900
+ mapped = [
901
+ _map_lookup(item, mapping, default, has_default=has_default) for item in value
902
+ ]
903
+ return type(value)(mapped)
904
+ if value in mapping:
905
+ return mapping[value]
906
+ if not isinstance(value, str):
907
+ as_str = str(value)
908
+ if as_str in mapping:
909
+ return mapping[as_str]
910
+ if has_default:
911
+ return default
912
+ return value
913
+
914
+
915
+ __all__ = [
916
+ "load_spec",
917
+ "map_parameters",
918
+ "load_context_map",
919
+ "get_selector_keys",
920
+ "matches_context_map_selectors",
921
+ "apply_context_map",
922
+ "load_context_map_data",
923
+ "load_context_map_meta",
924
+ ]