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,781 @@
1
+ """
2
+ Parameter parsing and conversion utilities for Bruker Paravision datasets.
3
+
4
+ This module provides the `Parameters` class, a high-level object that loads
5
+ and converts Paravision JCAMP-DX formatted scan parameter files (e.g., `method`,
6
+ `acqp`, `reco`). It parses raw JCAMP-DX text into structured Python types,
7
+ applies normalization rules (including repeat encodings, symbolic references,
8
+ and ndarray reshaping), and exposes each parameter as a Python attribute for
9
+ object-oriented access.
10
+
11
+ The module additionally includes:
12
+ - Automatic detection and formatting of numeric arrays.
13
+ - Expansion of Bruker-style @N*(x) repeat encodings.
14
+ - Special handling of symbolic references in `<...>` notation.
15
+ - Conversion of multi-dimensional JCAMP structures into Python tuples or
16
+ NumPy ndarrays.
17
+ - A smoke test utility to validate all `.jdx` fixture files.
18
+
19
+ This module forms a central part of `brkraw.core`, enabling downstream users
20
+ to interact with Paravision metadata reliably and idiomatically in Python.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from collections import OrderedDict
26
+ import io
27
+ from pathlib import Path
28
+ from typing import IO, Optional, Any, Union, Tuple, Literal, List, Dict, Mapping
29
+ import numpy as np
30
+ import json
31
+ from .jcamp import parse_jcamp
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class Parameters:
37
+ _header: OrderedDict
38
+ _store: OrderedDict
39
+ _path: Optional[Path]
40
+ _comments: List[str]
41
+ _exceptions: List[str]
42
+ _format: Optional[dict]
43
+ _source: Union[Path, str, IO[bytes], bytes, bytearray]
44
+ _source_bytes: bytes
45
+
46
+ def __init__(
47
+ self,
48
+ source: Union[Path, str, IO[bytes], bytes, bytearray],
49
+ format_registry: Optional[dict] = None,
50
+ ):
51
+ normalized_source, preview_bytes = self._normalize_source(source)
52
+
53
+ self._path = Path(source) if isinstance(source, (str, Path)) else None
54
+ self._source = normalized_source
55
+ self._source_bytes = preview_bytes
56
+
57
+ try:
58
+ parsed_data = parse_jcamp(self._source)
59
+ except Exception as exc:
60
+ raise ValueError("Source does not look like JCAMP-DX content.") from exc
61
+
62
+ self._formatting(parsed_data, format_registry)
63
+
64
+ @property
65
+ def source(self):
66
+ return self._source_bytes.decode("utf-8").split("\n")
67
+
68
+ @staticmethod
69
+ def _normalize_source(
70
+ source: Union[Path, str, IO[bytes], bytes, bytearray]
71
+ ) -> Tuple[Union[Path, str, IO[bytes], bytes, bytearray], bytes]:
72
+ """Return a parseable source plus a byte preview for JCAMP detection."""
73
+ # Path string that points to a real file
74
+ if isinstance(source, (str, Path)):
75
+ path = Path(source)
76
+ if not path.exists():
77
+ raise FileNotFoundError(path)
78
+ data = path.read_bytes()
79
+ return source, data
80
+
81
+ # Raw bytes
82
+ if isinstance(source, (bytes, bytearray)):
83
+ data_bytes = bytes(source)
84
+ return data_bytes, data_bytes
85
+
86
+ # File-like
87
+ if hasattr(source, "read"):
88
+ raw = source.read() # type: ignore[attr-defined]
89
+ if hasattr(source, "seek"):
90
+ try:
91
+ source.seek(0) # type: ignore[call-arg,attr-defined]
92
+ except Exception:
93
+ pass
94
+ if isinstance(raw, (bytes, bytearray)):
95
+ data_bytes = bytes(raw)
96
+ else:
97
+ data_bytes = str(raw).encode("utf-8", errors="ignore")
98
+ return io.BytesIO(data_bytes), data_bytes
99
+
100
+ raise TypeError(f"Unsupported source type: {type(source)}")
101
+
102
+ def edit_source(
103
+ self,
104
+ source: Union[str, bytes, bytearray],
105
+ *,
106
+ reparse: bool = True,
107
+ format_registry: Optional[dict] = None,
108
+ ) -> None:
109
+ """Replace the underlying JCAMP source and optionally reparse.
110
+
111
+ Args:
112
+ source: New JCAMP content as text or bytes.
113
+ reparse: When True, rebuild parsed header/params from the new content.
114
+ format_registry: Optional formatter overrides for reparse.
115
+ """
116
+ if isinstance(source, str):
117
+ data = source.encode("utf-8")
118
+ else:
119
+ data = bytes(source)
120
+
121
+ self._source = io.BytesIO(data)
122
+ self._source_bytes = data
123
+
124
+ if reparse:
125
+ try:
126
+ parsed_data = parse_jcamp(self._source)
127
+ except Exception as exc:
128
+ raise ValueError("Source does not look like JCAMP-DX content.") from exc
129
+ self._formatting(parsed_data, format_registry or self._format)
130
+
131
+ def save_to(self, path: Union[Path, str]) -> Path:
132
+ """Write the current source bytes to a new file."""
133
+ out_path = Path(path)
134
+ out_path.write_bytes(self._source_bytes)
135
+ return out_path
136
+
137
+ def source_text(self) -> str:
138
+ """Return the current JCAMP source as text."""
139
+ return self._source_bytes.decode("utf-8", errors="ignore")
140
+
141
+ def replace_value(self, key: str, value: Optional[str], *, reparse: bool = True) -> None:
142
+ """Replace a JCAMP parameter block with a raw JCAMP value string."""
143
+ self.replace_values({key: value}, reparse=reparse)
144
+
145
+ def replace_values(self, updates: Mapping[str, Optional[str]], *, reparse: bool = True) -> None:
146
+ """Replace multiple JCAMP parameter blocks with raw JCAMP value strings."""
147
+ text = self._source_bytes.decode("utf-8", errors="ignore")
148
+ updated_text = _edit_jcamp_text(text, updates)
149
+ self.edit_source(updated_text, reparse=reparse, format_registry=self._format)
150
+
151
+ @staticmethod
152
+ def _looks_like_jcamp(data: bytes) -> bool:
153
+ """Heuristic: check for JCAMP-style header lines in decoded text."""
154
+ try:
155
+ text = data.decode("utf-8", errors="ignore")
156
+ except Exception:
157
+ return False
158
+
159
+ header_seen = 0
160
+ for line in text.splitlines()[:50]:
161
+ stripped = line.strip()
162
+ if not stripped:
163
+ continue
164
+ if stripped.startswith("##"):
165
+ header_seen += 1
166
+ if "=" in stripped:
167
+ return True
168
+ if header_seen >= 2:
169
+ return True
170
+ return False
171
+
172
+ @property
173
+ def header(self) -> Optional[OrderedDict]:
174
+ return self._header
175
+
176
+ @property
177
+ def path(self) -> Optional[Path]:
178
+ return self._path
179
+
180
+ @staticmethod
181
+ def _get_params(parsed: dict) -> OrderedDict:
182
+ """Extract the parameter dictionary from the JCAMP parse result.
183
+
184
+ Args:
185
+ parsed (dict): Result dictionary returned by `parse_jcamp_from_path`.
186
+ Expected to contain a "params" key.
187
+
188
+ Returns:
189
+ OrderedDict: Mapping of parameter keys to dictionaries containing
190
+ `shape` and `data`.
191
+ """
192
+ return parsed['params']
193
+
194
+ @staticmethod
195
+ def _is_at_repeat_param(data: Any) -> bool:
196
+ """Check whether a data field uses Bruker @N*(x) repeat encoding.
197
+
198
+ Bruker JCAMP format sometimes encodes repeated values as:
199
+ ["@128*", value]
200
+
201
+ Args:
202
+ data (Any): Parsed JCAMP data field.
203
+
204
+ Returns:
205
+ bool: True if the field uses @N*(x) encoding, False otherwise.
206
+ """
207
+ if not isinstance(data, list) or not data:
208
+ return False
209
+ shape_hint = data[0]
210
+ if not isinstance(shape_hint, str):
211
+ return False
212
+ return shape_hint.startswith('@') and shape_hint.endswith('*')
213
+
214
+ @staticmethod
215
+ def _is_array(value: dict) -> bool:
216
+ """Determine whether the given JCAMP value can be converted to a NumPy array.
217
+
218
+ Args:
219
+ value (dict): Dictionary with keys "shape" and "data".
220
+
221
+ Returns:
222
+ bool: True if `np.asarray(data).reshape(shape)` succeeds.
223
+ """
224
+ try:
225
+ Parameters._get_reshaped_value(value)
226
+ return True
227
+ except Exception:
228
+ return False
229
+
230
+ @staticmethod
231
+ def _is_symbolic_ref_list(value: dict) -> bool:
232
+ """Identify symbolic-reference lists encoded with JCAMP shapes.
233
+
234
+ Paravision sometimes encodes object reference lists as:
235
+ shape = (N, M)
236
+ data = ["<RefA>", "<RefB>", ...]
237
+
238
+ The second dimension often corresponds to character length and should be ignored.
239
+
240
+ Args:
241
+ value (dict): Dictionary containing JCAMP `shape` and `data`.
242
+
243
+ Returns:
244
+ bool: True when the field represents a symbolic reference list.
245
+ """
246
+ shape = value.get("shape")
247
+ data = value.get("data")
248
+
249
+ # Must have a 2D shape tuple, e.g. (2, 65)
250
+ if not isinstance(shape, tuple) or len(shape) != 2:
251
+ return False
252
+
253
+ # Data must be a list of strings
254
+ if not isinstance(data, list) or not data:
255
+ return False
256
+
257
+ # First dimension should match the number of elements
258
+ if shape[0] != len(data):
259
+ return False
260
+
261
+ # All elements must be angle-bracketed strings: <...>
262
+ for item in data:
263
+ if not isinstance(item, str):
264
+ return False
265
+ s = item.strip()
266
+ if not (s.startswith("<") and s.endswith(">")):
267
+ return False
268
+ return True
269
+
270
+ @staticmethod
271
+ def _get_reshaped_value(value: dict) -> Union[np.ndarray, str]:
272
+ """Convert JCAMP numeric data into a NumPy ndarray with the given shape.
273
+
274
+ Args:
275
+ value (dict): Dictionary with "shape" and "data" keys.
276
+
277
+ Returns:
278
+ np.ndarray or str: Reshaped ndarray, or raw string when reshaping
279
+ is inappropriate.
280
+ """
281
+ if isinstance(value['data'], str):
282
+ return value['data']
283
+ else:
284
+ return np.asarray(value['data']).reshape(value['shape'])
285
+
286
+ @staticmethod
287
+ def _to_string_value(value):
288
+ """Convert JCAMP header values into readable strings.
289
+
290
+ Handles:
291
+ - Plain strings
292
+ - Scalars (int, float, NumPy scalar)
293
+ - Flat lists (joined by space)
294
+ - Nested lists (joined by semicolons)
295
+
296
+ Args:
297
+ value: Raw parsed JCAMP header content.
298
+
299
+ Returns:
300
+ str: Human-readable string representation.
301
+ """
302
+ # CASE 1: already a string
303
+ if isinstance(value, str):
304
+ return value
305
+
306
+ # CASE 2: scalar (int, float, numpy scalar, etc.)
307
+ if isinstance(value, (int, float)):
308
+ return str(value)
309
+
310
+ # CASE 3: list (flat or nested)
311
+ if isinstance(value, list):
312
+ # Check if this is a nested list (list of lists)
313
+ has_nested = any(isinstance(item, list) for item in value)
314
+
315
+ if has_nested:
316
+ # Nested case: join inner lists as phrases, then join phrases with semicolons
317
+ parts = []
318
+ for item in value:
319
+ if isinstance(item, list):
320
+ parts.append(" ".join(str(x) for x in item))
321
+ else:
322
+ parts.append(str(item))
323
+ return "; ".join(parts)
324
+ else:
325
+ # Flat case: simply join all elements with spaces
326
+ return " ".join(str(item) for item in value)
327
+
328
+ # Fallback for any unexpected type
329
+ return str(value)
330
+
331
+ @staticmethod
332
+ def _to_json_compatible(obj):
333
+ """Convert internal parameter values into JSON compatible types.
334
+
335
+ This normalizes nested containers and special types such as:
336
+
337
+ - numpy.ndarray -> list
338
+ - numpy scalar -> Python scalar
339
+ - tuple -> list
340
+ - Path -> str
341
+ - OrderedDict -> plain dict (order preserved by insertion)
342
+ """
343
+ import numpy as np
344
+ from pathlib import Path
345
+ from collections import OrderedDict
346
+
347
+ # Primitive JSON types
348
+ if isinstance(obj, (str, int, float, bool)) or obj is None:
349
+ return obj
350
+
351
+ # NumPy arrays and scalars
352
+ if isinstance(obj, np.ndarray):
353
+ return obj.tolist()
354
+ if isinstance(obj, np.generic):
355
+ return obj.item()
356
+
357
+ # Paths
358
+ if isinstance(obj, Path):
359
+ return str(obj)
360
+
361
+ # Dict-like
362
+ if isinstance(obj, (dict, OrderedDict)):
363
+ return {
364
+ str(k): Parameters._to_json_compatible(v)
365
+ for k, v in obj.items()
366
+ }
367
+
368
+ # Sequences
369
+ if isinstance(obj, (list, tuple)):
370
+ return [Parameters._to_json_compatible(v) for v in obj]
371
+
372
+ # Fallback - last resort stringification
373
+ return str(obj)
374
+
375
+ def _formatting(self, parsed_data: dict, format_registry: Optional[dict] = None):
376
+ """Parse and normalize all JCAMP parameters into structured objects.
377
+
378
+ This method:
379
+ - Loads JCAMP text using `parse_jcamp` (path, bytes, or file-like).
380
+ - Stores human-readable headers in `_header`.
381
+ - Normalizes all `$Param` fields via `_format_param_value`.
382
+ - Applies any user-provided `format_registry` to specific parameters.
383
+ - Populates `_exceptions` with any inconsistencies or formatting warnings.
384
+
385
+ Args:
386
+ format_registry (dict, optional):
387
+ Mapping of parameter names to custom formatting callables.
388
+ Each callable must accept the raw JCAMP `{shape, data}` dict and return
389
+ a normalized Python value.
390
+ """
391
+ self._format = format_registry
392
+ self._header = OrderedDict()
393
+ self._store = OrderedDict()
394
+ self._comments = parsed_data["comments"]
395
+ self._exceptions = parsed_data["exceptions"]
396
+
397
+ for key, value in self._get_params(parsed_data).items():
398
+ key_str = str(key)
399
+
400
+ # Header style parameters (no leading $)
401
+ if not key_str.startswith("$"):
402
+ self._header[key_str] = self._to_string_value(value["data"])
403
+ continue
404
+
405
+ # Parameter style: drop leading $
406
+ param_key = key_str[1:]
407
+
408
+ # 1) Custom formatter from registry has priority
409
+ if self._format and param_key in self._format:
410
+ formatted = self._format[param_key](value)
411
+ else:
412
+ formatted = self._format_param_value(param_key, value)
413
+
414
+ self._store[param_key] = formatted
415
+
416
+ def _format_param_value(self, param_key: str, value: dict):
417
+ """Normalize a single JCAMP parameter into a Python object.
418
+
419
+ Handles the full hierarchy of JCAMP transformation logic:
420
+ - Raw values when `shape` is None.
421
+ - Expansion of @N*(x) repeat encodings.
422
+ - Conversion into ndarray when shape and data permit.
423
+ - Tuple conversion for 1D shapes.
424
+ - Special-case formatting of symbolic reference lists (`<...>` tokens).
425
+ - Recording of mismatched shapes or incomplete formatting states.
426
+
427
+ Args:
428
+ param_key (str): Name of the JCAMP parameter (without leading `$`).
429
+ value (dict): JCAMP `{"shape": tuple or None, "data": raw}` structure.
430
+
431
+ Returns:
432
+ Any: A normalized Python type such as:
433
+ - scalar
434
+ - tuple
435
+ - list
436
+ - np.ndarray
437
+ - or a fallback raw structure (with warnings in `_exceptions`)
438
+ """
439
+ shape = value.get("shape")
440
+ data: Any = value.get("data")
441
+
442
+ # No shape metadata: just return raw data
443
+ if shape is None:
444
+ return data
445
+ if data is None:
446
+ return shape
447
+
448
+ # Expand @N*(x) repeat encoding if present
449
+ if self._is_at_repeat_param(data):
450
+ repeat_spec = data[0] # e.g. "@128*"
451
+ elem = data[1]
452
+ try:
453
+ repeat_count = int(repeat_spec[1:-1])
454
+ data = [elem] * repeat_count
455
+ value = {"shape": shape, "data": data}
456
+ except Exception as exc:
457
+ msg = (
458
+ f"Failed to expand repeat encoding for '{param_key}': "
459
+ f"{repeat_spec!r} -> {exc!r}"
460
+ )
461
+ self._exceptions.append(msg)
462
+ return data
463
+
464
+ # Try to treat as a proper numpy array
465
+ array_candidate = {"shape": shape, "data": data}
466
+ if self._is_array(array_candidate):
467
+ return self._get_reshaped_value(array_candidate)
468
+
469
+ # Fallback: handle simple 1D shapes as tuple
470
+ if isinstance(shape, tuple) and len(shape) == 1:
471
+ expected_len = shape[0]
472
+
473
+ # Shape of length 1: treat as scalar-like / struc
474
+ if expected_len == 1:
475
+ return data
476
+
477
+ # Shape of length N: treat as N element tuple
478
+ tup = tuple(data)
479
+ if len(tup) != expected_len:
480
+ msg = (
481
+ f"Shape mismatch in parameter '{param_key}': "
482
+ f"expected length {expected_len}, got {len(tup)}"
483
+ )
484
+ self._exceptions.append(msg)
485
+ return tup
486
+ if self._is_symbolic_ref_list(value):
487
+ return np.asarray(value["data"])
488
+
489
+ # Any other complex shape that could not be reshaped
490
+ # Return data as is but record that formatting was incomplete
491
+ msg = (
492
+ f"Could not format parameter '{param_key}' with shape {shape!r}; "
493
+ f"leaving raw data."
494
+ )
495
+ self._exceptions.append(msg)
496
+ return data
497
+
498
+ def __getitem__(self, key):
499
+ """Dictionary-style access to parsed parameters."""
500
+ return self._store[key]
501
+
502
+ def __setitem__(self, key, value):
503
+ self._store[key] = value
504
+
505
+ def __getattr__(self, key):
506
+ """Attribute-style access to parsed parameters.
507
+
508
+ Raises:
509
+ AttributeError: When the parameter does not exist.
510
+ """
511
+ try:
512
+ return self._store[key]
513
+ except KeyError:
514
+ raise AttributeError(key)
515
+
516
+ def __setattr__(self, key: str, value):
517
+ if key.startswith("_"):
518
+ super().__setattr__(key, value)
519
+ else:
520
+ self._store[key] = value
521
+
522
+ def keys(self):
523
+ """Return all available parameter names."""
524
+ return self._store.keys()
525
+
526
+ def search_keys(
527
+ self,
528
+ query: str,
529
+ *,
530
+ case_sensitive: bool = False,
531
+ include_header: bool = True,
532
+ include_params: bool = True,
533
+ match_mode: Literal["substring", "exact"] = "substring",
534
+ ) -> List[Dict[str, Any]]:
535
+ """Search parameter and header entries and return matching key-value pairs.
536
+
537
+ Args:
538
+ query (str):
539
+ Substring to search for inside keys.
540
+ case_sensitive (bool, optional):
541
+ When True, match is case sensitive.
542
+ When False (default), keys and query are compared in lowercase.
543
+ include_header (bool, optional):
544
+ Search inside header keys as well (default: True).
545
+ include_params (bool, optional):
546
+ Search inside parameter keys (default: True).
547
+ match_mode ({"substring", "exact"}, optional):
548
+ Whether to search by substring (default) or exact key match.
549
+
550
+ Returns:
551
+ List[Dict[str, Any]]: A list of single-entry dictionaries containing
552
+ matching keys and their values, preserving header-first order.
553
+ """
554
+ if not isinstance(query, str):
555
+ raise TypeError("query must be a string")
556
+ if match_mode not in {"substring", "exact"}:
557
+ raise ValueError("match_mode must be 'substring' or 'exact'")
558
+
559
+ # Prepare comparable query
560
+ if not case_sensitive:
561
+ query_cmp = query.lower()
562
+ else:
563
+ query_cmp = query
564
+
565
+ matches: List[Dict[str, Any]] = []
566
+
567
+ def collect_matches(source: OrderedDict):
568
+ for key, value in source.items():
569
+ key_cmp = key.lower() if not case_sensitive else key
570
+ is_match = (
571
+ query_cmp in key_cmp if match_mode == "substring" else query_cmp == key_cmp
572
+ )
573
+ if is_match:
574
+ matches.append({key: value})
575
+
576
+ # Search in header
577
+ if include_header:
578
+ collect_matches(self._header)
579
+
580
+ # Search in parameters (_store)
581
+ if include_params:
582
+ collect_matches(self._store)
583
+
584
+ return matches
585
+
586
+ def get(self, key: str, default=None):
587
+ """Return the value for key if present, else default."""
588
+ if key in self._store:
589
+ return self._store[key]
590
+ if key in self._header:
591
+ return self._header[key]
592
+ return default
593
+
594
+ def to_json(self, path: Optional[Union[Path, str]] = None, *, indent: int = 2) -> str:
595
+ """Serialize the current Parameters object to a JSON string and optionally file.
596
+
597
+ The JSON payload includes:
598
+ - path: Source JCAMP file path as string.
599
+ - header: Normalized header entries.
600
+ - params: Parsed and formatted parameter values.
601
+ - comments: JCAMP comment lines (without `$$`).
602
+ - exceptions: Collected parsing or formatting warnings.
603
+
604
+ Args:
605
+ path (Path or str, optional):
606
+ Output file path. When provided, the JSON string is written to
607
+ this location.
608
+ indent (int, optional):
609
+ Indentation level passed to `json.dumps` for pretty printing.
610
+
611
+ Returns:
612
+ str: The serialized JSON string representing this Parameters object.
613
+ """
614
+ payload = {
615
+ "path": str(self._path) if hasattr(self, "_path") else None,
616
+ "header": self._to_json_compatible(self._header),
617
+ "params": self._to_json_compatible(self._store),
618
+ "comments": list(self._comments or []),
619
+ "exceptions": list(self._exceptions or []),
620
+ }
621
+
622
+ text = json.dumps(payload, indent=indent, sort_keys=False)
623
+
624
+ if path is not None:
625
+ out_path = Path(path)
626
+ out_path.write_text(text, encoding="utf-8")
627
+
628
+ return text
629
+
630
+
631
+ def run_smoke_test(
632
+ fixtures_dir: Path,
633
+ format_registry: Optional[dict] = None,
634
+ ) -> dict:
635
+ """Execute a smoke test over all JCAMP `.jdx` files in a directory.
636
+
637
+ The smoke test ensures:
638
+ - Parameters objects can be constructed without raising errors.
639
+ - JCAMP `_exceptions` are recorded for problematic fields.
640
+ - No raw JCAMP values with unprocessed `{"shape": ..., "data": ...}` remain.
641
+ - All parameters are accessible as Python attributes.
642
+ - Diagnostics are logged for initialization failures, shape mismatches,
643
+ symbolic reference issues, or incomplete conversions.
644
+
645
+ Args:
646
+ fixtures_dir (Path):
647
+ Directory containing one or more `.jdx` JCAMP test files.
648
+ format_registry (dict, optional):
649
+ Optional mapping of parameter names to custom formatting functions.
650
+
651
+ Returns:
652
+ dict: Summary of smoke-test results with the following keys:
653
+
654
+ - total_files (int): Count of `.jdx` files processed.
655
+ - ok_files (List[Path]): Files fully validated without issues.
656
+ - exception_files (List[Tuple[str, List[str]]]):
657
+ Files with JCAMP parser-generated `_exceptions`.
658
+ - init_error_files (List[Tuple[str, str]]):
659
+ Files that failed to initialize a Parameters object.
660
+ - raw_value_params (List[Tuple[str, str]]):
661
+ Parameters that remained in raw `{"shape":..., "data":...}` form.
662
+ - attr_access_errors (List[Tuple[str, str, str]]):
663
+ Attribute-access failures `(file, key, error)`.
664
+
665
+ """
666
+ summary = {
667
+ "total_files": 0,
668
+ "ok_files": [],
669
+ "exception_files": [], # (file, exceptions)
670
+ "init_error_files": [], # (file, repr(exc))
671
+ "raw_value_params": [], # (file, param_key)
672
+ "attr_access_errors": [], # (file, param_key, repr(exc))
673
+ }
674
+
675
+ for jdx_path in sorted(fixtures_dir.glob("*.jdx")):
676
+ summary["total_files"] += 1
677
+ logger.info(f"Checking {jdx_path}")
678
+ file_str = jdx_path.as_posix()
679
+
680
+ try:
681
+ params = Parameters(jdx_path, format_registry=format_registry)
682
+ except Exception as exc:
683
+ logger.error(f"Failed to initialize Parameters for {file_str}: {exc}")
684
+ summary["init_error_files"].append((file_str, repr(exc)))
685
+ continue
686
+
687
+ file_has_exceptions = False
688
+ file_has_raw_values = False
689
+ file_has_attr_errors = False
690
+
691
+ # 1) Check recorded parse exceptions from jcamp/parser layer
692
+ if getattr(params, "_exceptions", None):
693
+ file_has_exceptions = True
694
+ logger.warning(
695
+ f"Found {len(params._exceptions)} exceptions in {file_str}"
696
+ )
697
+ summary["exception_files"].append((file_str, params._exceptions))
698
+
699
+ # 2) Check for leftover raw dict values with 'shape' key in _store
700
+ for key, val in params._store.items():
701
+ if isinstance(val, dict) and "shape" in val:
702
+ file_has_raw_values = True
703
+ logger.error(
704
+ f"Parameter '{key}' in {file_str} still has a raw dict value with 'shape'"
705
+ )
706
+ summary["raw_value_params"].append((file_str, key))
707
+
708
+ # 3) Check that every key is accessible as an attribute
709
+ for key in list(params._store.keys()):
710
+ try:
711
+ attr_val = getattr(params, key)
712
+ # Optional: ensure attribute value matches stored value
713
+ if attr_val is not params._store[key]:
714
+ # This is not necessarily an error, but you can log if you care.
715
+ logger.debug(
716
+ f"Attribute '{key}' in {file_str} does not match _store by identity"
717
+ )
718
+ except Exception as exc:
719
+ file_has_attr_errors = True
720
+ logger.error(
721
+ f"Attribute access failed for '{key}' in {file_str}: {exc}"
722
+ )
723
+ summary["attr_access_errors"].append((file_str, key, repr(exc)))
724
+
725
+ # 4) Mark file as fully OK only if no issues were detected
726
+ if not (file_has_exceptions or file_has_raw_values or file_has_attr_errors):
727
+ summary["ok_files"].append(jdx_path)
728
+
729
+ return summary
730
+
731
+
732
+ def _edit_jcamp_text(text: str, updates: Mapping[str, Optional[str]]) -> str:
733
+ if not updates:
734
+ return text
735
+ pending = set(updates.keys())
736
+ lines = text.splitlines(keepends=True)
737
+ out: List[str] = []
738
+ i = 0
739
+ while i < len(lines):
740
+ line = lines[i]
741
+ if line.startswith("##$"):
742
+ key = line[3:].split("=", 1)[0].strip()
743
+ block_start = i
744
+ i += 1
745
+ while i < len(lines) and not lines[i].startswith("##"):
746
+ i += 1
747
+ block_lines = lines[block_start:i]
748
+ if key in updates:
749
+ pending.discard(key)
750
+ new_value = updates[key]
751
+ if new_value is None:
752
+ continue
753
+ out.extend(_format_param_block(key, new_value))
754
+ else:
755
+ out.extend(block_lines)
756
+ continue
757
+ out.append(line)
758
+ i += 1
759
+ if pending:
760
+ logger.debug("JCAMP update keys not found: %s", sorted(pending))
761
+ return "".join(out)
762
+
763
+
764
+ def _format_param_block(key: str, value: str) -> List[str]:
765
+ value = value.rstrip("\n")
766
+ lines = value.splitlines()
767
+ if not lines:
768
+ return [f"##${key}= \n"]
769
+ out = [f"##${key}= {lines[0]}\n"]
770
+ if len(lines) > 1:
771
+ out.extend([line + "\n" for line in lines[1:]])
772
+ return out
773
+
774
+
775
+ __all__ = [
776
+ 'Parameters',
777
+ 'run_smoke_test',
778
+ ]
779
+
780
+ def __dir__() -> List[str]:
781
+ return sorted(__all__)