brkraw 0.5.3__py3-none-any.whl → 0.5.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
brkraw/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = '0.5.3'
3
+ __version__ = '0.5.6'
4
4
  from .apps.loader import BrukerLoader
5
5
 
6
6
 
brkraw/api/__init__.py ADDED
@@ -0,0 +1,122 @@
1
+ """Public API surface for BrkRaw.
2
+
3
+ This module intentionally re-exports a curated set of symbols for external use.
4
+ To keep import-time fast and reduce side effects, most symbols are lazily
5
+ imported on first access (PEP 562: module `__getattr__`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib import import_module
11
+ from typing import TYPE_CHECKING, Any, Dict, Tuple
12
+
13
+ # Public API -----------------------------------------------------------------
14
+
15
+ __all__ = [
16
+ "formatter",
17
+ "BrukerLoader",
18
+ "loader",
19
+ "hook",
20
+ "hook_manager",
21
+ "hook_resolver",
22
+ "pruner",
23
+ "rules",
24
+ "addon",
25
+ "addon_manager",
26
+ "validate_meta",
27
+ "transform",
28
+ "info_resolver",
29
+ "affine_resolver",
30
+ "shape_resolver",
31
+ "image_resolver",
32
+ "fid_resolver",
33
+ "nifti_resolver",
34
+ "types",
35
+ "config",
36
+ ]
37
+
38
+ # Lazy import map: name -> (module_path, attribute_name or None)
39
+ # If attribute_name is None, the module itself is returned.
40
+ _LAZY: Dict[str, Tuple[str, str | None]] = {
41
+ # core
42
+ "formatter": ("brkraw.core", "formatter"),
43
+
44
+ # apps
45
+ "BrukerLoader": ("brkraw.apps.loader", "BrukerLoader"),
46
+ "loader": ("brkraw.apps", "loader"),
47
+ "hook_manager": ("brkraw.apps", "hook"),
48
+ "addon_manager": ("brkraw.apps", "addon"),
49
+ "hook_resolver": ("brkraw.apps.loader.helper", "resolve_converter_hook"),
50
+ "config": ("brkraw.core", "config"),
51
+
52
+ # apps.loader.info resolvers
53
+ "info_resolver": ("brkraw.apps.loader", "info"),
54
+ "transform": ("brkraw.apps.loader.info", "transform"),
55
+
56
+ # resolvers
57
+ "affine_resolver": ("brkraw.resolver", "affine"),
58
+ "shape_resolver": ("brkraw.resolver", "shape"),
59
+ "image_resolver": ("brkraw.resolver", "image"),
60
+ "fid_resolver": ("brkraw.resolver", "fid"),
61
+ "nifti_resolver": ("brkraw.resolver", "nifti"),
62
+
63
+ # specs
64
+ "hook": ("brkraw.specs", "hook"),
65
+ "pruner": ("brkraw.specs", "pruner"),
66
+ "rules": ("brkraw.specs", "rules"),
67
+ "addon": ("brkraw.specs", "remapper"),
68
+
69
+ # meta
70
+ "validate_meta": ("brkraw.specs.meta", "validate_meta"),
71
+
72
+ # local
73
+ "types": ("brkraw.api", "types"),
74
+ }
75
+
76
+
77
+ def __getattr__(name: str) -> Any:
78
+ """Lazily import and return public symbols.
79
+
80
+ This keeps import-time minimal while preserving the convenient
81
+ `brkraw.api.<symbol>` access pattern.
82
+ """
83
+
84
+ try:
85
+ mod_path, attr = _LAZY[name]
86
+ except KeyError as e:
87
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from e
88
+
89
+ mod = import_module(mod_path)
90
+ obj = mod if attr is None else getattr(mod, attr)
91
+
92
+ # Cache the resolved object on the module for fast subsequent access.
93
+ globals()[name] = obj
94
+ return obj
95
+
96
+
97
+ def __dir__() -> list[str]:
98
+ # Expose the curated public surface in interactive environments.
99
+ return sorted(set(__all__))
100
+
101
+
102
+ # Type-checking / IDE support -------------------------------------------------
103
+ # Importing these only for type-checking keeps runtime imports lazy.
104
+ if TYPE_CHECKING:
105
+ from brkraw.core import formatter as formatter
106
+ from brkraw.core import config as config
107
+ from brkraw.apps.loader import BrukerLoader as BrukerLoader
108
+ from brkraw.apps.loader import info as info_resolver
109
+ from brkraw.apps.loader.info import transform as transform
110
+ from brkraw.apps.loader.helper import resolve_converter_hook as hook_resolver
111
+ from brkraw.apps import addon as addon_manager, hook as hook_manager, loader as loader
112
+ from brkraw.resolver import (
113
+ affine as affine_resolver,
114
+ fid as fid_resolver,
115
+ image as image_resolver,
116
+ nifti as nifti_resolver,
117
+ shape as shape_resolver,
118
+ )
119
+ from brkraw.specs import hook as hook, pruner as pruner, rules as rules
120
+ from brkraw.specs import remapper as addon
121
+ from brkraw.specs.meta import validate_meta as validate_meta
122
+ from brkraw.api import types as types
brkraw/api/types.py ADDED
@@ -0,0 +1,39 @@
1
+ from ..apps.loader.types import (
2
+ StudyLoader,
3
+ ScanLoader,
4
+ RecoLoader
5
+ )
6
+ from ..apps.loader.types import (
7
+ GetAffineType,
8
+ GetDataobjType,
9
+ ConvertType,
10
+ ConverterHook,
11
+ SubjectType,
12
+ SubjectPose,
13
+ AffineSpace,
14
+ Affines,
15
+ Dataobjs,
16
+ Metadata,
17
+ ConvertedObj,
18
+ HookArgs
19
+ )
20
+ from ..core.parameters import Parameters
21
+
22
+ __all__ = [
23
+ "GetAffineType",
24
+ "GetDataobjType",
25
+ "ConvertType",
26
+ "ConverterHook",
27
+ "StudyLoader",
28
+ "ScanLoader",
29
+ "RecoLoader",
30
+ "SubjectType",
31
+ "SubjectPose",
32
+ "AffineSpace",
33
+ "Affines",
34
+ "Dataobjs",
35
+ "Metadata",
36
+ "ConvertedObj",
37
+ "HookArgs",
38
+ "Parameters"
39
+ ]
@@ -1,10 +1,7 @@
1
- """BrkRaw loader package entrypoint.
2
-
3
- Last updated: 2025-12-30
4
- """
5
1
  from __future__ import annotations
6
2
 
7
-
8
3
  from .core import BrukerLoader
9
4
 
10
- __all__ = ["BrukerLoader"]
5
+ __all__ = [
6
+ "BrukerLoader"
7
+ ]
@@ -3,8 +3,6 @@
3
3
  This module binds helper methods onto Scan/Reco objects so callers can fetch
4
4
  reconstructed data, affines, NIfTI images, metadata, and parameter search
5
5
  results with a simple API.
6
-
7
- Last updated: 2025-12-30
8
6
  """
