brkraw 0.3.11__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. brkraw/__init__.py +9 -3
  2. brkraw/apps/__init__.py +12 -0
  3. brkraw/apps/addon/__init__.py +30 -0
  4. brkraw/apps/addon/core.py +35 -0
  5. brkraw/apps/addon/dependencies.py +402 -0
  6. brkraw/apps/addon/installation.py +500 -0
  7. brkraw/apps/addon/io.py +21 -0
  8. brkraw/apps/hook/__init__.py +25 -0
  9. brkraw/apps/hook/core.py +636 -0
  10. brkraw/apps/loader/__init__.py +10 -0
  11. brkraw/apps/loader/core.py +622 -0
  12. brkraw/apps/loader/formatter.py +288 -0
  13. brkraw/apps/loader/helper.py +797 -0
  14. brkraw/apps/loader/info/__init__.py +11 -0
  15. brkraw/apps/loader/info/scan.py +85 -0
  16. brkraw/apps/loader/info/scan.yaml +90 -0
  17. brkraw/apps/loader/info/study.py +69 -0
  18. brkraw/apps/loader/info/study.yaml +156 -0
  19. brkraw/apps/loader/info/transform.py +92 -0
  20. brkraw/apps/loader/types.py +220 -0
  21. brkraw/cli/__init__.py +5 -0
  22. brkraw/cli/commands/__init__.py +2 -0
  23. brkraw/cli/commands/addon.py +327 -0
  24. brkraw/cli/commands/config.py +205 -0
  25. brkraw/cli/commands/convert.py +903 -0
  26. brkraw/cli/commands/hook.py +348 -0
  27. brkraw/cli/commands/info.py +74 -0
  28. brkraw/cli/commands/init.py +214 -0
  29. brkraw/cli/commands/params.py +106 -0
  30. brkraw/cli/commands/prune.py +288 -0
  31. brkraw/cli/commands/session.py +371 -0
  32. brkraw/cli/hook_args.py +80 -0
  33. brkraw/cli/main.py +83 -0
  34. brkraw/cli/utils.py +60 -0
  35. brkraw/core/__init__.py +13 -0
  36. brkraw/core/config.py +380 -0
  37. brkraw/core/entrypoints.py +25 -0
  38. brkraw/core/formatter.py +367 -0
  39. brkraw/core/fs.py +495 -0
  40. brkraw/core/jcamp.py +600 -0
  41. brkraw/core/layout.py +451 -0
  42. brkraw/core/parameters.py +781 -0
  43. brkraw/core/zip.py +1121 -0
  44. brkraw/dataclasses/__init__.py +14 -0
  45. brkraw/dataclasses/node.py +139 -0
  46. brkraw/dataclasses/reco.py +33 -0
  47. brkraw/dataclasses/scan.py +61 -0
  48. brkraw/dataclasses/study.py +131 -0
  49. brkraw/default/__init__.py +3 -0
  50. brkraw/default/pruner_specs/deid4share.yaml +42 -0
  51. brkraw/default/rules/00_default.yaml +4 -0
  52. brkraw/default/specs/metadata_dicom.yaml +236 -0
  53. brkraw/default/specs/metadata_transforms.py +92 -0
  54. brkraw/resolver/__init__.py +7 -0
  55. brkraw/resolver/affine.py +539 -0
  56. brkraw/resolver/datatype.py +69 -0
  57. brkraw/resolver/fid.py +90 -0
  58. brkraw/resolver/helpers.py +36 -0
  59. brkraw/resolver/image.py +188 -0
  60. brkraw/resolver/nifti.py +370 -0
  61. brkraw/resolver/shape.py +235 -0
  62. brkraw/schema/__init__.py +3 -0
  63. brkraw/schema/context_map.yaml +62 -0
  64. brkraw/schema/meta.yaml +57 -0
  65. brkraw/schema/niftiheader.yaml +95 -0
  66. brkraw/schema/pruner.yaml +55 -0
  67. brkraw/schema/remapper.yaml +128 -0
  68. brkraw/schema/rules.yaml +154 -0
  69. brkraw/specs/__init__.py +10 -0
  70. brkraw/specs/hook/__init__.py +12 -0
  71. brkraw/specs/hook/logic.py +31 -0
  72. brkraw/specs/hook/validator.py +22 -0
  73. brkraw/specs/meta/__init__.py +5 -0
  74. brkraw/specs/meta/validator.py +156 -0
  75. brkraw/specs/pruner/__init__.py +15 -0
  76. brkraw/specs/pruner/logic.py +361 -0
  77. brkraw/specs/pruner/validator.py +119 -0
  78. brkraw/specs/remapper/__init__.py +27 -0
  79. brkraw/specs/remapper/logic.py +924 -0
  80. brkraw/specs/remapper/validator.py +314 -0
  81. brkraw/specs/rules/__init__.py +6 -0
  82. brkraw/specs/rules/logic.py +263 -0
  83. brkraw/specs/rules/validator.py +103 -0
  84. brkraw-0.5.0.dist-info/METADATA +81 -0
  85. brkraw-0.5.0.dist-info/RECORD +88 -0
  86. {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info}/WHEEL +1 -2
  87. brkraw-0.5.0.dist-info/entry_points.txt +13 -0
  88. brkraw/lib/__init__.py +0 -4
  89. brkraw/lib/backup.py +0 -641
  90. brkraw/lib/bids.py +0 -0
  91. brkraw/lib/errors.py +0 -125
  92. brkraw/lib/loader.py +0 -1220
  93. brkraw/lib/orient.py +0 -194
  94. brkraw/lib/parser.py +0 -48
  95. brkraw/lib/pvobj.py +0 -301
  96. brkraw/lib/reference.py +0 -245
  97. brkraw/lib/utils.py +0 -471
  98. brkraw/scripts/__init__.py +0 -0
  99. brkraw/scripts/brk_backup.py +0 -106
  100. brkraw/scripts/brkraw.py +0 -744
  101. brkraw/ui/__init__.py +0 -0
  102. brkraw/ui/config.py +0 -17
  103. brkraw/ui/main_win.py +0 -214
  104. brkraw/ui/previewer.py +0 -225
  105. brkraw/ui/scan_info.py +0 -72
  106. brkraw/ui/scan_list.py +0 -73
  107. brkraw/ui/subj_info.py +0 -128
  108. brkraw-0.3.11.dist-info/METADATA +0 -25
  109. brkraw-0.3.11.dist-info/RECORD +0 -28
  110. brkraw-0.3.11.dist-info/entry_points.txt +0 -3
  111. brkraw-0.3.11.dist-info/top_level.txt +0 -2
  112. tests/__init__.py +0 -0
  113. {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,220 @@
1
+ """Typing helpers for app-level loader interfaces.
2
+
3
+ Last updated: 2025-12-30
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Union, Tuple, Dict, Optional, Protocol, Literal, Mapping, Callable, List, TYPE_CHECKING
9
+ if TYPE_CHECKING:
10
+ from typing_extensions import ParamSpec, TypeAlias
11
+ else:
12
+ try:
13
+ from typing import ParamSpec, TypeAlias
14
+ except ImportError: # pragma: no cover - fallback for Python 3.8
15
+ from typing_extensions import ParamSpec, TypeAlias
16
+ from ...dataclasses.study import Study
17
+ from ...dataclasses.scan import Scan
18
+ from ...dataclasses.reco import Reco
19
+ import numpy as np
20
+
21
+ if TYPE_CHECKING:
22
+ from pathlib import Path
23
+ from ...core.parameters import Parameters
24
+ from ...resolver.image import ResolvedImage
25
+ from ...resolver.affine import ResolvedAffine, SubjectType, SubjectPose
26
+ from ...resolver.nifti import Nifti1HeaderContents, XYZUNIT, TUNIT
27
+ from nibabel.nifti1 import Nifti1Image
28
+
29
+
30
+
31
+ InfoScope = Literal['full', 'study', 'scan']
32
+ AffineReturn = Optional[Union[np.ndarray, Tuple[np.ndarray, ...]]]
33
+ AffineSpace = Literal["raw", "scanner", "subject_ras"]
34
+
35
+ P = ParamSpec("P")
36
+
37
+
38
+ class GetDataobjType(Protocol[P]):
39
+ """Callable signature for get_dataobj overrides."""
40
+ def __call__(
41
+ self,
42
+ scan: "Scan",
43
+ reco_id: Optional[int],
44
+ *args: P.args,
45
+ **kwargs: P.kwargs
46
+ ) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
47
+ ...
48
+
49
+
50
+ class GetAffineType(Protocol):
51
+ """Callable signature for get_affine overrides."""
52
+ def __call__(
53
+ self,
54
+ scan: "Scan",
55
+ reco_id: Optional[int],
56
+ *,
57
+ space: AffineSpace,
58
+ override_subject_type: Optional[SubjectType],
59
+ override_subject_pose: Optional[SubjectPose],
60
+ decimals: Optional[int] = None,
61
+ **kwargs: Any
62
+ ) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
63
+ ...
64
+
65
+
66
+ class GetNifti1ImageType(Protocol):
67
+ """Callable signature for get_nifti1image overrides."""
68
+ def __call__(
69
+ self,
70
+ scan: "Scan",
71
+ reco_id: Optional[int] = None,
72
+ *,
73
+ override_header: Optional[Union[dict, "Nifti1HeaderContents"]],
74
+ space: AffineSpace,
75
+ override_subject_type: Optional[SubjectType],
76
+ override_subject_pose: Optional[SubjectPose],
77
+ flip_x: bool,
78
+ flatten_fg: bool,
79
+ xyz_units: XYZUNIT,
80
+ t_units: TUNIT,
81
+ **kwargs: Any,
82
+ ) -> Optional[Union[Tuple["Nifti1Image", ...], "Nifti1Image"]]:
83
+ ...
84
+
85
+
86
+ class ConvertType(Protocol):
87
+ """Callable signature for convert overrides."""
88
+ def __call__(
89
+ self,
90
+ scan: "Scan",
91
+ reco_id: Optional[int] = None,
92
+ *,
93
+ format: Union[Literal["nifti", "nifti1"], str],
94
+ override_header: Optional[Union[dict, "Nifti1HeaderContents"]],
95
+ space: AffineSpace,
96
+ override_subject_type: Optional[SubjectType],
97
+ override_subject_pose: Optional[SubjectPose],
98
+ flip_x: bool,
99
+ flatten_fg: bool,
100
+ xyz_units: XYZUNIT,
101
+ t_units: TUNIT,
102
+ **kwargs: Any,
103
+ ) -> Optional[Union[Tuple["Nifti1Image", ...], "Nifti1Image"]]:
104
+ ...
105
+
106
+
107
+ class BaseLoader(Protocol):
108
+ """Base protocol for loader types that can search parameters."""
109
+ def search_params(
110
+ self, key: str,
111
+ *,
112
+ file: Optional[Union[str, List[str]]] = None,
113
+ scan_id: Optional[int] = None,
114
+ reco_id: Optional[int] = None
115
+ ) -> Optional[dict]:
116
+ ...
117
+
118
+
119
+ class StudyLoader(Study, BaseLoader):
120
+ """Study with attached loader helpers."""
121
+ subject: Parameters
122
+
123
+
124
+ class ScanLoader(Scan, BaseLoader):
125
+ """Scan with attached loader helpers and conversion overrides."""
126
+
127
+ image_info: Dict[int, Optional["ResolvedImage"]]
128
+ affine_info: Dict[int, Optional["ResolvedAffine"]]
129
+ _converter_hook: Optional[ConverterHook]
130
+ _converter_hook_name: Optional[str]
131
+
132
+ def get_fid(self,
133
+ buffer_start: Optional[int],
134
+ buffer_size: Optional[int],
135
+ *,
136
+ as_complex: bool) -> Optional[np.ndarray]:
137
+ ...
138
+
139
+ def get_dataobj(
140
+ self,
141
+ reco_id: Optional[int] = None
142
+ ) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
143
+ ...
144
+
145
+ def get_affine(
146
+ self,
147
+ reco_id: Optional[int] = None,
148
+ *,
149
+ space: AffineSpace = "subject_ras",
150
+ override_subject_type: Optional[SubjectType],
151
+ override_subject_pose: Optional[SubjectPose],
152
+ decimals: Optional[int] = None,
153
+ **kwargs: Any,
154
+ ) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
155
+ ...
156
+
157
+ def get_nifti1image(
158
+ self,
159
+ reco_id: Optional[int] = None,
160
+ *,
161
+ override_header: Optional[Union[dict, "Nifti1HeaderContents"]],
162
+ space: AffineSpace = "subject_ras",
163
+ override_subject_type: Optional[SubjectType],
164
+ override_subject_pose: Optional[SubjectPose],
165
+ flip_x: bool,
166
+ flatten_fg: bool,
167
+ xyz_units: XYZUNIT,
168
+ t_units: TUNIT
169
+ ) -> Optional[Union[Tuple["Nifti1Image", ...], "Nifti1Image"]]:
170
+ ...
171
+
172
+ def convert(
173
+ self,
174
+ reco_id: Optional[int] = None,
175
+ *,
176
+ format: Literal["nifti", "nifti1"],
177
+ override_header: Optional[Union[dict, "Nifti1HeaderContents"]],
178
+ space: AffineSpace = "subject_ras",
179
+ override_subject_type: Optional[SubjectType],
180
+ override_subject_pose: Optional[SubjectPose],
181
+ flip_x: bool,
182
+ flatten_fg: bool,
183
+ xyz_units: XYZUNIT,
184
+ t_units: TUNIT,
185
+ hook_args_by_name: Optional[Mapping[str, Mapping[str, Any]]] = None,
186
+ ) -> Optional[Union[Tuple["Nifti1Image", ...], "Nifti1Image"]]:
187
+ ...
188
+
189
+ def get_metadata(
190
+ self,
191
+ reco_id: Optional[int] = None,
192
+ spec: Optional[Union[Mapping[str, Any], str, "Path"]] = None,
193
+ context_map: Optional[Union[str, "Path"]] = None,
194
+ return_spec: bool = False,
195
+ ) -> Optional[Union[dict, Tuple[Optional[dict], Optional[dict]]]]:
196
+ ...
197
+
198
+
199
+ class RecoLoader(Reco, BaseLoader):
200
+ """Reco with attached loader helpers."""
201
+ ...
202
+
203
+
204
+ ConverterHook: TypeAlias = Mapping[str, Union[GetDataobjType[Any], GetAffineType, GetNifti1ImageType, ConvertType]]
205
+ """Mapping of converter hook keys to override callables."""
206
+
207
+
208
+ __all__ = [
209
+ 'GetDataobjType',
210
+ 'GetAffineType',
211
+ 'GetNifti1ImageType',
212
+ 'ConvertType',
213
+ 'ConverterHook',
214
+ 'StudyLoader',
215
+ 'ScanLoader',
216
+ 'RecoLoader',
217
+ ]
218
+
219
+ def __dir__() -> List[str]:
220
+ return sorted(__all__)
brkraw/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from brkraw.cli.main import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,2 @@
1
+ from __future__ import annotations
2
+
@@ -0,0 +1,327 @@
1
+ from __future__ import annotations
2
+ from typing import Dict, Optional
3
+
4
+ import argparse
5
+ import logging
6
+ import shlex
7
+ import subprocess
8
+ from pathlib import Path
9
+ from brkraw.core import config as config_core
10
+ from brkraw.core import formatter
11
+ from brkraw.apps import addon as addon_app
12
+
13
+ logger = logging.getLogger("brkraw")
14
+
15
+
16
+ def cmd_addon(args: argparse.Namespace) -> int:
17
+ handler = getattr(args, "addon_func", None)
18
+ if handler is None:
19
+ args.parser.print_help()
20
+ return 2
21
+ return handler(args)
22
+
23
+
24
+ def _normalize_row(row: Dict[str, str]) -> Dict[str, object]:
25
+ name = row.get("name", "")
26
+ desc = row.get("description", "")
27
+ category = row.get("category", "")
28
+ version = row.get("version", "")
29
+ name_cell: object = name
30
+ desc_cell: object = desc
31
+ category_cell: object = category
32
+ version_cell: object = version
33
+ if row.get("name_unknown") == "1":
34
+ name_cell = {"value": name, "color": "gray"}
35
+ if row.get("version_unknown") == "1":
36
+ version_cell = {"value": version, "color": "gray"}
37
+ if row.get("description_unknown") == "1":
38
+ desc_cell = {"value": desc, "color": "gray"}
39
+ if row.get("category_unknown") == "1":
40
+ category_cell = {"value": category, "color": "gray"}
41
+ return {
42
+ "file": row.get("file", ""),
43
+ "category": category_cell,
44
+ "name": name_cell,
45
+ "version": version_cell,
46
+ "description": desc_cell,
47
+ }
48
+
49
+ def _normalize_pruner_row(row: Dict[str, str]) -> Dict[str, object]:
50
+ name = row.get("name", "")
51
+ desc = row.get("description", "")
52
+ version = row.get("version", "")
53
+ name_cell: object = name
54
+ desc_cell: object = desc
55
+ version_cell: object = version
56
+ if row.get("name_unknown") == "1":
57
+ name_cell = {"value": name, "color": "gray"}
58
+ if row.get("version_unknown") == "1":
59
+ version_cell = {"value": version, "color": "gray"}
60
+ if row.get("description_unknown") == "1":
61
+ desc_cell = {"value": desc, "color": "gray"}
62
+ return {
63
+ "file": row.get("file", ""),
64
+ "name": name_cell,
65
+ "version": version_cell,
66
+ "description": desc_cell,
67
+ }
68
+
69
+
70
+ def _normalize_rule_row(row: Dict[str, str]) -> Dict[str, object]:
71
+ name = row.get("name", "")
72
+ desc = row.get("description", "")
73
+ category = row.get("category", "")
74
+ name_cell: object = name
75
+ desc_cell: object = desc
76
+ category_cell: object = category
77
+ if row.get("name_unknown") == "1":
78
+ name_cell = {"value": name, "color": "gray"}
79
+ if row.get("description_unknown") == "1":
80
+ desc_cell = {"value": desc, "color": "gray"}
81
+ if row.get("category_unknown") == "1":
82
+ category_cell = {"value": category, "color": "gray"}
83
+ return {
84
+ "file": row.get("file", ""),
85
+ "category": category_cell,
86
+ "name": name_cell,
87
+ "description": desc_cell,
88
+ }
89
+
90
+
91
+ def _normalize_transform_row(row: Dict[str, str]) -> Dict[str, object]:
92
+ spec = row.get("spec", "")
93
+ spec_cell: object = spec
94
+ if row.get("spec_unknown") == "1":
95
+ spec_cell = {"value": spec, "color": "gray"}
96
+ return {
97
+ "file": row.get("file", ""),
98
+ "spec": spec_cell,
99
+ }
100
+
101
+
102
+ def cmd_add(args: argparse.Namespace) -> int:
103
+ installed = addon_app.add(args.filename, root=args.root)
104
+ logger.info("Installed %d file(s).", len(installed))
105
+ return 0
106
+
107
+
108
+ def cmd_list(args: argparse.Namespace) -> int:
109
+ data = addon_app.list_installed(root=args.root)
110
+ width = config_core.output_width(root=args.root)
111
+ rules = data["rules"]
112
+ pruner_specs = data.get("pruner_specs", [])
113
+ columns = ("file", "category", "name", "version", "description")
114
+ pruner_columns = ("file", "name", "version", "description")
115
+ rule_columns = ("file", "category", "name", "description")
116
+ transform_columns = ("file", "spec")
117
+ spec_rows = [_normalize_row(row) for row in data["specs"]]
118
+ pruner_rows = [_normalize_pruner_row(row) for row in pruner_specs]
119
+ rules_rows = [_normalize_rule_row(row) for row in rules]
120
+ transform_rows = [_normalize_transform_row(row) for row in data["transforms"]]
121
+ category_order = {"info_spec": 0, "metadata_spec": 1, "converter_hook": 2, "<Unknown>": 9}
122
+ spec_rows.sort(
123
+ key=lambda row: (
124
+ category_order.get(str(row.get("category", "")), 9),
125
+ str(row.get("name", "")),
126
+ )
127
+ )
128
+ pruner_rows.sort(
129
+ key=lambda row: (
130
+ str(row.get("name", "")),
131
+ str(row.get("version", "")),
132
+ )
133
+ )
134
+ spec_widths = formatter.compute_column_widths(columns, spec_rows)
135
+ col_widths = formatter.compute_column_widths(columns, spec_rows)
136
+ pruner_widths = formatter.compute_column_widths(pruner_columns, pruner_rows)
137
+ spec_table = formatter.format_table(
138
+ "Specs",
139
+ columns,
140
+ spec_rows,
141
+ width=width,
142
+ colors={"file": "gray", "name": "cyan", "description": "gray"},
143
+ title_color="cyan",
144
+ col_widths=col_widths,
145
+ )
146
+ rules_table = formatter.format_table(
147
+ "Rules",
148
+ rule_columns,
149
+ rules_rows,
150
+ width=width,
151
+ colors={"file": "gray", "name": "yellow", "description": "gray"},
152
+ title_color="yellow",
153
+ col_widths=formatter.compute_column_widths(rule_columns, rules_rows),
154
+ )
155
+ pruner_table = formatter.format_table(
156
+ "Pruner Specs",
157
+ pruner_columns,
158
+ pruner_rows,
159
+ width=width,
160
+ colors={"file": "gray", "name": "magenta", "description": "gray"},
161
+ title_color="magenta",
162
+ col_widths=pruner_widths,
163
+ min_last_col_width=40,
164
+ )
165
+ transforms_table = formatter.format_table(
166
+ "Transforms",
167
+ transform_columns,
168
+ transform_rows,
169
+ width=width,
170
+ colors={"file": "gray", "spec": "cyan"},
171
+ title_color="cyan",
172
+ )
173
+ logger.info("%s", rules_table)
174
+ logger.info("")
175
+ logger.info("%s", spec_table)
176
+ if pruner_rows:
177
+ logger.info("")
178
+ logger.info("%s", pruner_table)
179
+ if transform_rows:
180
+ logger.info("")
181
+ logger.info("%s", transforms_table)
182
+ return 0
183
+
184
+
185
+ def cmd_rm(args: argparse.Namespace) -> int:
186
+ try:
187
+ removed = addon_app.remove(
188
+ args.filename,
189
+ root=args.root,
190
+ kind=args.kind,
191
+ force=args.force,
192
+ )
193
+ except FileNotFoundError as exc:
194
+ logger.error("No matching addon files found: %s", exc)
195
+ return 2
196
+ except RuntimeError as exc:
197
+ logger.error("%s", exc)
198
+ return 2
199
+ logger.info("Removed %d file(s).", len(removed))
200
+ return 0
201
+
202
+
203
+
204
+
205
+ def cmd_edit(args: argparse.Namespace) -> int:
206
+ editor = config_core.resolve_editor_binary(root=args.root)
207
+ if not editor:
208
+ logger.error("No editor configured. Set editor or $EDITOR.")
209
+ return 2
210
+ paths = config_core.paths(root=args.root)
211
+ target = _resolve_edit_target(
212
+ args.target,
213
+ kind=args.kind,
214
+ category=args.category,
215
+ root=args.root,
216
+ paths=paths,
217
+ )
218
+ cmd = shlex.split(editor) + [str(target)]
219
+ return subprocess.call(cmd)
220
+
221
+
222
+ def _resolve_edit_target(
223
+ target: str,
224
+ *,
225
+ kind: Optional[str],
226
+ category: Optional[str],
227
+ root: Optional[str],
228
+ paths: config_core.ConfigPaths,
229
+ ) -> Path:
230
+ candidate = Path(target).expanduser()
231
+ if candidate.exists():
232
+ return candidate.resolve()
233
+ if kind == "spec":
234
+ return addon_app.resolve_spec_reference(target, category=category, root=root)
235
+ if kind == "pruner":
236
+ return addon_app.resolve_pruner_spec_reference(target, root=root)
237
+ if kind == "rule":
238
+ return _resolve_rule_target(target, category=category, rules_dir=paths.rules_dir)
239
+ if kind == "transform":
240
+ return (paths.transforms_dir / target).resolve()
241
+ transform_candidate = (paths.transforms_dir / target).resolve()
242
+ if transform_candidate.exists():
243
+ return transform_candidate
244
+ pruner_candidate = (paths.pruner_specs_dir / target).resolve()
245
+ if pruner_candidate.exists():
246
+ return pruner_candidate
247
+ try:
248
+ return addon_app.resolve_spec_reference(target, category=category, root=root)
249
+ except Exception:
250
+ try:
251
+ return addon_app.resolve_pruner_spec_reference(target, root=root)
252
+ except Exception:
253
+ return _resolve_rule_target(target, category=category, rules_dir=paths.rules_dir)
254
+
255
+
256
+ def _resolve_spec_path(target: str, *, category: Optional[str], root: Optional[str]) -> Path:
257
+ return addon_app.resolve_spec_reference(target, category=category, root=root)
258
+
259
+
260
+ def _resolve_rule_target(target: str, *, category: Optional[str], rules_dir: Path) -> Path:
261
+ candidate = (rules_dir / target).resolve()
262
+ if candidate.exists():
263
+ return candidate
264
+ rules = addon_app.list_installed(root=str(rules_dir.parent)).get("rules", [])
265
+ matches = [
266
+ entry for entry in rules
267
+ if entry.get("name") == target and (category is None or entry.get("category") == category)
268
+ ]
269
+ file_set = {
270
+ file
271
+ for entry in matches
272
+ if isinstance((file := entry.get("file")), str) and file
273
+ }
274
+ files = sorted(file_set)
275
+ if len(files) == 1:
276
+ return (rules_dir / files[0]).resolve()
277
+ if not files:
278
+ raise FileNotFoundError(target)
279
+ raise ValueError(f"Multiple rule files match {target}: {', '.join(files)}")
280
+
281
+
282
+ def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[name-defined]
283
+ addon_parser = subparsers.add_parser(
284
+ "addon",
285
+ help="Manage info specs and rules.",
286
+ )
287
+ addon_parser.add_argument(
288
+ "--root",
289
+ help="Override config root directory (default: BRKRAW_CONFIG_HOME or ~/.brkraw).",
290
+ )
291
+ addon_parser.set_defaults(func=cmd_addon, parser=addon_parser)
292
+ addon_sub = addon_parser.add_subparsers(dest="addon_command")
293
+
294
+ add_parser = addon_sub.add_parser("add", help="Install a spec or rule file.")
295
+ add_parser.add_argument("filename", help="Spec/rule YAML.")
296
+ add_parser.set_defaults(addon_func=cmd_add)
297
+
298
+ list_parser = addon_sub.add_parser("list", help="List installed specs and rules.")
299
+ list_parser.set_defaults(addon_func=cmd_list)
300
+
301
+ rm_parser = addon_sub.add_parser("rm", help="Remove an installed spec or rule file.")
302
+ rm_parser.add_argument("filename", help="Spec/rule filename to remove.")
303
+ rm_parser.add_argument(
304
+ "--kind",
305
+ choices=["spec", "pruner", "rule", "transform"],
306
+ help="Limit removal to a specific kind.",
307
+ )
308
+ rm_parser.add_argument(
309
+ "--force",
310
+ action="store_true",
311
+ help="Remove even if dependencies are detected.",
312
+ )
313
+ rm_parser.set_defaults(addon_func=cmd_rm)
314
+
315
+
316
+ edit_parser = addon_sub.add_parser("edit", help="Edit an installed spec or rule.")
317
+ edit_parser.add_argument("target", help="Spec/rule name or filename.")
318
+ edit_parser.add_argument(
319
+ "--kind",
320
+ choices=["spec", "pruner", "rule", "transform"],
321
+ help="Target kind (default: auto-detect).",
322
+ )
323
+ edit_parser.add_argument(
324
+ "--category",
325
+ help="Spec/rule category hint (e.g. info_spec, metadata_spec).",
326
+ )
327
+ edit_parser.set_defaults(addon_func=cmd_edit)