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,500 @@
1
+ """Installation and listing helpers for addon specs and rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import resources
6
+
7
+ try:
8
+ resources.files # type: ignore[attr-defined]
9
+ except AttributeError: # pragma: no cover - fallback for Python 3.8
10
+ import importlib_resources as resources # type: ignore[assignment]
11
+ import logging
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Set, Tuple, Union
15
+
16
+ import yaml
17
+
18
+ from ...core import config as config_core
19
+ from ...specs import remapper
20
+ from ...specs.pruner import validator as pruner_validator
21
+ from ...specs.rules import validator as rules_validator
22
+ from .dependencies import (
23
+ RULE_KEYS,
24
+ collect_transforms_sources as _collect_transforms_sources,
25
+ extract_transforms_source as _extract_transforms_source,
26
+ load_pruner_spec_records as _load_pruner_spec_records,
27
+ load_spec_records as _load_spec_records,
28
+ normalize_transform_ref as _normalize_transform_ref,
29
+ warn_dependencies as _warn_dependencies,
30
+ resolve_spec_reference,
31
+ )
32
+ from .io import write_file as _write_file
33
+
34
+ logger = logging.getLogger("brkraw")
35
+
36
+
37
+ def add(path: Union[str, Path], root: Optional[Union[str, Path]] = None) -> List[Path]:
38
+ """Install a spec or rule YAML file."""
39
+ src = Path(path)
40
+ if not src.exists():
41
+ raise FileNotFoundError(src)
42
+ if src.suffix.lower() in {".yaml", ".yml"}:
43
+ return add_from_yaml(src, root=root)
44
+ raise ValueError(f"Unsupported file type: {src}")
45
+
46
+
47
+ def add_spec_data(
48
+ spec_data: Dict[str, Any],
49
+ *,
50
+ filename: Optional[str] = None,
51
+ source_path: Optional[Path] = None,
52
+ root: Optional[Union[str, Path]] = None,
53
+ transforms_dir: Optional[Path] = None,
54
+ ) -> List[Path]:
55
+ """Install a spec from parsed data."""
56
+ if not isinstance(spec_data, dict):
57
+ raise ValueError("Spec data must be a mapping.")
58
+ if filename is None:
59
+ if source_path is None:
60
+ raise ValueError("filename is required when source_path is not provided.")
61
+ filename = source_path.name
62
+ if not filename.endswith((".yaml", ".yml")):
63
+ raise ValueError(f"Spec filename must be .yaml/.yml: {filename}")
64
+ paths = config_core.paths(root=root)
65
+ target = paths.specs_dir / filename
66
+ installed = [target]
67
+ installed_transforms, updated = install_transforms_from_spec(
68
+ spec_data,
69
+ base_dir=source_path.parent if source_path else None,
70
+ target_spec=target,
71
+ root=root,
72
+ target_transforms_dir=transforms_dir,
73
+ )
74
+ installed += installed_transforms
75
+ content = yaml.safe_dump(spec_data, sort_keys=False)
76
+ _write_file(target, content)
77
+ logger.info("Installed spec: %s", target)
78
+ return installed
79
+
80
+
81
+ def add_pruner_spec_data(
82
+ spec_data: Dict[str, Any],
83
+ *,
84
+ filename: Optional[str] = None,
85
+ source_path: Optional[Path] = None,
86
+ root: Optional[Union[str, Path]] = None,
87
+ ) -> List[Path]:
88
+ """Install a pruner spec from parsed data."""
89
+ if not isinstance(spec_data, dict):
90
+ raise ValueError("Pruner spec data must be a mapping.")
91
+ if filename is None:
92
+ if source_path is None:
93
+ raise ValueError("filename is required when source_path is not provided.")
94
+ filename = source_path.name
95
+ if not filename.endswith((".yaml", ".yml")):
96
+ raise ValueError(f"Pruner spec filename must be .yaml/.yml: {filename}")
97
+ pruner_validator.validate_prune_spec(spec_data)
98
+ paths = config_core.paths(root=root)
99
+ target = paths.pruner_specs_dir / filename
100
+ content = yaml.safe_dump(spec_data, sort_keys=False)
101
+ _write_file(target, content)
102
+ logger.info("Installed pruner spec: %s", target)
103
+ return [target]
104
+
105
+
106
+ def add_rule_data(
107
+ rule_data: Dict[str, Any],
108
+ *,
109
+ filename: Optional[str] = None,
110
+ source_path: Optional[Path] = None,
111
+ root: Optional[Union[str, Path]] = None,
112
+ ) -> List[Path]:
113
+ """Install a rule file from parsed data."""
114
+ if not isinstance(rule_data, dict):
115
+ raise ValueError("Rule data must be a mapping.")
116
+ if filename is None:
117
+ if source_path is None:
118
+ raise ValueError("filename is required when source_path is not provided.")
119
+ filename = source_path.name
120
+ if not filename.endswith((".yaml", ".yml")):
121
+ raise ValueError(f"Rule filename must be .yaml/.yml: {filename}")
122
+ rules_validator.validate_rules(rule_data)
123
+ ensure_rule_specs_present(rule_data, root=root)
124
+ paths = config_core.paths(root=root)
125
+ target = paths.rules_dir / filename
126
+ content = yaml.safe_dump(rule_data, sort_keys=False)
127
+ _write_file(target, content)
128
+ logger.info("Installed rule: %s", target)
129
+ return [target]
130
+
131
+
132
+ def install_defaults(root: Optional[Union[str, Path]] = None) -> List[Path]:
133
+ """Install bundled default specs and rules."""
134
+ installed: List[Path] = []
135
+
136
+ base = resources.files("brkraw.default")
137
+ for rel_dir in ("specs", "rules", "pruner_specs"):
138
+ src_dir = base / rel_dir
139
+ if not src_dir.is_dir():
140
+ continue
141
+ for entry in src_dir.iterdir():
142
+ name = entry.name
143
+ if not (name.endswith(".yaml") or name.endswith(".yml")):
144
+ continue
145
+ content = entry.read_text(encoding="utf-8")
146
+ data = yaml.safe_load(content)
147
+ if not isinstance(data, dict):
148
+ continue
149
+ entry_path = Path(str(entry))
150
+ if rel_dir == "specs":
151
+ installed += add_spec_data(
152
+ data,
153
+ filename=name,
154
+ source_path=entry_path,
155
+ root=root,
156
+ )
157
+ elif rel_dir == "pruner_specs":
158
+ installed += add_pruner_spec_data(
159
+ data,
160
+ filename=name,
161
+ source_path=entry_path,
162
+ root=root,
163
+ )
164
+ else:
165
+ installed += add_rule_data(
166
+ data,
167
+ filename=name,
168
+ source_path=entry_path,
169
+ root=root,
170
+ )
171
+ return installed
172
+
173
+
174
+ def list_installed(root: Optional[Union[str, Path]] = None) -> Dict[str, List[Dict[str, str]]]:
175
+ """List installed specs and rules with metadata."""
176
+ paths = config_core.paths(root=root)
177
+ result: Dict[str, List[Dict[str, str]]] = {
178
+ "specs": [],
179
+ "pruner_specs": [],
180
+ "rules": [],
181
+ "transforms": [],
182
+ }
183
+
184
+ rule_entries = load_rule_entries(paths.rules_dir)
185
+ spec_categories = spec_categories_from_rules(rule_entries)
186
+
187
+ transforms_map: Dict[str, Set[str]] = {}
188
+ for record in _load_spec_records(paths.specs_dir):
189
+ name = record.get("name")
190
+ desc = record.get("description")
191
+ version = record.get("version")
192
+ category = (
193
+ record.get("category")
194
+ or spec_categories.get(record["file"])
195
+ or spec_categories.get(name or "")
196
+ )
197
+ result["specs"].append(
198
+ {
199
+ "file": record["file"],
200
+ "name": name if name else "<Unknown>",
201
+ "version": version if version else "<Unknown>",
202
+ "description": desc if desc else "<Unknown>",
203
+ "category": category if category else "<Unknown>",
204
+ "name_unknown": "1" if not name else "0",
205
+ "version_unknown": "1" if not version else "0",
206
+ "description_unknown": "1" if not desc else "0",
207
+ "category_unknown": "1" if not category else "0",
208
+ }
209
+ )
210
+ spec_path = record["path"]
211
+ spec_label = record["file"]
212
+ for src in _collect_transforms_sources(spec_path):
213
+ normalized = _normalize_transform_ref(
214
+ src,
215
+ spec_path=spec_path,
216
+ transforms_dir=paths.transforms_dir,
217
+ )
218
+ transforms_map.setdefault(normalized, set()).add(spec_label)
219
+
220
+ for entry in rule_entries:
221
+ result["rules"].append(entry)
222
+
223
+ for record in _load_pruner_spec_records(paths.pruner_specs_dir):
224
+ result["pruner_specs"].append(
225
+ {
226
+ "file": record["file"],
227
+ "name": record.get("name") or "<Unknown>",
228
+ "version": record.get("version") or "<Unknown>",
229
+ "description": record.get("description") or "<Unknown>",
230
+ "name_unknown": "1" if not record.get("name") else "0",
231
+ "version_unknown": "1" if not record.get("version") else "0",
232
+ "description_unknown": "1" if not record.get("description") else "0",
233
+ }
234
+ )
235
+
236
+ for path in sorted(paths.transforms_dir.rglob("*.py")):
237
+ relpath = str(path.relative_to(paths.transforms_dir))
238
+ mapped = transforms_map.get(relpath)
239
+ result["transforms"].append(
240
+ {
241
+ "file": relpath,
242
+ "spec": ", ".join(sorted(mapped)) if mapped else "<Unknown>",
243
+ "spec_unknown": "1" if not mapped else "0",
244
+ }
245
+ )
246
+
247
+ return result
248
+
249
+
250
+ def remove(
251
+ filename: Union[str, Path],
252
+ *,
253
+ root: Optional[Union[str, Path]] = None,
254
+ kind: Optional[str] = None,
255
+ force: bool = False,
256
+ ) -> List[Path]:
257
+ """Remove an installed spec/rule/transform file by filename.
258
+
259
+ Notes:
260
+ The `filename` argument matches the installed file name (not the
261
+ spec/rule `__meta__.name`).
262
+ """
263
+ name = Path(filename).name
264
+ paths = config_core.paths(root=root)
265
+ removed: List[Path] = []
266
+ kinds = [kind] if kind else ["spec", "pruner", "rule", "transform"]
267
+ targets = resolve_targets(name, kinds, paths)
268
+ if not targets:
269
+ raise FileNotFoundError(name)
270
+ has_deps = False
271
+ for target, item in targets:
272
+ has_deps = _warn_dependencies(target, kind=item, root=root) or has_deps
273
+ if has_deps and not force:
274
+ raise RuntimeError("Dependencies found; use --force to remove.")
275
+ for target, item in targets:
276
+ target.unlink()
277
+ removed.append(target)
278
+ if not removed:
279
+ raise FileNotFoundError(name)
280
+ return removed
281
+
282
+
283
+ def resolve_targets(
284
+ name: str,
285
+ kinds: List[str],
286
+ paths: config_core.ConfigPaths,
287
+ ) -> List[Tuple[Path, str]]:
288
+ targets: List[Tuple[Path, str]] = []
289
+ for item in kinds:
290
+ if item == "spec":
291
+ base = paths.specs_dir
292
+ elif item == "pruner":
293
+ base = paths.pruner_specs_dir
294
+ elif item == "rule":
295
+ base = paths.rules_dir
296
+ elif item == "transform":
297
+ base = paths.transforms_dir
298
+ else:
299
+ raise ValueError("kind must be 'spec' or 'pruner' or 'rule' or 'transform'.")
300
+ if not base.exists():
301
+ continue
302
+ candidate = (base / name).resolve()
303
+ if candidate.exists():
304
+ matches = [candidate]
305
+ else:
306
+ matches = [path for path in base.rglob(name) if path.is_file()]
307
+ for path in matches:
308
+ targets.append((path, item))
309
+ return targets
310
+
311
+
312
+ def add_from_yaml(path: Path, root: Optional[Union[str, Path]]) -> List[Path]:
313
+ """Install a spec or rule YAML after classifying the content."""
314
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
315
+ if data is None:
316
+ raise ValueError(f"Empty YAML file: {path}")
317
+ if not isinstance(data, dict):
318
+ raise ValueError(f"Rule/spec YAML must be a mapping: {path}")
319
+
320
+ kind = classify_yaml(data)
321
+ if kind == "rule":
322
+ return add_rule_data(data, filename=path.name, source_path=path, root=root)
323
+ if kind == "spec":
324
+ return add_spec_data(data, filename=path.name, source_path=path, root=root)
325
+ if kind == "pruner_spec":
326
+ return add_pruner_spec_data(data, filename=path.name, source_path=path, root=root)
327
+ raise ValueError(f"Unrecognized YAML file: {path}")
328
+
329
+
330
+ def classify_yaml(data: Dict[str, Any]) -> str:
331
+ """Classify YAML content as a spec or rule mapping."""
332
+ if RULE_KEYS.intersection(data.keys()):
333
+ rules_validator.validate_rules(data)
334
+ return "rule"
335
+ errors = remapper.validate_spec(data, raise_on_error=False)
336
+ if not errors:
337
+ return "spec"
338
+ try:
339
+ pruner_validator.validate_prune_spec(data)
340
+ except Exception:
341
+ pass
342
+ else:
343
+ return "pruner_spec"
344
+ rules_validator.validate_rules(data)
345
+ return "rule"
346
+
347
+
348
+ def update_transforms_source(spec_data: Dict[str, Any], value: List[str]) -> None:
349
+ """Rewrite transforms_source fields inside a spec mapping."""
350
+ meta = spec_data.get("__meta__")
351
+ if isinstance(meta, dict) and meta.get("transforms_source"):
352
+ meta["transforms_source"] = value[0] if len(value) == 1 else value
353
+ for section in spec_data.values():
354
+ if not isinstance(section, dict):
355
+ continue
356
+ section_meta = section.get("__meta__")
357
+ if isinstance(section_meta, dict) and section_meta.get("transforms_source"):
358
+ section_meta["transforms_source"] = value[0] if len(value) == 1 else value
359
+
360
+
361
+ def install_transforms_from_spec(
362
+ spec_data: Dict[str, Any],
363
+ *,
364
+ base_dir: Optional[Path],
365
+ target_spec: Path,
366
+ root: Optional[Union[str, Path]],
367
+ target_transforms_dir: Optional[Path] = None,
368
+ ) -> Tuple[List[Path], bool]:
369
+ """Install transforms referenced by a spec and rewrite paths."""
370
+ sources = _extract_transforms_source(spec_data)
371
+ if not sources:
372
+ return [], False
373
+ paths = config_core.paths(root=root)
374
+ transforms_dir = target_transforms_dir or paths.transforms_dir
375
+ installed: List[Path] = []
376
+ rel_paths: List[str] = []
377
+ for src in sources:
378
+ src_path = Path(src)
379
+ target = transforms_dir / src_path.name
380
+ if base_dir is not None:
381
+ candidate = (base_dir / src_path).resolve()
382
+ if not candidate.exists():
383
+ raise FileNotFoundError(candidate)
384
+ _write_file(target, candidate.read_text(encoding="utf-8"))
385
+ elif src_path.is_absolute():
386
+ if not src_path.exists():
387
+ raise FileNotFoundError(src_path)
388
+ _write_file(target, src_path.read_text(encoding="utf-8"))
389
+ else:
390
+ raise FileNotFoundError(src_path)
391
+ rel_paths.append(os.path.relpath(target, start=target_spec.parent))
392
+ installed.append(target)
393
+ logger.info("Installed transforms: %s", target)
394
+ update_transforms_source(spec_data, rel_paths)
395
+ return installed, True
396
+
397
+
398
+ def ensure_rule_specs_present(rule_data: Dict[str, Any], *, root: Optional[Union[str, Path]]) -> None:
399
+ """Ensure rule references point to installed specs."""
400
+ base = config_core.resolve_root(root)
401
+ for key in RULE_KEYS:
402
+ if key == "converter_hook":
403
+ continue
404
+ for item in rule_data.get(key, []) or []:
405
+ if not isinstance(item, dict):
406
+ continue
407
+ use = item.get("use")
408
+ if not isinstance(use, str):
409
+ continue
410
+ version = item.get("version") if isinstance(item.get("version"), str) else None
411
+ spec_path = resolve_spec_reference(
412
+ use,
413
+ category=key,
414
+ version=version,
415
+ root=base,
416
+ )
417
+ ensure_spec_category(spec_path, key)
418
+
419
+
420
+ def ensure_spec_category(spec_path: Path, category: str) -> None:
421
+ if category not in {"info_spec", "metadata_spec"}:
422
+ return
423
+ data = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
424
+ if not isinstance(data, dict):
425
+ raise ValueError(f"Spec file must be a mapping: {spec_path}")
426
+ meta = data.get("__meta__")
427
+ if not isinstance(meta, dict):
428
+ raise ValueError(f"{spec_path}: __meta__ must be an object.")
429
+ current = meta.get("category")
430
+ if current is None:
431
+ raise ValueError(f"{spec_path}: __meta__.category is required.")
432
+ if current != category:
433
+ raise ValueError(
434
+ f"{spec_path}: __meta__.category={current!r} conflicts with rule category {category!r}."
435
+ )
436
+
437
+
438
+ def load_rule_entries(rules_dir: Path) -> List[Dict[str, str]]:
439
+ """Load rule metadata for listing."""
440
+ entries: List[Dict[str, str]] = []
441
+ if not rules_dir.exists():
442
+ return entries
443
+ files = list(rules_dir.rglob("*.yaml")) + list(rules_dir.rglob("*.yml"))
444
+ for path in sorted(files):
445
+ relpath = str(path.relative_to(rules_dir))
446
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
447
+ if not isinstance(data, dict):
448
+ continue
449
+ for key in RULE_KEYS:
450
+ for item in data.get(key, []) or []:
451
+ if not isinstance(item, dict):
452
+ continue
453
+ entries.append(
454
+ {
455
+ "file": relpath,
456
+ "category": key,
457
+ "name": str(item.get("name", "")),
458
+ "description": str(item.get("description", "")),
459
+ "version": str(item.get("version", "")),
460
+ "use": str(item.get("use", "")),
461
+ }
462
+ )
463
+ return entries
464
+
465
+
466
+ def spec_categories_from_rules(rule_entries: List[Dict[str, str]]) -> Dict[str, str]:
467
+ """Derive spec category labels from rule entries."""
468
+ mapping: Dict[str, Set[str]] = {}
469
+ for entry in rule_entries:
470
+ category = entry.get("category", "")
471
+ if category not in {"info_spec", "metadata_spec"}:
472
+ continue
473
+ use = entry.get("use", "")
474
+ if not use:
475
+ continue
476
+ spec_name = Path(use).name if use else ""
477
+ if not spec_name:
478
+ continue
479
+ mapping.setdefault(spec_name, set()).add(category)
480
+ return {name: ", ".join(sorted(cats)) for name, cats in mapping.items()}
481
+
482
+
483
+ __all__ = [
484
+ "add",
485
+ "add_spec_data",
486
+ "add_pruner_spec_data",
487
+ "add_rule_data",
488
+ "install_defaults",
489
+ "list_installed",
490
+ "remove",
491
+ "resolve_targets",
492
+ "add_from_yaml",
493
+ "classify_yaml",
494
+ "update_transforms_source",
495
+ "install_transforms_from_spec",
496
+ "ensure_rule_specs_present",
497
+ "ensure_spec_category",
498
+ "load_rule_entries",
499
+ "spec_categories_from_rules",
500
+ ]
@@ -0,0 +1,21 @@
1
+ """Addon IO helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def write_file(target: Path, content: str) -> None:
9
+ """Write text content to a target path.
10
+
11
+ Args:
12
+ target: Output file path.
13
+ content: Text content to write.
14
+ """
15
+ target.parent.mkdir(parents=True, exist_ok=True)
16
+ target.write_text(content, encoding="utf-8")
17
+
18
+
19
+ __all__ = [
20
+ "write_file",
21
+ ]
@@ -0,0 +1,25 @@
1
+ """Hook package utilities for converter hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+ from .core import (
8
+ install_hook,
9
+ install_all,
10
+ list_hooks,
11
+ read_hook_docs,
12
+ uninstall_hook,
13
+ )
14
+
15
+ __all__ = [
16
+ "install_all",
17
+ "install_hook",
18
+ "list_hooks",
19
+ "read_hook_docs",
20
+ "uninstall_hook",
21
+ ]
22
+
23
+
24
+ def __dir__() -> List[str]:
25
+ return sorted(__all__)