9
7
 
10
8
  from __future__ import annotations
@@ -14,19 +12,26 @@ import re
14
12
  import logging
15
13
  import sys
16
14
  from functools import partial
17
- from typing import TYPE_CHECKING, Optional, Tuple, Union, Any, Mapping, Callable, cast, List, Dict, Iterable, Literal
15
+ from typing import (
16
+ TYPE_CHECKING, cast,
17
+ Optional, Union,
18
+ Any, Iterable,
19
+ Tuple, List, Mapping, Dict, Literal,
20
+ )
18
21
  from pathlib import Path
19
22
 
20
23
  from ...core import config as config_core
21
24
  from ...core.config import resolve_root
22
- from ...specs import hook as converter_core
23
25
  from ...specs.pruner import prune_dataset_to_zip
24
26
  from ...specs.rules import load_rules, select_rule_use
25
- from ...dataclasses import Scan, Study
26
- from .types import StudyLoader, ScanLoader, RecoLoader
27
+ from ...dataclasses import Study
28
+ from .types import (
29
+ StudyLoader,
30
+ ScanLoader,
31
+ RecoLoader,
32
+ )
27
33
  from .formatter import format_info_tables
28
34
 
29
- logger = logging.getLogger("brkraw")
30
35
  from . import info as info_resolver
