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.
- brkraw/__init__.py +9 -3
- brkraw/apps/__init__.py +12 -0
- brkraw/apps/addon/__init__.py +30 -0
- brkraw/apps/addon/core.py +35 -0
- brkraw/apps/addon/dependencies.py +402 -0
- brkraw/apps/addon/installation.py +500 -0
- brkraw/apps/addon/io.py +21 -0
- brkraw/apps/hook/__init__.py +25 -0
- brkraw/apps/hook/core.py +636 -0
- brkraw/apps/loader/__init__.py +10 -0
- brkraw/apps/loader/core.py +622 -0
- brkraw/apps/loader/formatter.py +288 -0
- brkraw/apps/loader/helper.py +797 -0
- brkraw/apps/loader/info/__init__.py +11 -0
- brkraw/apps/loader/info/scan.py +85 -0
- brkraw/apps/loader/info/scan.yaml +90 -0
- brkraw/apps/loader/info/study.py +69 -0
- brkraw/apps/loader/info/study.yaml +156 -0
- brkraw/apps/loader/info/transform.py +92 -0
- brkraw/apps/loader/types.py +220 -0
- brkraw/cli/__init__.py +5 -0
- brkraw/cli/commands/__init__.py +2 -0
- brkraw/cli/commands/addon.py +327 -0
- brkraw/cli/commands/config.py +205 -0
- brkraw/cli/commands/convert.py +903 -0
- brkraw/cli/commands/hook.py +348 -0
- brkraw/cli/commands/info.py +74 -0
- brkraw/cli/commands/init.py +214 -0
- brkraw/cli/commands/params.py +106 -0
- brkraw/cli/commands/prune.py +288 -0
- brkraw/cli/commands/session.py +371 -0
- brkraw/cli/hook_args.py +80 -0
- brkraw/cli/main.py +83 -0
- brkraw/cli/utils.py +60 -0
- brkraw/core/__init__.py +13 -0
- brkraw/core/config.py +380 -0
- brkraw/core/entrypoints.py +25 -0
- brkraw/core/formatter.py +367 -0
- brkraw/core/fs.py +495 -0
- brkraw/core/jcamp.py +600 -0
- brkraw/core/layout.py +451 -0
- brkraw/core/parameters.py +781 -0
- brkraw/core/zip.py +1121 -0
- brkraw/dataclasses/__init__.py +14 -0
- brkraw/dataclasses/node.py +139 -0
- brkraw/dataclasses/reco.py +33 -0
- brkraw/dataclasses/scan.py +61 -0
- brkraw/dataclasses/study.py +131 -0
- brkraw/default/__init__.py +3 -0
- brkraw/default/pruner_specs/deid4share.yaml +42 -0
- brkraw/default/rules/00_default.yaml +4 -0
- brkraw/default/specs/metadata_dicom.yaml +236 -0
- brkraw/default/specs/metadata_transforms.py +92 -0
- brkraw/resolver/__init__.py +7 -0
- brkraw/resolver/affine.py +539 -0
- brkraw/resolver/datatype.py +69 -0
- brkraw/resolver/fid.py +90 -0
- brkraw/resolver/helpers.py +36 -0
- brkraw/resolver/image.py +188 -0
- brkraw/resolver/nifti.py +370 -0
- brkraw/resolver/shape.py +235 -0
- brkraw/schema/__init__.py +3 -0
- brkraw/schema/context_map.yaml +62 -0
- brkraw/schema/meta.yaml +57 -0
- brkraw/schema/niftiheader.yaml +95 -0
- brkraw/schema/pruner.yaml +55 -0
- brkraw/schema/remapper.yaml +128 -0
- brkraw/schema/rules.yaml +154 -0
- brkraw/specs/__init__.py +10 -0
- brkraw/specs/hook/__init__.py +12 -0
- brkraw/specs/hook/logic.py +31 -0
- brkraw/specs/hook/validator.py +22 -0
- brkraw/specs/meta/__init__.py +5 -0
- brkraw/specs/meta/validator.py +156 -0
- brkraw/specs/pruner/__init__.py +15 -0
- brkraw/specs/pruner/logic.py +361 -0
- brkraw/specs/pruner/validator.py +119 -0
- brkraw/specs/remapper/__init__.py +27 -0
- brkraw/specs/remapper/logic.py +924 -0
- brkraw/specs/remapper/validator.py +314 -0
- brkraw/specs/rules/__init__.py +6 -0
- brkraw/specs/rules/logic.py +263 -0
- brkraw/specs/rules/validator.py +103 -0
- brkraw-0.5.0.dist-info/METADATA +81 -0
- brkraw-0.5.0.dist-info/RECORD +88 -0
- {brkraw-0.3.11.dist-info → brkraw-0.5.0.dist-info}/WHEEL +1 -2
- brkraw-0.5.0.dist-info/entry_points.txt +13 -0
- brkraw/lib/__init__.py +0 -4
- brkraw/lib/backup.py +0 -641
- brkraw/lib/bids.py +0 -0
- brkraw/lib/errors.py +0 -125
- brkraw/lib/loader.py +0 -1220
- brkraw/lib/orient.py +0 -194
- brkraw/lib/parser.py +0 -48
- brkraw/lib/pvobj.py +0 -301
- brkraw/lib/reference.py +0 -245
- brkraw/lib/utils.py +0 -471
- brkraw/scripts/__init__.py +0 -0
- brkraw/scripts/brk_backup.py +0 -106
- brkraw/scripts/brkraw.py +0 -744
- brkraw/ui/__init__.py +0 -0
- brkraw/ui/config.py +0 -17
- brkraw/ui/main_win.py +0 -214
- brkraw/ui/previewer.py +0 -225
- brkraw/ui/scan_info.py +0 -72
- brkraw/ui/scan_list.py +0 -73
- brkraw/ui/subj_info.py +0 -128
- brkraw-0.3.11.dist-info/METADATA +0 -25
- brkraw-0.3.11.dist-info/RECORD +0 -28
- brkraw-0.3.11.dist-info/entry_points.txt +0 -3
- brkraw-0.3.11.dist-info/top_level.txt +0 -2
- tests/__init__.py +0 -0
- {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__)
|