brkraw 0.5.0rc1__py3-none-any.whl → 0.5.2__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 +1 -1
- brkraw/apps/hook/core.py +58 -10
- brkraw/apps/loader/core.py +5 -1
- brkraw/apps/loader/helper.py +155 -14
- brkraw/apps/loader/info/scan.py +18 -5
- brkraw/apps/loader/types.py +6 -1
- brkraw/cli/commands/convert.py +201 -79
- brkraw/cli/commands/hook.py +146 -4
- brkraw/cli/commands/session.py +6 -1
- brkraw/cli/hook_args.py +80 -0
- brkraw/core/config.py +8 -0
- brkraw/core/layout.py +56 -11
- brkraw/default/rules/00_default.yaml +4 -0
- brkraw/default/specs/metadata_dicom.yaml +236 -0
- brkraw/default/specs/metadata_transforms.py +18 -0
- brkraw/resolver/affine.py +56 -32
- brkraw/schema/context_map.yaml +5 -0
- brkraw/schema/remapper.yaml +6 -0
- brkraw/specs/__init__.py +2 -2
- brkraw/specs/{converter → hook}/logic.py +1 -0
- brkraw/specs/{converter → hook}/validator.py +1 -0
- brkraw/specs/remapper/logic.py +83 -16
- brkraw/specs/remapper/validator.py +21 -5
- {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/METADATA +31 -4
- {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/RECORD +29 -33
- brkraw/default/rules/10-metadata.yaml +0 -42
- brkraw/default/rules/20-mrs.yaml +0 -14
- brkraw/default/specs/metadata_anat.yaml +0 -54
- brkraw/default/specs/metadata_common.yaml +0 -129
- brkraw/default/specs/metadata_func.yaml +0 -127
- brkraw/default/specs/mrs.yaml +0 -71
- brkraw/default/specs/mrs_transforms.py +0 -26
- brkraw/specs/{converter → hook}/__init__.py +1 -1
- {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/WHEEL +0 -0
- {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/entry_points.txt +0 -0
- {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/licenses/LICENSE +0 -0
brkraw/cli/commands/convert.py
CHANGED
|
@@ -6,16 +6,18 @@ Last updated: 2026-01-06
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import argparse
|
|
9
|
+
import inspect
|
|
9
10
|
import json
|
|
10
11
|
import logging
|
|
11
12
|
import os
|
|
12
13
|
import re
|
|
14
|
+
import sys
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
from typing import Any, Mapping, Optional, Dict, List, Tuple, Literal, cast, get_args
|
|
15
17
|
|
|
16
18
|
import numpy as np
|
|
17
|
-
|
|
18
19
|
from brkraw.cli.utils import load
|
|
20
|
+
from brkraw.cli.hook_args import load_hook_args_yaml, merge_hook_args
|
|
19
21
|
from brkraw.core import config as config_core
|
|
20
22
|
from brkraw.core import layout as layout_core
|
|
21
23
|
from brkraw.resolver import nifti as nifti_resolver
|
|
@@ -29,6 +31,8 @@ logger = logging.getLogger("brkraw")
|
|
|
29
31
|
|
|
30
32
|
_INVALID_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
|
|
31
33
|
|
|
34
|
+
_COUNTER_TAG = re.compile(r"\{(?:Counter|counter)\}")
|
|
35
|
+
|
|
32
36
|
|
|
33
37
|
def cmd_convert(args: argparse.Namespace) -> int:
|
|
34
38
|
"""Convert a scan/reco to NIfTI with optional metadata sidecars.
|
|
@@ -77,8 +81,13 @@ def cmd_convert(args: argparse.Namespace) -> int:
|
|
|
77
81
|
# resolve flags + spaces
|
|
78
82
|
if not args.sidecar:
|
|
79
83
|
args.sidecar = _env_flag("BRKRAW_CONVERT_SIDECAR")
|
|
84
|
+
if args.no_convert and not args.sidecar:
|
|
85
|
+
logger.error("--no-convert requires --sidecar.")
|
|
86
|
+
return 2
|
|
80
87
|
if not args.flip_x:
|
|
81
88
|
args.flip_x = _env_flag("BRKRAW_CONVERT_FLIP_X")
|
|
89
|
+
if not args.flatten_fg:
|
|
90
|
+
args.flatten_fg = _env_flag("BRKRAW_CONVERT_FLATTEN_FG")
|
|
82
91
|
if args.space is None:
|
|
83
92
|
args.space = os.environ.get("BRKRAW_CONVERT_SPACE")
|
|
84
93
|
if args.override_subject_type is None:
|
|
@@ -138,10 +147,36 @@ def cmd_convert(args: argparse.Namespace) -> int:
|
|
|
138
147
|
)
|
|
139
148
|
|
|
140
149
|
try:
|
|
141
|
-
|
|
150
|
+
render_layout_supports_counter = "counter" in inspect.signature(layout_core.render_layout).parameters
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
render_layout_supports_counter = True
|
|
153
|
+
try:
|
|
154
|
+
slicepack_supports_counter = (
|
|
155
|
+
"counter" in inspect.signature(layout_core.render_slicepack_suffixes).parameters
|
|
156
|
+
)
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
slicepack_supports_counter = True
|
|
159
|
+
|
|
160
|
+
hook_args_by_name: Dict[str, Dict[str, Any]] = {}
|
|
161
|
+
hook_args_yaml_sources: List[str] = []
|
|
162
|
+
for env_key in ("BRKRAW_CONVERT_HOOK_ARGS_YAML", "BRKRAW_HOOK_ARGS_YAML"):
|
|
163
|
+
value = os.environ.get(env_key)
|
|
164
|
+
if value:
|
|
165
|
+
hook_args_yaml_sources.extend([part.strip() for part in value.split(",") if part.strip()])
|
|
166
|
+
hook_args_yaml_sources.extend(args.hook_args_yaml or [])
|
|
167
|
+
if hook_args_yaml_sources:
|
|
168
|
+
try:
|
|
169
|
+
hook_args_by_name = load_hook_args_yaml(hook_args_yaml_sources)
|
|
170
|
+
except ValueError as exc:
|
|
171
|
+
logger.error("%s", exc)
|
|
172
|
+
return 2
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
hook_args_cli = _parse_hook_args(args.hook_arg or [])
|
|
142
176
|
except ValueError as exc:
|
|
143
177
|
logger.error("%s", exc)
|
|
144
178
|
return 2
|
|
179
|
+
hook_args_by_name = merge_hook_args(hook_args_by_name, hook_args_cli)
|
|
145
180
|
|
|
146
181
|
loader = load(args.path, prefix="Loading")
|
|
147
182
|
try:
|
|
@@ -155,8 +190,6 @@ def cmd_convert(args: argparse.Namespace) -> int:
|
|
|
155
190
|
if batch_all and output_is_file:
|
|
156
191
|
logger.error("When omitting --scan-id, --output must be a directory.")
|
|
157
192
|
return 2
|
|
158
|
-
if not batch_all and args.reco_id is None:
|
|
159
|
-
args.reco_id = 1
|
|
160
193
|
|
|
161
194
|
scan_ids = list(loader.avail.keys()) if batch_all else [args.scan_id]
|
|
162
195
|
if not scan_ids:
|
|
@@ -196,6 +229,7 @@ def cmd_convert(args: argparse.Namespace) -> int:
|
|
|
196
229
|
slicepack_suffix = meta_suffix
|
|
197
230
|
|
|
198
231
|
total_written = 0
|
|
232
|
+
reserved_paths: set = set()
|
|
199
233
|
for scan_id in scan_ids:
|
|
200
234
|
if scan_id is None:
|
|
201
235
|
continue
|
|
@@ -224,79 +258,114 @@ def cmd_convert(args: argparse.Namespace) -> int:
|
|
|
224
258
|
):
|
|
225
259
|
logger.debug("Skipping scan %s reco %s (selector mismatch).", scan_id, reco_id)
|
|
226
260
|
continue
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
override_header=cast(Nifti1HeaderContents, override_header) if override_header else None,
|
|
233
|
-
override_subject_type=cast(Optional[SubjectType], args.override_subject_type),
|
|
234
|
-
override_subject_pose=cast(Optional[SubjectPose], args.override_subject_pose),
|
|
235
|
-
flip_x=args.flip_x,
|
|
236
|
-
xyz_units=cast(XYZUNIT, args.xyz_units),
|
|
237
|
-
t_units=cast(TUNIT, args.t_units),
|
|
238
|
-
hook_args_by_name=hook_args_by_name,
|
|
239
|
-
)
|
|
240
|
-
if nii is None:
|
|
241
|
-
if not batch_all and args.reco_id is not None:
|
|
242
|
-
logger.error("No NIfTI output generated for scan %s reco %s.", scan_id, reco_id)
|
|
243
|
-
return 2
|
|
244
|
-
continue
|
|
245
|
-
|
|
246
|
-
nii_list = list(nii) if isinstance(nii, tuple) else [nii]
|
|
247
|
-
try:
|
|
248
|
-
base_name = layout_core.render_layout(
|
|
249
|
-
loader,
|
|
250
|
-
scan_id,
|
|
251
|
-
layout_entries=layout_entries,
|
|
252
|
-
layout_template=layout_template,
|
|
253
|
-
context_map=args.context_map,
|
|
254
|
-
reco_id=reco_id,
|
|
255
|
-
)
|
|
256
|
-
except Exception as exc:
|
|
257
|
-
logger.error("%s", exc)
|
|
258
|
-
return 2
|
|
259
|
-
if args.prefix:
|
|
260
|
-
base_name = layout_core.render_layout(
|
|
261
|
-
loader,
|
|
262
|
-
scan_id,
|
|
263
|
-
layout_entries=None,
|
|
264
|
-
layout_template=args.prefix,
|
|
265
|
-
context_map=args.context_map,
|
|
266
|
-
reco_id=reco_id,
|
|
267
|
-
)
|
|
268
|
-
if batch_all and args.prefix:
|
|
269
|
-
base_name = f"{base_name}_scan-{scan_id}"
|
|
270
|
-
if args.reco_id is None and len(reco_ids) > 1:
|
|
271
|
-
base_name = f"{base_name}_reco-{reco_id}"
|
|
272
|
-
base_name = _sanitize_filename(base_name)
|
|
273
|
-
|
|
274
|
-
slicepack_suffixes = None
|
|
275
|
-
if len(nii_list) > 1:
|
|
276
|
-
info = layout_core.load_layout_info(
|
|
277
|
-
loader,
|
|
261
|
+
if args.no_convert:
|
|
262
|
+
nii_list: List[Any] = []
|
|
263
|
+
output_count = 1
|
|
264
|
+
else:
|
|
265
|
+
nii = loader.convert(
|
|
278
266
|
scan_id,
|
|
279
|
-
context_map=args.context_map,
|
|
280
267
|
reco_id=reco_id,
|
|
268
|
+
format=cast(Literal["nifti", "nifti1"], args.format),
|
|
269
|
+
space=cast(AffineSpace, args.space),
|
|
270
|
+
override_header=cast(Nifti1HeaderContents, override_header) if override_header else None,
|
|
271
|
+
override_subject_type=cast(Optional[SubjectType], args.override_subject_type),
|
|
272
|
+
override_subject_pose=cast(Optional[SubjectPose], args.override_subject_pose),
|
|
273
|
+
flip_x=args.flip_x,
|
|
274
|
+
flatten_fg=args.flatten_fg,
|
|
275
|
+
xyz_units=cast(XYZUNIT, args.xyz_units),
|
|
276
|
+
t_units=cast(TUNIT, args.t_units),
|
|
277
|
+
hook_args_by_name=hook_args_by_name,
|
|
281
278
|
)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
279
|
+
if nii is None:
|
|
280
|
+
if not batch_all and args.reco_id is not None:
|
|
281
|
+
logger.error("No NIfTI output generated for scan %s reco %s.", scan_id, reco_id)
|
|
282
|
+
return 2
|
|
283
|
+
continue
|
|
284
|
+
nii_list = list(nii) if isinstance(nii, tuple) else [nii]
|
|
285
|
+
output_count = len(nii_list)
|
|
286
|
+
|
|
287
|
+
slicepack_suffixes: Optional[List[str]] = None
|
|
288
|
+
output_paths: Optional[List[Path]] = None
|
|
289
|
+
uses_counter_tag = _uses_counter_tag(
|
|
290
|
+
layout_template=layout_template,
|
|
291
|
+
layout_entries=layout_entries,
|
|
292
|
+
prefix_template=args.prefix,
|
|
294
293
|
)
|
|
295
|
-
|
|
294
|
+
counter_enabled = bool(uses_counter_tag and render_layout_supports_counter)
|
|
295
|
+
|
|
296
|
+
for counter in range(1, 1000):
|
|
297
|
+
layout_kwargs: Dict[str, Any] = {"counter": counter} if counter_enabled else {}
|
|
298
|
+
try:
|
|
299
|
+
candidate_base_name = layout_core.render_layout(
|
|
300
|
+
loader,
|
|
301
|
+
scan_id,
|
|
302
|
+
layout_entries=layout_entries,
|
|
303
|
+
layout_template=layout_template,
|
|
304
|
+
context_map=args.context_map,
|
|
305
|
+
reco_id=reco_id,
|
|
306
|
+
**layout_kwargs,
|
|
307
|
+
)
|
|
308
|
+
except Exception as exc:
|
|
309
|
+
logger.error("%s", exc)
|
|
310
|
+
return 2
|
|
311
|
+
if args.prefix:
|
|
312
|
+
candidate_base_name = layout_core.render_layout(
|
|
313
|
+
loader,
|
|
314
|
+
scan_id,
|
|
315
|
+
layout_entries=None,
|
|
316
|
+
layout_template=args.prefix,
|
|
317
|
+
context_map=args.context_map,
|
|
318
|
+
reco_id=reco_id,
|
|
319
|
+
**layout_kwargs,
|
|
320
|
+
)
|
|
321
|
+
if batch_all and args.prefix:
|
|
322
|
+
candidate_base_name = f"{candidate_base_name}_scan-{scan_id}"
|
|
323
|
+
if args.reco_id is None and len(reco_ids) > 1:
|
|
324
|
+
candidate_base_name = f"{candidate_base_name}_reco-{reco_id}"
|
|
325
|
+
candidate_base_name = _sanitize_filename(candidate_base_name)
|
|
326
|
+
|
|
327
|
+
if not counter_enabled and counter > 1:
|
|
328
|
+
candidate_base_name = f"{candidate_base_name}_{counter}"
|
|
329
|
+
|
|
330
|
+
slicepack_suffixes = None
|
|
331
|
+
if not args.no_convert and output_count > 1:
|
|
332
|
+
info = layout_core.load_layout_info(
|
|
333
|
+
loader,
|
|
334
|
+
scan_id,
|
|
335
|
+
context_map=args.context_map,
|
|
336
|
+
reco_id=reco_id,
|
|
337
|
+
)
|
|
338
|
+
slicepack_suffixes = layout_core.render_slicepack_suffixes(
|
|
339
|
+
info,
|
|
340
|
+
count=len(nii_list),
|
|
341
|
+
template=slicepack_suffix,
|
|
342
|
+
**({"counter": counter} if slicepack_supports_counter and counter_enabled else {}),
|
|
343
|
+
)
|
|
344
|
+
output_paths = _resolve_output_paths(
|
|
345
|
+
args.output,
|
|
346
|
+
candidate_base_name,
|
|
347
|
+
count=output_count,
|
|
348
|
+
compress=bool(args.compress),
|
|
349
|
+
slicepack_suffix=slicepack_suffix,
|
|
350
|
+
slicepack_suffixes=slicepack_suffixes,
|
|
351
|
+
)
|
|
352
|
+
if output_paths is None:
|
|
353
|
+
return 2
|
|
354
|
+
if len(output_paths) != output_count:
|
|
355
|
+
logger.error("Output path count does not match NIfTI outputs.")
|
|
356
|
+
return 2
|
|
357
|
+
if _paths_collide(output_paths, reserved_paths):
|
|
358
|
+
continue
|
|
359
|
+
break
|
|
360
|
+
else:
|
|
361
|
+
logger.error("Could not resolve unique output name after many attempts.")
|
|
296
362
|
return 2
|
|
297
|
-
|
|
298
|
-
|
|
363
|
+
|
|
364
|
+
if output_paths is None:
|
|
365
|
+
logger.error("Output paths could not be resolved.")
|
|
299
366
|
return 2
|
|
367
|
+
for path in output_paths:
|
|
368
|
+
reserved_paths.add(path)
|
|
300
369
|
|
|
301
370
|
sidecar_meta = None
|
|
302
371
|
if args.sidecar:
|
|
@@ -306,15 +375,24 @@ def cmd_convert(args: argparse.Namespace) -> int:
|
|
|
306
375
|
context_map=args.context_map,
|
|
307
376
|
)
|
|
308
377
|
|
|
309
|
-
|
|
310
|
-
path
|
|
311
|
-
|
|
312
|
-
logger.info("Wrote NIfTI: %s", path)
|
|
313
|
-
total_written += 1
|
|
314
|
-
if args.sidecar:
|
|
378
|
+
if args.no_convert:
|
|
379
|
+
for path in output_paths:
|
|
380
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
315
381
|
_write_sidecar(path, sidecar_meta)
|
|
382
|
+
total_written += 1
|
|
383
|
+
else:
|
|
384
|
+
for path, obj in zip(output_paths, nii_list):
|
|
385
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
obj.to_filename(str(path))
|
|
387
|
+
logger.info("Wrote NIfTI: %s", path)
|
|
388
|
+
total_written += 1
|
|
389
|
+
if args.sidecar:
|
|
390
|
+
_write_sidecar(path, sidecar_meta)
|
|
316
391
|
if total_written == 0:
|
|
317
|
-
|
|
392
|
+
if args.no_convert:
|
|
393
|
+
logger.error("No sidecar outputs generated.")
|
|
394
|
+
else:
|
|
395
|
+
logger.error("No NIfTI outputs generated.")
|
|
318
396
|
return 2
|
|
319
397
|
return 0
|
|
320
398
|
|
|
@@ -549,6 +627,15 @@ def _expand_output_paths(
|
|
|
549
627
|
return [base_dir / f"{base}{suffix.format(index=i + 1)}{ext}" for i in range(count)]
|
|
550
628
|
|
|
551
629
|
|
|
630
|
+
def _paths_collide(paths: List[Path], reserved: set) -> bool:
|
|
631
|
+
if len(set(paths)) != len(paths):
|
|
632
|
+
return True
|
|
633
|
+
for path in paths:
|
|
634
|
+
if path in reserved or path.exists():
|
|
635
|
+
return True
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
|
|
552
639
|
def _env_flag(name: str) -> bool:
|
|
553
640
|
"""Return True when an env var is set to a truthy value.
|
|
554
641
|
|
|
@@ -603,6 +690,25 @@ def _parse_hook_args(values: List[str]) -> Dict[str, Dict[str, Any]]:
|
|
|
603
690
|
return parsed
|
|
604
691
|
|
|
605
692
|
|
|
693
|
+
def _uses_counter_tag(
|
|
694
|
+
*,
|
|
695
|
+
layout_template: Optional[str],
|
|
696
|
+
layout_entries: List[Any],
|
|
697
|
+
prefix_template: Optional[str],
|
|
698
|
+
) -> bool:
|
|
699
|
+
if isinstance(layout_template, str) and _COUNTER_TAG.search(layout_template):
|
|
700
|
+
return True
|
|
701
|
+
if isinstance(prefix_template, str) and _COUNTER_TAG.search(prefix_template):
|
|
702
|
+
return True
|
|
703
|
+
for field in layout_entries or []:
|
|
704
|
+
if not isinstance(field, Mapping):
|
|
705
|
+
continue
|
|
706
|
+
key = field.get("key")
|
|
707
|
+
if isinstance(key, str) and key.strip() in {"Counter", "counter"}:
|
|
708
|
+
return True
|
|
709
|
+
return False
|
|
710
|
+
|
|
711
|
+
|
|
606
712
|
def _coerce_scalar(value: str) -> Any:
|
|
607
713
|
if value.lower() in {"true", "false"}:
|
|
608
714
|
return value.lower() == "true"
|
|
@@ -673,7 +779,7 @@ def _add_convert_args(
|
|
|
673
779
|
"-r",
|
|
674
780
|
"--reco-id",
|
|
675
781
|
type=int,
|
|
676
|
-
help="Reco id to convert (
|
|
782
|
+
help="Reco id to convert (defaults to all recos when omitted).",
|
|
677
783
|
)
|
|
678
784
|
parser.add_argument(
|
|
679
785
|
"--flip-x",
|
|
@@ -711,6 +817,11 @@ def _add_convert_args(
|
|
|
711
817
|
action="store_true",
|
|
712
818
|
help="Write a JSON sidecar using metadata rules.",
|
|
713
819
|
)
|
|
820
|
+
parser.add_argument(
|
|
821
|
+
"--no-convert",
|
|
822
|
+
action="store_true",
|
|
823
|
+
help="Skip NIfTI conversion and only write sidecar metadata (requires --sidecar).",
|
|
824
|
+
)
|
|
714
825
|
parser.add_argument(
|
|
715
826
|
"--context-map",
|
|
716
827
|
dest="context_map",
|
|
@@ -722,6 +833,12 @@ def _add_convert_args(
|
|
|
722
833
|
default=[],
|
|
723
834
|
help="Hook argument in HOOK:KEY=VALUE format (repeatable).",
|
|
724
835
|
)
|
|
836
|
+
parser.add_argument(
|
|
837
|
+
"--hook-args-yaml",
|
|
838
|
+
action="append",
|
|
839
|
+
default=[],
|
|
840
|
+
help="YAML file containing hook args mapping (repeatable).",
|
|
841
|
+
)
|
|
725
842
|
parser.add_argument(
|
|
726
843
|
"--space",
|
|
727
844
|
choices=list(get_args(AffineSpace)),
|
|
@@ -742,6 +859,11 @@ def _add_convert_args(
|
|
|
742
859
|
choices=["nifti", "nifti1"],
|
|
743
860
|
help="Output format (default: nifti).",
|
|
744
861
|
)
|
|
862
|
+
parser.add_argument(
|
|
863
|
+
"--flatten-fg",
|
|
864
|
+
action="store_true",
|
|
865
|
+
help="Flatten frame-group dimensions to 4D when data is 5D or higher.",
|
|
866
|
+
)
|
|
745
867
|
parser.add_argument(
|
|
746
868
|
"--no-compress",
|
|
747
869
|
dest="compress",
|
brkraw/cli/commands/hook.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import dataclasses
|
|
4
|
+
import importlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import inspect
|
|
7
|
+
from typing import Any, Dict, Mapping
|
|
4
8
|
|
|
5
9
|
import argparse
|
|
6
10
|
import logging
|
|
@@ -8,6 +12,8 @@ import logging
|
|
|
8
12
|
from brkraw.apps import hook as hook_app
|
|
9
13
|
from brkraw.core import config as config_core
|
|
10
14
|
from brkraw.core import formatter
|
|
15
|
+
from brkraw.specs import hook as converter_core
|
|
16
|
+
import yaml
|
|
11
17
|
|
|
12
18
|
logger = logging.getLogger("brkraw")
|
|
13
19
|
|
|
@@ -119,7 +125,7 @@ def cmd_install(args: argparse.Namespace) -> int:
|
|
|
119
125
|
|
|
120
126
|
def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
121
127
|
try:
|
|
122
|
-
hook_name, removed = hook_app.uninstall_hook(
|
|
128
|
+
hook_name, removed, module_missing = hook_app.uninstall_hook(
|
|
123
129
|
args.target,
|
|
124
130
|
root=args.root,
|
|
125
131
|
force=args.force,
|
|
@@ -132,6 +138,10 @@ def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
|
132
138
|
return 2
|
|
133
139
|
removed_count = sum(len(items) for items in removed.values())
|
|
134
140
|
logger.info("Removed %d file(s).", removed_count)
|
|
141
|
+
if module_missing:
|
|
142
|
+
logger.info(
|
|
143
|
+
"Hook module is not installed; removed registry entries only."
|
|
144
|
+
)
|
|
135
145
|
logger.info("To uninstall the package, run: pip uninstall %s", hook_name)
|
|
136
146
|
return 0
|
|
137
147
|
|
|
@@ -145,8 +155,12 @@ def cmd_docs(args: argparse.Namespace) -> int:
|
|
|
145
155
|
logger.info("[Hook Docs] %s", hook_name)
|
|
146
156
|
if args.render:
|
|
147
157
|
try:
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
import importlib
|
|
159
|
+
|
|
160
|
+
console_mod = importlib.import_module("rich.console")
|
|
161
|
+
markdown_mod = importlib.import_module("rich.markdown")
|
|
162
|
+
Console = getattr(console_mod, "Console")
|
|
163
|
+
Markdown = getattr(markdown_mod, "Markdown")
|
|
150
164
|
except Exception:
|
|
151
165
|
logger.warning("rich is not available; printing raw text.")
|
|
152
166
|
print(text)
|
|
@@ -158,6 +172,119 @@ def cmd_docs(args: argparse.Namespace) -> int:
|
|
|
158
172
|
return 0
|
|
159
173
|
|
|
160
174
|
|
|
175
|
+
_PRESET_IGNORE_PARAMS = frozenset(
|
|
176
|
+
{
|
|
177
|
+
"self",
|
|
178
|
+
"scan",
|
|
179
|
+
"scan_id",
|
|
180
|
+
"reco_id",
|
|
181
|
+
"format",
|
|
182
|
+
"space",
|
|
183
|
+
"override_header",
|
|
184
|
+
"override_subject_type",
|
|
185
|
+
"override_subject_pose",
|
|
186
|
+
"flip_x",
|
|
187
|
+
"xyz_units",
|
|
188
|
+
"t_units",
|
|
189
|
+
"decimals",
|
|
190
|
+
"spec",
|
|
191
|
+
"context_map",
|
|
192
|
+
"return_spec",
|
|
193
|
+
"hook_args_by_name",
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _infer_hook_preset(entry: Mapping[str, Any]) -> Dict[str, Any]:
|
|
199
|
+
preset: Dict[str, Any] = {}
|
|
200
|
+
modules: list[object] = []
|
|
201
|
+
|
|
202
|
+
for func in entry.values():
|
|
203
|
+
if callable(func):
|
|
204
|
+
mod_name = getattr(func, "__module__", None)
|
|
205
|
+
if isinstance(mod_name, str) and mod_name:
|
|
206
|
+
try:
|
|
207
|
+
modules.append(importlib.import_module(mod_name))
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
for module in modules:
|
|
212
|
+
module_preset = _infer_hook_preset_from_module(module)
|
|
213
|
+
if module_preset:
|
|
214
|
+
return dict(sorted(module_preset.items(), key=lambda item: item[0]))
|
|
215
|
+
|
|
216
|
+
for func in entry.values():
|
|
217
|
+
if not callable(func):
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
sig = inspect.signature(func)
|
|
221
|
+
except (TypeError, ValueError):
|
|
222
|
+
continue
|
|
223
|
+
for param in sig.parameters.values():
|
|
224
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
225
|
+
continue
|
|
226
|
+
name = param.name
|
|
227
|
+
if name in _PRESET_IGNORE_PARAMS:
|
|
228
|
+
continue
|
|
229
|
+
if name in preset:
|
|
230
|
+
continue
|
|
231
|
+
if param.default is inspect.Parameter.empty:
|
|
232
|
+
preset[name] = None
|
|
233
|
+
else:
|
|
234
|
+
preset[name] = param.default
|
|
235
|
+
return dict(sorted(preset.items(), key=lambda item: item[0]))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _infer_hook_preset_from_module(module: object) -> Dict[str, Any]:
|
|
239
|
+
for attr in ("HOOK_PRESET", "HOOK_ARGS", "HOOK_DEFAULTS"):
|
|
240
|
+
value = getattr(module, attr, None)
|
|
241
|
+
if isinstance(value, Mapping):
|
|
242
|
+
return dict(value)
|
|
243
|
+
|
|
244
|
+
build_options = getattr(module, "_build_options", None)
|
|
245
|
+
if callable(build_options):
|
|
246
|
+
try:
|
|
247
|
+
options = build_options({})
|
|
248
|
+
except Exception:
|
|
249
|
+
return {}
|
|
250
|
+
if dataclasses.is_dataclass(options):
|
|
251
|
+
if not isinstance(options, type):
|
|
252
|
+
return dict(dataclasses.asdict(options))
|
|
253
|
+
defaults: Dict[str, Any] = {}
|
|
254
|
+
for field in dataclasses.fields(options):
|
|
255
|
+
if field.default is not dataclasses.MISSING:
|
|
256
|
+
defaults[field.name] = field.default
|
|
257
|
+
continue
|
|
258
|
+
if field.default_factory is not dataclasses.MISSING: # type: ignore[comparison-overlap]
|
|
259
|
+
try:
|
|
260
|
+
defaults[field.name] = field.default_factory() # type: ignore[misc]
|
|
261
|
+
except Exception:
|
|
262
|
+
defaults[field.name] = None
|
|
263
|
+
continue
|
|
264
|
+
defaults[field.name] = None
|
|
265
|
+
return defaults
|
|
266
|
+
if hasattr(options, "__dict__"):
|
|
267
|
+
return dict(vars(options))
|
|
268
|
+
return {}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cmd_preset(args: argparse.Namespace) -> int:
|
|
272
|
+
try:
|
|
273
|
+
entry = converter_core.resolve_hook(args.target)
|
|
274
|
+
except LookupError as exc:
|
|
275
|
+
logger.error("%s", exc)
|
|
276
|
+
return 2
|
|
277
|
+
preset = _infer_hook_preset(entry)
|
|
278
|
+
payload = {"hooks": {args.target: preset}}
|
|
279
|
+
text = yaml.safe_dump(payload, sort_keys=False)
|
|
280
|
+
if args.output:
|
|
281
|
+
Path(args.output).expanduser().write_text(text, encoding="utf-8")
|
|
282
|
+
logger.info("Wrote preset: %s", args.output)
|
|
283
|
+
return 0
|
|
284
|
+
print(text, end="" if text.endswith("\n") else "\n")
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
161
288
|
def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[name-defined]
|
|
162
289
|
hook_parser = subparsers.add_parser(
|
|
163
290
|
"hook",
|
|
@@ -204,3 +331,18 @@ def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[na
|
|
|
204
331
|
help="Render markdown using rich (if installed).",
|
|
205
332
|
)
|
|
206
333
|
docs_parser.set_defaults(hook_func=cmd_docs)
|
|
334
|
+
|
|
335
|
+
preset_parser = hook_sub.add_parser(
|
|
336
|
+
"preset",
|
|
337
|
+
help="Generate a YAML hook-args preset template.",
|
|
338
|
+
)
|
|
339
|
+
preset_parser.add_argument(
|
|
340
|
+
"target",
|
|
341
|
+
help="Hook entrypoint name.",
|
|
342
|
+
)
|
|
343
|
+
preset_parser.add_argument(
|
|
344
|
+
"-o",
|
|
345
|
+
"--output",
|
|
346
|
+
help="Write the preset YAML to a file instead of stdout.",
|
|
347
|
+
)
|
|
348
|
+
preset_parser.set_defaults(hook_func=cmd_preset)
|
brkraw/cli/commands/session.py
CHANGED
|
@@ -99,6 +99,7 @@ def cmd_unset(args: argparse.Namespace) -> int:
|
|
|
99
99
|
"BRKRAW_CONVERT_SPACE",
|
|
100
100
|
"BRKRAW_CONVERT_COMPRESS",
|
|
101
101
|
"BRKRAW_CONVERT_FLIP_X",
|
|
102
|
+
"BRKRAW_CONVERT_FLATTEN_FG",
|
|
102
103
|
"BRKRAW_CONVERT_OVERRIDE_SUBJECT_TYPE",
|
|
103
104
|
"BRKRAW_CONVERT_OVERRIDE_SUBJECT_POSE",
|
|
104
105
|
"BRKRAW_CONVERT_XYZ_UNITS",
|
|
@@ -153,6 +154,7 @@ def cmd_env(_: argparse.Namespace) -> int:
|
|
|
153
154
|
convert_compress = os.environ.get("BRKRAW_CONVERT_COMPRESS")
|
|
154
155
|
convert_space = os.environ.get("BRKRAW_CONVERT_SPACE")
|
|
155
156
|
convert_flip_x = os.environ.get("BRKRAW_CONVERT_FLIP_X")
|
|
157
|
+
convert_flatten_fg = os.environ.get("BRKRAW_CONVERT_FLATTEN_FG")
|
|
156
158
|
convert_subject_type = os.environ.get("BRKRAW_CONVERT_OVERRIDE_SUBJECT_TYPE")
|
|
157
159
|
convert_subject_pose = os.environ.get("BRKRAW_CONVERT_OVERRIDE_SUBJECT_POSE")
|
|
158
160
|
convert_xyz_units = os.environ.get("BRKRAW_CONVERT_XYZ_UNITS")
|
|
@@ -174,6 +176,7 @@ def cmd_env(_: argparse.Namespace) -> int:
|
|
|
174
176
|
and convert_compress is None
|
|
175
177
|
and convert_space is None
|
|
176
178
|
and convert_flip_x is None
|
|
179
|
+
and convert_flatten_fg is None
|
|
177
180
|
and convert_subject_type is None
|
|
178
181
|
and convert_subject_pose is None
|
|
179
182
|
and convert_xyz_units is None
|
|
@@ -211,6 +214,8 @@ def cmd_env(_: argparse.Namespace) -> int:
|
|
|
211
214
|
print(f"BRKRAW_CONVERT_COMPRESS={convert_compress}")
|
|
212
215
|
if convert_flip_x is not None:
|
|
213
216
|
print(f"BRKRAW_CONVERT_FLIP_X={convert_flip_x}")
|
|
217
|
+
if convert_flatten_fg is not None:
|
|
218
|
+
print(f"BRKRAW_CONVERT_FLATTEN_FG={convert_flatten_fg}")
|
|
214
219
|
if convert_subject_type is not None:
|
|
215
220
|
print(f"BRKRAW_CONVERT_OVERRIDE_SUBJECT_TYPE={convert_subject_type}")
|
|
216
221
|
if convert_subject_pose is not None:
|
|
@@ -306,7 +311,7 @@ def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[na
|
|
|
306
311
|
help=(
|
|
307
312
|
"Set BRKRAW_CONVERT_<OPTION> as KEY=VALUE (repeatable). "
|
|
308
313
|
"Keys: OUTPUT, PREFIX, SCAN_ID, RECO_ID, SIDECAR, CONTEXT_MAP, "
|
|
309
|
-
"COMPRESS, SPACE, FLIP_X, OVERRIDE_SUBJECT_TYPE, "
|
|
314
|
+
"COMPRESS, SPACE, FLIP_X, FLATTEN_FG, OVERRIDE_SUBJECT_TYPE, "
|
|
310
315
|
"OVERRIDE_SUBJECT_POSE, XYZ_UNITS, T_UNITS, HEADER, FORMAT."
|
|
311
316
|
),
|
|
312
317
|
)
|