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,636 @@
1
+ """Hook installer utilities for converter hook packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.metadata
7
+ import logging
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union, cast
11
+
12
+ try:
13
+ from importlib import resources
14
+ except ImportError: # pragma: no cover - fallback for Python 3.8
15
+ import importlib_resources as resources # type: ignore[assignment]
16
+ import yaml
17
+
18
+ from ...core import config as config_core
19
+ from ...core.entrypoints import list_entry_points
20
+ from ...specs.hook.logic import DEFAULT_GROUP
21
+ from ..addon import installation as addon_install
22
+ from ..addon import dependencies as addon_deps
23
+ from ..addon.io import write_file as _write_file
24
+
25
+ logger = logging.getLogger("brkraw")
26
+
27
+ REGISTRY_FILENAME = "hooks.yaml"
28
+ MANIFEST_NAMES = ("brkraw_hook.yaml", "brkraw_hook.yml")
29
+
30
+
31
+ def _metadata_get(
32
+ dist: Optional[importlib.metadata.Distribution],
33
+ key: str,
34
+ default: Optional[str] = None,
35
+ ) -> Optional[str]:
36
+ """Typed helper to read distribution metadata."""
37
+ if dist is None:
38
+ return default
39
+ meta = cast(Mapping[str, str], dist.metadata)
40
+ return meta.get(key, default)
41
+
42
+
43
+ def _packages_distributions() -> Mapping[str, List[str]]:
44
+ packages_distributions = getattr(importlib.metadata, "packages_distributions", None)
45
+ if packages_distributions is not None:
46
+ return cast(Callable[[], Mapping[str, List[str]]], packages_distributions)()
47
+
48
+ mapping: Dict[str, List[str]] = {}
49
+ for dist in importlib.metadata.distributions():
50
+ read_text = getattr(dist, "read_text", None)
51
+ top_level_text = ""
52
+ if callable(read_text):
53
+ top_level = read_text("top_level.txt")
54
+ if isinstance(top_level, str):
55
+ top_level_text = top_level
56
+ dist_name = _metadata_get(dist, "Name")
57
+ if not dist_name:
58
+ continue
59
+ for package in top_level_text.splitlines():
60
+ package = package.strip()
61
+ if package:
62
+ mapping.setdefault(package, []).append(dist_name)
63
+ return mapping
64
+
65
+
66
+ def list_hooks(*, root: Optional[Union[str, Path]] = None) -> List[Dict[str, Any]]:
67
+ hooks = _collect_hooks()
68
+ registry = _load_registry(root=root)
69
+ installed = registry.get("hooks", {})
70
+ root_path = config_core.resolve_root(root)
71
+ for hook in hooks:
72
+ entry = installed.get(hook["name"])
73
+ hook["installed"] = bool(entry)
74
+ hook["installed_version"] = entry.get("version") if isinstance(entry, dict) else None
75
+ hook["install_status"] = _install_status(entry, root=root_path)
76
+ return hooks
77
+
78
+
79
+ def install_all(
80
+ *,
81
+ root: Optional[Union[str, Path]] = None,
82
+ upgrade: bool = False,
83
+ force: bool = False,
84
+ ) -> Dict[str, List[str]]:
85
+ hooks = _collect_hooks()
86
+ installed: List[str] = []
87
+ skipped: List[str] = []
88
+ for hook in hooks:
89
+ status = _install_hook(hook, root=root, upgrade=upgrade, force=force)
90
+ if status == "installed":
91
+ installed.append(hook["name"])
92
+ else:
93
+ skipped.append(hook["name"])
94
+ return {"installed": installed, "skipped": skipped}
95
+
96
+
97
+ def install_hook(
98
+ target: str,
99
+ *,
100
+ root: Optional[Union[str, Path]] = None,
101
+ upgrade: bool = False,
102
+ force: bool = False,
103
+ ) -> str:
104
+ hook = _resolve_hook_target(target)
105
+ return _install_hook(hook, root=root, upgrade=upgrade, force=force)
106
+
107
+
108
+ def read_hook_docs(
109
+ target: str,
110
+ *,
111
+ root: Optional[Union[str, Path]] = None,
112
+ ) -> Tuple[str, str]:
113
+ hook = _resolve_hook_target(target)
114
+ manifest_path, manifest = _load_manifest(hook["dist"], hook.get("packages"))
115
+ docs_path = _resolve_docs_path(manifest, manifest_path)
116
+ if docs_path is None:
117
+ raise FileNotFoundError(f"Hook docs not found for {hook['name']}")
118
+ return hook["name"], docs_path.read_text(encoding="utf-8")
119
+
120
+
121
+ def uninstall_hook(
122
+ target: str,
123
+ *,
124
+ root: Optional[Union[str, Path]] = None,
125
+ force: bool = False,
126
+ ) -> Tuple[str, Dict[str, List[str]], bool]:
127
+ registry = _load_registry(root=root)
128
+ hooks = registry.get("hooks", {})
129
+ hook_name = _resolve_hook_name(target)
130
+ entry = hooks.get(hook_name)
131
+ if entry is None:
132
+ entry_matches = [
133
+ name
134
+ for name, data in hooks.items()
135
+ if target in (data.get("entrypoints") or [])
136
+ ]
137
+ if len(entry_matches) == 1:
138
+ hook_name = entry_matches[0]
139
+ entry = hooks.get(hook_name)
140
+ elif entry_matches:
141
+ names = ", ".join(sorted(entry_matches))
142
+ raise ValueError(f"Multiple hooks match {target}: {names}")
143
+ if entry is None:
144
+ raise LookupError(f"Hook not installed: {hook_name}")
145
+ module_missing = not list_entry_points(DEFAULT_GROUP, name=hook_name)
146
+ removed: Dict[str, List[str]] = {
147
+ "specs": [],
148
+ "pruner_specs": [],
149
+ "rules": [],
150
+ "transforms": [],
151
+ }
152
+ root_path = config_core.resolve_root(root)
153
+ for kind in ("specs", "pruner_specs", "rules", "transforms"):
154
+ for relpath in entry.get(kind, []) if isinstance(entry, dict) else []:
155
+ target_path = root_path / relpath
156
+ if not target_path.exists():
157
+ continue
158
+ if _has_dependencies(target_path, kind, root=root_path) and not force:
159
+ raise RuntimeError("Dependencies found; use --force to remove.")
160
+ target_path.unlink()
161
+ removed[kind].append(relpath)
162
+ hooks.pop(hook_name, None)
163
+ _save_registry(registry, root=root)
164
+ return hook_name, removed, module_missing
165
+
166
+
167
+ def _install_hook(
168
+ hook: Dict[str, Any],
169
+ *,
170
+ root: Optional[Union[str, Path]],
171
+ upgrade: bool,
172
+ force: bool,
173
+ ) -> str:
174
+ registry = _load_registry(root=root)
175
+ hooks = registry.setdefault("hooks", {})
176
+ existing = hooks.get(hook["name"])
177
+ if isinstance(existing, dict):
178
+ if not upgrade and not force:
179
+ return "skipped"
180
+ if not force:
181
+ installed_version = existing.get("version")
182
+ if installed_version and not _is_version_newer(hook["version"], installed_version):
183
+ return "skipped"
184
+ manifest_path, manifest = _load_manifest(hook["dist"], hook.get("packages"))
185
+ namespace = _namespace_for_hook(hook["name"])
186
+ installed = _install_manifest(
187
+ manifest,
188
+ manifest_path,
189
+ root=root,
190
+ namespace=namespace,
191
+ )
192
+ hooks[hook["name"]] = {
193
+ "version": hook["version"],
194
+ "entrypoints": hook["entrypoints"],
195
+ "namespace": namespace,
196
+ **installed,
197
+ }
198
+ _save_registry(registry, root=root)
199
+ return "installed"
200
+
201
+
202
+ def _kind_to_remove(kind: str) -> str:
203
+ if kind == "specs":
204
+ return "spec"
205
+ if kind == "pruner_specs":
206
+ return "pruner"
207
+ if kind == "rules":
208
+ return "rule"
209
+ if kind == "transforms":
210
+ return "transform"
211
+ raise ValueError(f"Unknown hook kind: {kind}")
212
+
213
+
214
+ def _install_manifest(
215
+ manifest: Mapping[str, Any],
216
+ manifest_path: Path,
217
+ *,
218
+ root: Optional[Union[str, Path]],
219
+ namespace: str,
220
+ ) -> Dict[str, List[str]]:
221
+ installed: Dict[str, List[str]] = {
222
+ "specs": [],
223
+ "pruner_specs": [],
224
+ "rules": [],
225
+ "transforms": [],
226
+ }
227
+ base_dir = manifest_path.parent
228
+ paths = config_core.paths(root=root)
229
+ spec_basenames = {
230
+ Path(item).name
231
+ for item in _normalize_manifest_list(manifest.get("specs"))
232
+ }
233
+ for spec in _normalize_manifest_list(manifest.get("specs")):
234
+ src = _resolve_manifest_path(base_dir, spec)
235
+ spec_data = _read_yaml(src)
236
+ paths_installed = addon_install.add_spec_data(
237
+ spec_data,
238
+ filename=str(Path(namespace) / src.name),
239
+ source_path=src,
240
+ root=root,
241
+ transforms_dir=paths.transforms_dir / namespace,
242
+ )
243
+ _record_installed_paths(paths_installed, installed, root=root)
244
+ for pruner_spec in _normalize_manifest_list(manifest.get("pruner_specs")):
245
+ src = _resolve_manifest_path(base_dir, pruner_spec)
246
+ spec_data = _read_yaml(src)
247
+ paths_installed = addon_install.add_pruner_spec_data(
248
+ spec_data,
249
+ filename=str(Path(namespace) / src.name),
250
+ source_path=src,
251
+ root=root,
252
+ )
253
+ _record_installed_paths(paths_installed, installed, root=root)
254
+ for rule in _normalize_manifest_list(manifest.get("rules")):
255
+ src = _resolve_manifest_path(base_dir, rule)
256
+ rule_data = _read_yaml(src)
257
+ _rewrite_rule_uses(rule_data, namespace, spec_basenames)
258
+ paths_installed = addon_install.add_rule_data(
259
+ rule_data,
260
+ filename=str(Path(namespace) / src.name),
261
+ source_path=src,
262
+ root=root,
263
+ )
264
+ _record_installed_paths(paths_installed, installed, root=root)
265
+ for transform in _normalize_manifest_list(manifest.get("transforms")):
266
+ src = _resolve_manifest_path(base_dir, transform)
267
+ target = paths.transforms_dir / namespace / src.name
268
+ _write_file(target, src.read_text(encoding="utf-8"))
269
+ installed["transforms"].append(_relative_to_root(target, root=root))
270
+ logger.info("Installed transforms: %s", target)
271
+ return installed
272
+
273
+
274
+ def _record_installed_paths(
275
+ paths: Sequence[Path],
276
+ installed: Dict[str, List[str]],
277
+ *,
278
+ root: Optional[Union[str, Path]],
279
+ ) -> None:
280
+ config_paths = config_core.paths(root=root)
281
+ for path in paths:
282
+ relpath = _relative_to_root(path, root=root)
283
+ try:
284
+ path.relative_to(config_paths.specs_dir)
285
+ except ValueError:
286
+ pass
287
+ else:
288
+ installed["specs"].append(relpath)
289
+ continue
290
+ try:
291
+ path.relative_to(config_paths.pruner_specs_dir)
292
+ except ValueError:
293
+ pass
294
+ else:
295
+ installed["pruner_specs"].append(relpath)
296
+ continue
297
+ try:
298
+ path.relative_to(config_paths.rules_dir)
299
+ except ValueError:
300
+ pass
301
+ else:
302
+ installed["rules"].append(relpath)
303
+ continue
304
+ try:
305
+ path.relative_to(config_paths.transforms_dir)
306
+ except ValueError:
307
+ continue
308
+ installed["transforms"].append(relpath)
309
+
310
+
311
+ def _normalize_manifest_list(value: Any) -> List[str]:
312
+ if value is None:
313
+ return []
314
+ if isinstance(value, str):
315
+ return [value]
316
+ if isinstance(value, list):
317
+ items = [item for item in value if isinstance(item, str) and item.strip()]
318
+ return items
319
+ raise ValueError("Manifest entries must be a list of strings.")
320
+
321
+
322
+ def _resolve_manifest_path(base_dir: Path, value: str) -> Path:
323
+ path = Path(value).expanduser()
324
+ if not path.is_absolute():
325
+ path = (base_dir / path).resolve()
326
+ if not path.exists():
327
+ raise FileNotFoundError(path)
328
+ return path
329
+
330
+
331
+ def _read_yaml(path: Path) -> Dict[str, Any]:
332
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
333
+ if data is None:
334
+ raise ValueError(f"Empty YAML file: {path}")
335
+ if not isinstance(data, dict):
336
+ raise ValueError(f"YAML file must be a mapping: {path}")
337
+ return data
338
+
339
+
340
+ def _collect_hooks() -> List[Dict[str, Any]]:
341
+ hooks: Dict[str, Dict[str, Any]] = {}
342
+ for ep in list_entry_points(DEFAULT_GROUP):
343
+ dist = _resolve_distribution(ep)
344
+ dist_name = _dist_name(dist) or ep.name
345
+ entry = hooks.setdefault(
346
+ dist_name,
347
+ {
348
+ "name": dist_name,
349
+ "entrypoints": [],
350
+ "packages": set(),
351
+ "dist": dist,
352
+ "version": _dist_version(dist),
353
+ "author": _dist_author(dist),
354
+ "description": _dist_description(dist),
355
+ },
356
+ )
357
+ entry["entrypoints"].append(ep.name)
358
+ pkg = _entrypoint_package(ep)
359
+ if pkg:
360
+ entry["packages"].add(pkg)
361
+ for hook in hooks.values():
362
+ packages = hook.get("packages")
363
+ if isinstance(packages, set):
364
+ hook["packages"] = sorted(packages)
365
+ return sorted(hooks.values(), key=lambda item: item["name"])
366
+
367
+
368
+ def _resolve_hook_target(target: str) -> Dict[str, Any]:
369
+ hooks = _collect_hooks()
370
+ name_matches = [hook for hook in hooks if hook["name"] == target]
371
+ if len(name_matches) == 1:
372
+ return name_matches[0]
373
+ entry_matches = [
374
+ hook for hook in hooks if target in hook.get("entrypoints", [])
375
+ ]
376
+ if len(entry_matches) == 1:
377
+ return entry_matches[0]
378
+ if not name_matches and not entry_matches:
379
+ raise LookupError(f"Unknown hook: {target}")
380
+ names = sorted({hook["name"] for hook in name_matches + entry_matches})
381
+ raise ValueError(f"Multiple hooks match {target}: {', '.join(names)}")
382
+
383
+
384
+ def _resolve_hook_name(target: str) -> str:
385
+ try:
386
+ return _resolve_hook_target(target)["name"]
387
+ except LookupError:
388
+ return target
389
+
390
+
391
+ def _resolve_distribution(ep: importlib.metadata.EntryPoint) -> Optional[importlib.metadata.Distribution]:
392
+ dist = getattr(ep, "dist", None)
393
+ if dist is not None:
394
+ return dist
395
+ pkg = getattr(ep, "module", "").split(".")[0]
396
+ if not pkg:
397
+ return None
398
+ mapping = _packages_distributions()
399
+ dist_names = mapping.get(pkg, [])
400
+ if not dist_names:
401
+ return None
402
+ try:
403
+ return importlib.metadata.distribution(dist_names[0])
404
+ except importlib.metadata.PackageNotFoundError:
405
+ return None
406
+
407
+
408
+ def _dist_name(dist: Optional[importlib.metadata.Distribution]) -> Optional[str]:
409
+ if dist is None:
410
+ return None
411
+ return _metadata_get(dist, "Name")
412
+
413
+
414
+ def _dist_version(dist: Optional[importlib.metadata.Distribution]) -> str:
415
+ if dist is None:
416
+ return "<Unknown>"
417
+ return _metadata_get(dist, "Version", "<Unknown>") or "<Unknown>"
418
+
419
+
420
+ def _dist_description(dist: Optional[importlib.metadata.Distribution]) -> str:
421
+ if dist is None:
422
+ return "<Unknown>"
423
+ return _metadata_get(dist, "Summary", "<Unknown>") or "<Unknown>"
424
+
425
+
426
+ def _dist_author(dist: Optional[importlib.metadata.Distribution]) -> str:
427
+ if dist is None:
428
+ return "<Unknown>"
429
+ for key in ("Author", "Author-email", "Maintainer", "Maintainer-email"):
430
+ value = _metadata_get(dist, key)
431
+ if value:
432
+ return value
433
+ return "<Unknown>"
434
+
435
+
436
+ def _load_manifest(
437
+ dist: Optional[importlib.metadata.Distribution],
438
+ packages: Optional[Sequence[str]] = None,
439
+ ) -> Tuple[Path, Dict[str, Any]]:
440
+ if dist is None:
441
+ raise FileNotFoundError("Hook distribution not available.")
442
+ manifest = _find_manifest(dist, packages=packages)
443
+ if manifest is None:
444
+ raise FileNotFoundError(
445
+ f"No hook manifest found in {_metadata_get(dist, 'Name', '<Unknown>')}"
446
+ )
447
+ data = _read_yaml(manifest)
448
+ return manifest, data
449
+
450
+
451
+ def _find_manifest(
452
+ dist: importlib.metadata.Distribution,
453
+ *,
454
+ packages: Optional[Sequence[str]] = None,
455
+ ) -> Optional[Path]:
456
+ files = dist.files or []
457
+ for name in MANIFEST_NAMES:
458
+ for entry in files:
459
+ if entry.name == name:
460
+ return Path(str(dist.locate_file(entry)))
461
+ search_packages = list(packages or []) or _dist_top_level(dist)
462
+ for package in search_packages:
463
+ for name in MANIFEST_NAMES:
464
+ try:
465
+ candidate = resources.files(package).joinpath(name)
466
+ except Exception:
467
+ candidate = None
468
+ if candidate is not None and candidate.is_file():
469
+ return Path(str(candidate))
470
+ fallback = _find_manifest_in_module(package)
471
+ if fallback is not None:
472
+ return fallback
473
+ return None
474
+
475
+
476
+ def _entrypoint_package(ep: importlib.metadata.EntryPoint) -> Optional[str]:
477
+ module = getattr(ep, "module", None)
478
+ if not module:
479
+ value = getattr(ep, "value", "")
480
+ module = value.split(":", 1)[0] if value else ""
481
+ if not module:
482
+ return None
483
+ return module.split(".", 1)[0]
484
+
485
+
486
+ def _find_manifest_in_module(package: str) -> Optional[Path]:
487
+ try:
488
+ module = importlib.import_module(package)
489
+ except Exception:
490
+ return None
491
+ module_file = getattr(module, "__file__", None)
492
+ if not module_file:
493
+ return None
494
+ base = Path(module_file).resolve().parent
495
+ for name in MANIFEST_NAMES:
496
+ candidate = base / name
497
+ if candidate.is_file():
498
+ return candidate
499
+ return None
500
+
501
+
502
+ def _dist_top_level(dist: importlib.metadata.Distribution) -> List[str]:
503
+ raw = dist.read_text("top_level.txt")
504
+ if not raw:
505
+ return []
506
+ return [line.strip() for line in raw.splitlines() if line.strip()]
507
+
508
+
509
+ def _registry_path(root: Optional[Union[str, Path]]) -> Path:
510
+ base = config_core.resolve_root(root)
511
+ return base / REGISTRY_FILENAME
512
+
513
+
514
+ def _load_registry(*, root: Optional[Union[str, Path]]) -> Dict[str, Any]:
515
+ path = _registry_path(root)
516
+ if not path.exists():
517
+ return {"hooks": {}}
518
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
519
+ if not isinstance(data, dict):
520
+ return {"hooks": {}}
521
+ if "hooks" not in data:
522
+ data["hooks"] = {}
523
+ return data
524
+
525
+
526
+ def _save_registry(data: Mapping[str, Any], *, root: Optional[Union[str, Path]]) -> None:
527
+ config_core.ensure_initialized(root=root, create_config=True, exist_ok=True)
528
+ path = _registry_path(root)
529
+ content = yaml.safe_dump(dict(data), sort_keys=False)
530
+ path.write_text(content, encoding="utf-8")
531
+
532
+
533
+ def _is_version_newer(version: str, installed: str) -> bool:
534
+ if version == "<Unknown>" or installed == "<Unknown>":
535
+ return True
536
+ try:
537
+ from packaging.version import Version # type: ignore
538
+ except Exception:
539
+ return _compare_fallback(version, installed) > 0
540
+ return Version(version) > Version(installed)
541
+
542
+
543
+ def _compare_fallback(a: str, b: str) -> int:
544
+ def _split(value: str) -> Tuple[Tuple[int, ...], str]:
545
+ nums = tuple(int(item) for item in re.findall(r"\d+", value))
546
+ return nums, value
547
+
548
+ a_nums, a_raw = _split(a)
549
+ b_nums, b_raw = _split(b)
550
+ if a_nums != b_nums:
551
+ return (a_nums > b_nums) - (a_nums < b_nums)
552
+ return (a_raw > b_raw) - (a_raw < b_raw)
553
+
554
+
555
+ def _namespace_for_hook(name: str) -> str:
556
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_") or "hook"
557
+
558
+
559
+ def _rewrite_rule_uses(rule_data: Dict[str, Any], namespace: str, spec_basenames: set[str]) -> None:
560
+ for key in addon_deps.RULE_KEYS:
561
+ if key == "converter_hook":
562
+ continue
563
+ entries = rule_data.get(key) or []
564
+ if not isinstance(entries, list):
565
+ continue
566
+ for item in entries:
567
+ if not isinstance(item, dict):
568
+ continue
569
+ use = item.get("use")
570
+ if not isinstance(use, str):
571
+ continue
572
+ base = Path(use).name
573
+ if base in spec_basenames:
574
+ item["use"] = str(Path("specs") / namespace / base)
575
+
576
+
577
+ def _relative_to_root(path: Path, *, root: Optional[Union[str, Path]]) -> str:
578
+ base = config_core.resolve_root(root)
579
+ try:
580
+ return str(path.resolve().relative_to(base))
581
+ except ValueError:
582
+ return str(path.name)
583
+
584
+
585
+ def _has_dependencies(
586
+ target: Path,
587
+ kind: str,
588
+ *,
589
+ root: Optional[Union[str, Path]],
590
+ ) -> bool:
591
+ try:
592
+ return addon_deps.warn_dependencies(target, kind=_kind_to_remove(kind), root=root)
593
+ except Exception:
594
+ return False
595
+
596
+
597
+ def _resolve_docs_path(manifest: Mapping[str, Any], manifest_path: Path) -> Optional[Path]:
598
+ value = manifest.get("docs")
599
+ if not value:
600
+ value = manifest.get("readme")
601
+ if not isinstance(value, str):
602
+ return None
603
+ return _resolve_manifest_path(manifest_path.parent, value)
604
+
605
+
606
+ def _install_status(entry: Any, *, root: Path) -> str:
607
+ if not isinstance(entry, dict):
608
+ return "No"
609
+ paths: List[str] = []
610
+ for kind in ("specs", "pruner_specs", "rules", "transforms"):
611
+ items = entry.get(kind, [])
612
+ if isinstance(items, list):
613
+ paths.extend([item for item in items if isinstance(item, str) and item.strip()])
614
+ if not paths:
615
+ return "Yes"
616
+ missing = 0
617
+ for relpath in paths:
618
+ if not (root / relpath).exists():
619
+ missing += 1
620
+ if missing == 0:
621
+ return "Yes"
622
+ return "Partially"
623
+
624
+ a_nums, a_raw = _split(a)
625
+ b_nums, b_raw = _split(b)
626
+ if a_nums != b_nums:
627
+ return (a_nums > b_nums) - (a_nums < b_nums)
628
+ return (a_raw > b_raw) - (a_raw < b_raw)
629
+
630
+
631
+ __all__ = [
632
+ "install_all",
633
+ "install_hook",
634
+ "list_hooks",
635
+ "uninstall_hook",
636
+ ]
@@ -0,0 +1,10 @@
1
+ """BrkRaw loader package entrypoint.
2
+
3
+ Last updated: 2025-12-30
4
+ """
5
+ from __future__ import annotations
6
+
7
+
8
+ from .core import BrukerLoader
9
+
10
+ __all__ = ["BrukerLoader"]