31
36
  from .helper import (
32
37
  make_dir,
@@ -35,24 +40,39 @@ from .helper import (
35
40
  get_dataobj as _get_dataobj,
36
41
  get_metadata as _get_metadata,
37
42
  get_nifti1image as _get_nifti1image,
43
+ resolve_reco_id as _resolve_reco_id,
38
44
  search_parameters as _search_parameters,
39
45
  apply_converter_hook as _apply_converter_hook,
46
+ resolve_converter_hook as _resolve_converter_hook,
40
47
  resolve_data_and_affine as _resolve_data_and_affine,
41
48
  )
42
49
 
43
50
  if TYPE_CHECKING:
44
51
  import numpy as np
45
52
  from pathlib import Path
53
+ from .types import (
54
+ XYZUNIT,
55
+ TUNIT,
56
+ SubjectType,
57
+ SubjectPose,
58
+ InfoScope,
59
+ AffineSpace,
60
+ Dataobjs,
61
+ Affines,
62
+ ConvertedObj,
63
+ Metadata,
64
+ )
46
65
  from ...resolver.nifti import Nifti1HeaderContents
47
- from .types import XYZUNIT, TUNIT, SubjectType, SubjectPose, InfoScope, AffineSpace
48
66
 
67
+ logger = logging.getLogger(__name__)
49
68
 
50
69
 
51
70
  class BrukerLoader:
52
71
  """High-level entrypoint that resolves scans and exposes handy accessors."""
53
72
 
54
73
  def __init__(self,
55
- path: Union[str, Path],
74
+ path: Union[str, Path],
75
+ disable_hook: bool = False,
56
76
  affine_decimals: Optional[int] = None):
57
77
  """
58
78
  Create a loader for a Bruker study rooted at `path`.
@@ -68,8 +88,11 @@ class BrukerLoader:
68
88
  self._study: Union["Study", "StudyLoader"] = Study.from_path(path)
69
89
  if affine_decimals is None:
70
90
  affine_decimals = config_core.float_decimals(root=resolve_root(None))
91
+ self._base = resolve_root(None)
92
+ self._scans = {}
71
93
  self._affine_decimals = affine_decimals
72
94
  self._sw_version: Optional[str] = self._parse_sw_version()
95
+ self._hook_disabled = disable_hook
73
96
  self._attach_helpers()
74
97
 
75
98
  def _parse_sw_version(self) -> Optional[str]:
@@ -139,75 +162,52 @@ class BrukerLoader:
139
162
 
140
163
  def _attach_helpers(self):
141
164
  """Resolve per-scan metadata and bind helper methods."""
165
+ logger.debug("Attaching helpers to study %s", getattr(self._study.fs, "root", "?"))
142
166
  self._study = cast(StudyLoader, self._study)
143
167
  self._study.search_params = MethodType(_search_parameters, self._study)
144
- base = resolve_root(None)
145
- try:
146
- rules = load_rules(root=base, validate=False)
147
- except Exception:
148
- rules = {}
149
- for scan in self._study.avail.values():
168
+
169
+ for lazy_scan in self._study.avail.values():
170
+ scan = lazy_scan.materialize()
150
171
  _resolve_data_and_affine(
151
172
  scan,
152
173
  affine_decimals=self._affine_decimals
153
174
  )
154
- scan = cast(ScanLoader, scan)
155
-
156
- # bind helper functions as methods on the scan instance
157
- scan.get_dataobj = MethodType(_get_dataobj, scan)
158
- scan.get_affine = MethodType(
159
- partial(_get_affine, decimals=self._affine_decimals),
160
- scan,
161
- )
162
- scan.get_nifti1image = MethodType(_get_nifti1image, scan)
163
- scan.convert = MethodType(_convert, scan)
164
- scan._converter_hook = None
165
- scan._converter_hook_name = None
166
- scan.converter_func = None
167
- if rules:
168
- try:
169
- hook_name = select_rule_use(
170
- scan,
171
- rules.get("converter_hook", []),
172
- base=base,
173
- resolve_paths=False,
174
- )
175
- except Exception as exc:
176
- logger.debug(
177
- "Converter hook rule selection failed for scan %s: %s",
178
- getattr(scan, "scan_id", "?"),
179
- exc,
180
- exc_info=True,
181
- )
182
- hook_name = None
183
- if isinstance(hook_name, str):
184
- try:
185
- entry = converter_core.resolve_hook(hook_name)
186
- except Exception as exc:
187
- logger.warning(
188
- "Converter hook %r not available: %s",
189
- hook_name,
190
- exc,
191
- )
192
- entry = None
193
- if entry:
194
- logger.debug("Applying converter hook: %s", hook_name)
195
- scan._converter_hook_name = hook_name
196
- _apply_converter_hook(
197
- scan,
198
- entry,
199
- affine_decimals=self._affine_decimals,
200
- )
201
- else:
202
- logger.debug("Converter hook %r resolved to no entry.", hook_name)
203
- else:
204
- logger.debug("No converter hook selected for scan %s.", getattr(scan, "scan_id", "?"))
175
+ scan = cast(ScanLoader, lazy_scan.materialize())
176
+ self.reset_converter(scan)
205
177
  scan.get_metadata = MethodType(_get_metadata, scan)
206
178
  scan.search_params = MethodType(_search_parameters, scan)
179
+ scan._hook_resolved = False
180
+
207
181
  for reco in scan.avail.values():
208
182
  reco = cast(RecoLoader, reco)
209
183
  reco.search_params = MethodType(_search_parameters, reco)
210
184
 
185
+ def _prep_scan(self, scan_id: int, reco_id: Optional[int] = None, **kwargs: Any) -> ScanLoader:
186
+ scan = self.get_scan(scan_id)
187
+
188
+ enable_hook = kwargs.get("enable_hook") # force enable
189
+ if enable_hook is not None:
190
+ del kwargs["enable_hook"]
191
+ else:
192
+ enable_hook = False
193
+
194
+ hook_is_enabled = enable_hook or not self._hook_disabled
195
+
196
+ if hook_is_enabled:
197
+ if enable_hook:
198
+ logger.debug("hook enabled by optional argument for get_dataobj()")
199
+ if scan._hook_resolved is False: # prevent multiple execution
200
+ _resolve_converter_hook(scan, self._base, affine_decimals=self._affine_decimals)
201
+
202
+ logger.debug(
203
+ "scan=%s reco=%s hook_enabled=%s hook=%s",
204
+ scan_id,
205
+ reco_id,
206
+ hook_is_enabled,
207
+ getattr(scan, "_converter_hook_name", None),
208
+ )
209
+ return scan
210
+
211
211
  def search_params(self, key: str,
212
212
  *,
213
213
  file: Optional[Union[str, List[str]]] = None,
@@ -227,40 +227,22 @@ class BrukerLoader:
227
227
  self._study = cast(StudyLoader, self._study)
228
228
  return self._study.search_params(key, file=file, scan_id=scan_id, reco_id=reco_id)
229
229
 
230
- def override_converter(
231
- self,
232
- scan_id: int,
233
- converter_hook: Mapping[str, Callable[..., Any]],
234
- ) -> None:
235
- """Override scan conversion methods with a converter hook.
236
-
237
- Args:
238
- scan_id: Scan identifier.
239
- converter_hook: Mapping of method names to callables. Only
240
- provided keys are overridden.
241
- """
242
- scan = self.avail[scan_id]
243
- scan = cast(ScanLoader, scan)
244
- _apply_converter_hook(
245
- scan,
246
- converter_hook,
247
- affine_decimals=self._affine_decimals,
248
- )
249
-
250
- def restore_converter(self, scan_id: int) -> None:
230
+ def reset_converter(self, scan: ScanLoader) -> None:
251
231
  """Restore default conversion methods for a scan.
252
232
 
253
233
  Args:
254
234
  scan_id: Scan identifier.
255
235
  """
256
- scan = self.avail[scan_id]
257
- scan = cast(ScanLoader, scan)
236
+ logger.debug("Initializing converter for scan %s", getattr(scan, "scan_id", "?"))
258
237
  scan.get_dataobj = MethodType(_get_dataobj, scan)
259
238
  scan.get_affine = MethodType(
260
239
  partial(_get_affine, decimals=self._affine_decimals),
261
240
  scan,
262
241
  )
242
+ scan.get_nifti1image = MethodType(_get_nifti1image, scan)
243
+ scan.convert = MethodType(_convert, scan)
263
244
  scan._converter_hook = None
245
+ scan._converter_hook_name = None
264
246
  scan.converter_func = None
265
247
 
266
248
  def get_scan(self, scan_id: int) -> "ScanLoader":
@@ -272,8 +254,7 @@ class BrukerLoader:
272
254
  Returns:
273
255
  Scan loader instance.
274
256
  """
275
- scan = self._study.get_scan(scan_id)
276
- scan = cast(ScanLoader, scan)
257
+ scan = cast(ScanLoader, self._study.get_scan(scan_id))
277
258
  return scan
278
259
 
279
260
  def get_fid(
@@ -298,9 +279,18 @@ class BrukerLoader:
298
279
  scan = self.get_scan(scan_id)
299
280
  if not hasattr(scan, 'get_fid'):
300
281
  return None
301
- return scan.get_fid(buffer_start=buffer_start, buffer_size=buffer_size, as_complex=as_complex)
282
+ return scan.get_fid(
283
+ buffer_start=buffer_start,
284
+ buffer_size=buffer_size,
285
+ as_complex=as_complex
286
+ )
302
287
 
303
- def get_dataobj(self, scan_id: int, reco_id: Optional[int] = None) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
288
+ def get_dataobj(
289
+ self,
290
+ scan_id: int,
291
+ reco_id: Optional[int] = None,
292
+ **kwargs: Any
293
+ ) -> Dataobjs:
304
294
  """Return reconstructed data for a scan/reco via attached helper.
305
295
 
306
296
  Args:
@@ -310,18 +300,20 @@ class BrukerLoader:
310
300
  Returns:
311
301
  Single ndarray when one slice pack exists; otherwise a tuple.
312
302
  """
313
- scan = self.get_scan(scan_id)
314
- return scan.get_dataobj(reco_id)
315
-
316
- def get_affine(self,
317
- scan_id: int,
318
- reco_id: Optional[int] = None,
319
- *,
320
- space: AffineSpace = 'subject_ras',
321
- override_subject_type: Optional[SubjectType] = None,
322
- override_subject_pose: Optional[SubjectPose] = None,
323
- decimals: Optional[int] = None
324
- ) -> Optional[Union[Tuple["np.ndarray", ...], "np.ndarray"]]:
303
+ scan = self._prep_scan(scan_id, reco_id, **kwargs)
304
+ return scan.get_dataobj(reco_id, **kwargs)
305
+
306
+ def get_affine(
307
+ self,
308
+ scan_id: int,
309
+ reco_id: Optional[int] = None,
310
+ *,
311
+ space: AffineSpace = 'subject_ras',
312
+ override_subject_type: Optional[SubjectType] = None,
313
+ override_subject_pose: Optional[SubjectPose] = None,
314
+ decimals: Optional[int] = None,
315
+ **kwargs: Any
316
+ ) -> Affines:
325
317
  """Return affine(s) for a scan/reco via attached helper.
326
318
 
327
319
  Args:
@@ -335,15 +327,16 @@ class BrukerLoader:
335
327
  Returns:
336
328
  Single affine matrix when one slice pack exists; otherwise a tuple.
337
329
  """
338
- scan = self.get_scan(scan_id)
330
+ scan = self._prep_scan(scan_id, reco_id, **kwargs)
339
331
  decimals = decimals or self._affine_decimals
340
332
  return scan.get_affine(reco_id,
341
333
  space=space,
342
334
  override_subject_pose=override_subject_pose,
343
335
  override_subject_type=override_subject_type,
344
- decimals=decimals)
336
+ decimals=decimals, **kwargs)
345
337
 
346
- def get_nifti1image(self, scan_id: int, reco_id: Optional[int] = None,
338
+ def get_nifti1image(
339
+ self, scan_id: int, reco_id: Optional[int] = None,
347
340
  *,
348
341
  space: AffineSpace = 'subject_ras',
349
342
  override_header: Optional[Nifti1HeaderContents] = None,
@@ -351,7 +344,8 @@ class BrukerLoader:
351
344
  override_subject_pose: Optional[SubjectPose] = None,
352
345
  flatten_fg: bool = False,
353
346
  xyz_units: XYZUNIT = 'mm',
354
- t_units: TUNIT = 'sec'):
347
+ t_units: TUNIT = 'sec'
348
+ ) -> ConvertedObj:
355
349
  """Return NIfTI image(s) for a scan/reco via attached helper.
356
350
 
357
351
  Args:
@@ -368,13 +362,23 @@ class BrukerLoader:
368
362
  Single NIfTI image when one slice pack exists; otherwise a tuple.
369
363
  """
370
364
  scan = self.get_scan(scan_id)
371
- return scan.convert(
372
- reco_id,
373
- space=space,
365
+ dataobj = scan.get_dataobj(reco_id)
366
+ affine = scan.get_affine(reco_id,
367
+ space=space,
368
+ decimals=self._affine_decimals,
369
+ override_subject_pose=override_subject_pose,
370
+ override_subject_type=override_subject_type,
371
+ flatten_fg=flatten_fg,
372
+ xyz_units=xyz_units,
373
+ t_units=t_units)
374
+ resolved_reco_id = _resolve_reco_id(scan, reco_id)
375
+ if resolved_reco_id is None:
376
+ return None
377
+ return scan.get_nifti1image(
378
+ resolved_reco_id,
379
+ cast(Tuple[np.ndarray, ...], dataobj),
380
+ cast(Tuple[np.ndarray, ...], affine),
374
381
  override_header=override_header,
375
- override_subject_type=override_subject_type,
376
- override_subject_pose=override_subject_pose,
377
- flatten_fg=flatten_fg,
378
382
  xyz_units=xyz_units,
379
383
  t_units=t_units,
380
384
  )
@@ -389,12 +393,11 @@ class BrukerLoader:
389
393
  override_subject_type: Optional[SubjectType] = None,
390
394
  override_subject_pose: Optional[SubjectPose] = None,
391
395
  flatten_fg: bool = False,
392
- xyz_units: XYZUNIT = "mm",
393
- t_units: TUNIT = "sec",
394
396
  hook_args_by_name: Optional[Mapping[str, Mapping[str, Any]]] = None,
395
- ):
397
+ **kwargs: Any,
398
+ ) -> ConvertedObj:
396
399
  """Convert a scan/reco to output object(s) supporting to_filename()."""
