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.
- brkraw/__init__.py +9 -3
- brkraw/apps/__init__.py +12 -0
- brkraw/apps/addon/__init__.py +30 -0
- brkraw/apps/addon/core.py +35 -0
- brkraw/apps/addon/dependencies.py +402 -0
- brkraw/apps/addon/installation.py +500 -0
- brkraw/apps/addon/io.py +21 -0
- brkraw/apps/hook/__init__.py +25 -0
- brkraw/apps/hook/core.py +636 -0
- brkraw/apps/loader/__init__.py +10 -0
- brkraw/apps/loader/core.py +622 -0
- brkraw/apps/loader/formatter.py +288 -0
- brkraw/apps/loader/helper.py +797 -0
- brkraw/apps/loader/info/__init__.py +11 -0
- brkraw/apps/loader/info/scan.py +85 -0
- brkraw/apps/loader/info/scan.yaml +90 -0
- brkraw/apps/loader/info/study.py +69 -0
- brkraw/apps/loader/info/study.yaml +156 -0
- brkraw/apps/loader/info/transform.py +92 -0
- brkraw/apps/loader/types.py +220 -0
- brkraw/cli/__init__.py +5 -0
- brkraw/cli/commands/__init__.py +2 -0
- brkraw/cli/commands/addon.py +327 -0
- brkraw/cli/commands/config.py +205 -0
- brkraw/cli/commands/convert.py +903 -0
- brkraw/cli/commands/hook.py +348 -0
- brkraw/cli/commands/info.py +74 -0
- brkraw/cli/commands/init.py +214 -0
- brkraw/cli/commands/params.py +106 -0
- brkraw/cli/commands/prune.py +288 -0
- brkraw/cli/commands/session.py +371 -0
- brkraw/cli/hook_args.py +80 -0
- brkraw/cli/main.py +83 -0
- brkraw/cli/utils.py +60 -0
- brkraw/core/__init__.py +13 -0
- brkraw/core/config.py +380 -0
- brkraw/core/entrypoints.py +25 -0
- brkraw/core/formatter.py +367 -0
- brkraw/core/fs.py +495 -0
- brkraw/core/jcamp.py +600 -0
- brkraw/core/layout.py +451 -0
- brkraw/core/parameters.py +781 -0
- brkraw/core/zip.py +1121 -0
- brkraw/dataclasses/__init__.py +14 -0
- brkraw/dataclasses/node.py +139 -0
- brkraw/dataclasses/reco.py +33 -0
- brkraw/dataclasses/scan.py +61 -0
- brkraw/dataclasses/study.py +131 -0
- brkraw/default/__init__.py +3 -0
- brkraw/default/pruner_specs/deid4share.yaml +42 -0
- brkraw/default/rules/00_default.yaml +4 -0
- brkraw/default/specs/metadata_dicom.yaml +236 -0
- brkraw/default/specs/metadata_transforms.py +92 -0
- brkraw/resolver/__init__.py +7 -0
- brkraw/resolver/affine.py +539 -0
- brkraw/resolver/datatype.py +69 -0
- brkraw/resolver/fid.py +90 -0
- brkraw/resolver/helpers.py +36 -0
- brkraw/resolver/image.py +188 -0
- brkraw/resolver/nifti.py +370 -0
- brkraw/resolver/shape.py +235 -0
- brkraw/schema/__init__.py +3 -0
- brkraw/schema/context_map.yaml +62 -0
- brkraw/schema/meta.yaml +57 -0
- brkraw/schema/niftiheader.yaml +95 -0
- brkraw/schema/pruner.yaml +55 -0
- brkraw/schema/remapper.yaml +128 -0
- brkraw/schema/rules.yaml +154 -0
- brkraw/specs/__init__.py +10 -0
- brkraw/specs/hook/__init__.py +12 -0
- brkraw/specs/hook/logic.py +31 -0
- brkraw/specs/hook/validator.py +22 -0
- brkraw/specs/meta/__init__.py +5 -0
- brkraw/specs/meta/validator.py +156 -0
- brkraw/specs/pruner/__init__.py +15 -0
- brkraw/specs/pruner/logic.py +361 -0
- brkraw/specs/pruner/validator.py +119 -0
- brkraw/specs/remapper/__init__.py +27 -0
- brkraw/specs/remapper/logic.py +924 -0
- brkraw/specs/remapper/validator.py +314 -0
- brkraw/specs/rules/__init__.py +6 -0
- brkraw/specs/rules/logic.py +263 -0
- brkraw/specs/rules/validator.py +103 -0
- brkraw-0.5.0.dist-info/METADATA +81 -0
- brkraw-0.5.0.dist-info/RECORD +88 -0
- {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info}/WHEEL +1 -2
- brkraw-0.5.0.dist-info/entry_points.txt +13 -0
- brkraw/lib/__init__.py +0 -4
- brkraw/lib/backup.py +0 -641
- brkraw/lib/bids.py +0 -0
- brkraw/lib/errors.py +0 -125
- brkraw/lib/loader.py +0 -1220
- brkraw/lib/orient.py +0 -194
- brkraw/lib/parser.py +0 -48
- brkraw/lib/pvobj.py +0 -301
- brkraw/lib/reference.py +0 -245
- brkraw/lib/utils.py +0 -471
- brkraw/scripts/__init__.py +0 -0
- brkraw/scripts/brk_backup.py +0 -106
- brkraw/scripts/brkraw.py +0 -744
- brkraw/ui/__init__.py +0 -0
- brkraw/ui/config.py +0 -17
- brkraw/ui/main_win.py +0 -214
- brkraw/ui/previewer.py +0 -225
- brkraw/ui/scan_info.py +0 -72
- brkraw/ui/scan_list.py +0 -73
- brkraw/ui/subj_info.py +0 -128
- brkraw-0.3.11.dist-info/METADATA +0 -25
- brkraw-0.3.11.dist-info/RECORD +0 -28
- brkraw-0.3.11.dist-info/entry_points.txt +0 -3
- brkraw-0.3.11.dist-info/top_level.txt +0 -2
- tests/__init__.py +0 -0
- {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
|
+
]
|