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.
Files changed (36) hide show
  1. brkraw/__init__.py +1 -1
  2. brkraw/apps/hook/core.py +58 -10
  3. brkraw/apps/loader/core.py +5 -1
  4. brkraw/apps/loader/helper.py +155 -14
  5. brkraw/apps/loader/info/scan.py +18 -5
  6. brkraw/apps/loader/types.py +6 -1
  7. brkraw/cli/commands/convert.py +201 -79
  8. brkraw/cli/commands/hook.py +146 -4
  9. brkraw/cli/commands/session.py +6 -1
  10. brkraw/cli/hook_args.py +80 -0
  11. brkraw/core/config.py +8 -0
  12. brkraw/core/layout.py +56 -11
  13. brkraw/default/rules/00_default.yaml +4 -0
  14. brkraw/default/specs/metadata_dicom.yaml +236 -0
  15. brkraw/default/specs/metadata_transforms.py +18 -0
  16. brkraw/resolver/affine.py +56 -32
  17. brkraw/schema/context_map.yaml +5 -0
  18. brkraw/schema/remapper.yaml +6 -0
  19. brkraw/specs/__init__.py +2 -2
  20. brkraw/specs/{converter → hook}/logic.py +1 -0
  21. brkraw/specs/{converter → hook}/validator.py +1 -0
  22. brkraw/specs/remapper/logic.py +83 -16
  23. brkraw/specs/remapper/validator.py +21 -5
  24. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/METADATA +31 -4
  25. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/RECORD +29 -33
  26. brkraw/default/rules/10-metadata.yaml +0 -42
  27. brkraw/default/rules/20-mrs.yaml +0 -14
  28. brkraw/default/specs/metadata_anat.yaml +0 -54
  29. brkraw/default/specs/metadata_common.yaml +0 -129
  30. brkraw/default/specs/metadata_func.yaml +0 -127
  31. brkraw/default/specs/mrs.yaml +0 -71
  32. brkraw/default/specs/mrs_transforms.py +0 -26
  33. brkraw/specs/{converter → hook}/__init__.py +1 -1
  34. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/WHEEL +0 -0
  35. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/entry_points.txt +0 -0
  36. {brkraw-0.5.0rc1.dist-info → brkraw-0.5.2.dist-info}/licenses/LICENSE +0 -0
@@ -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
- hook_args_by_name = _parse_hook_args(args.hook_arg or [])
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
- nii = loader.convert(
228
- scan_id,
229
- reco_id=reco_id,
230
- format=cast(Literal["nifti", "nifti1"], args.format),
231
- space=cast(AffineSpace, args.space),
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
- slicepack_suffixes = layout_core.render_slicepack_suffixes(
283
- info,
284
- count=len(nii_list),
285
- template=slicepack_suffix,
286
- )
287
- output_paths = _resolve_output_paths(
288
- args.output,
289
- base_name,
290
- count=len(nii_list),
291
- compress=bool(args.compress),
292
- slicepack_suffix=slicepack_suffix,
293
- slicepack_suffixes=slicepack_suffixes,
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
- if output_paths is None:
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
- if len(output_paths) != len(nii_list):
298
- logger.error("Output path count does not match NIfTI outputs.")
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
- for path, obj in zip(output_paths, nii_list):
310
- path.parent.mkdir(parents=True, exist_ok=True)
311
- obj.to_filename(str(path))
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
- logger.error("No NIfTI outputs generated.")
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 (default: 1).",
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",
@@ -1,6 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Dict
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
- from rich.console import Console
149
- from rich.markdown import Markdown
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)
@@ -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
  )