397
- scan = self.get_scan(scan_id)
400
+ scan = self._prep_scan(scan_id, reco_id, **kwargs)
398
401
  return scan.convert(
399
402
  reco_id,
400
403
  space=space,
@@ -402,9 +405,8 @@ class BrukerLoader:
402
405
  override_subject_type=override_subject_type,
403
406
  override_subject_pose=override_subject_pose,
404
407
  flatten_fg=flatten_fg,
405
- xyz_units=xyz_units,
406
- t_units=t_units,
407
408
  hook_args_by_name=hook_args_by_name,
409
+ **kwargs,
408
410
  )
409
411
 
410
412
  def get_metadata(
@@ -414,7 +416,7 @@ class BrukerLoader:
414
416
  spec: Optional[Union[Mapping[str, Any], str, Path]] = None,
415
417
  context_map: Optional[Union[str, Path]] = None,
416
418
  return_spec: bool = False,
417
- ):
419
+ ) -> Metadata:
418
420
  """Return metadata for a scan/reco.
419
421
 
420
422
  Args:
@@ -472,9 +474,11 @@ class BrukerLoader:
472
474
  return BrukerLoader(out_path, affine_decimals=self._affine_decimals)
473
475
 
474
476
  @property
475
- def avail(self) -> Mapping[int, Union["Scan", "ScanLoader"]]:
477
+ def avail(self) -> Mapping[int, "ScanLoader"]:
476
478
  """Available scans keyed by scan id."""
477
- return self._study.avail
479
+ if len(self._scans) != len(self._study.avail):
480
+ self._scans = {scan_id: cast(ScanLoader, scan.materialize()) for scan_id, scan in self._study.avail.items()}
481
+ return self._scans
478
482
 
479
483
  @property
480
484
  def subject(self) -> Optional[Dict[str, Any]]:
@@ -1,6 +1,4 @@
1
1
  """Formatting helpers for loader info output.
2
-
3
- Last updated: 2025-12-30
4
2
  """
5
3
 
6
4
  from __future__ import annotations