microstructure-tpw 0.3.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.
- microstructure_tpw-0.3.0.dist-info/METADATA +234 -0
- microstructure_tpw-0.3.0.dist-info/RECORD +119 -0
- microstructure_tpw-0.3.0.dist-info/WHEEL +5 -0
- microstructure_tpw-0.3.0.dist-info/entry_points.txt +7 -0
- microstructure_tpw-0.3.0.dist-info/licenses/LICENSE +674 -0
- microstructure_tpw-0.3.0.dist-info/top_level.txt +1 -0
- odas_tpw/__init__.py +18 -0
- odas_tpw/chi/__init__.py +48 -0
- odas_tpw/chi/batchelor.py +189 -0
- odas_tpw/chi/chi.py +857 -0
- odas_tpw/chi/fp07.py +649 -0
- odas_tpw/chi/l2_chi.py +153 -0
- odas_tpw/chi/l3_chi.py +388 -0
- odas_tpw/chi/l4_chi.py +391 -0
- odas_tpw/config_base.py +423 -0
- odas_tpw/perturb/__init__.py +18 -0
- odas_tpw/perturb/adcp.py +274 -0
- odas_tpw/perturb/atomic_io.py +47 -0
- odas_tpw/perturb/binning.py +761 -0
- odas_tpw/perturb/cli.py +591 -0
- odas_tpw/perturb/combo.py +346 -0
- odas_tpw/perturb/config.py +1046 -0
- odas_tpw/perturb/ctd.py +424 -0
- odas_tpw/perturb/diag/__init__.py +12 -0
- odas_tpw/perturb/diag/_common.py +140 -0
- odas_tpw/perturb/diag/chi.py +42 -0
- odas_tpw/perturb/diag/cli.py +67 -0
- odas_tpw/perturb/diag/data.py +662 -0
- odas_tpw/perturb/diag/epsilon.py +45 -0
- odas_tpw/perturb/diag/inspector.py +342 -0
- odas_tpw/perturb/diag/mixing.py +94 -0
- odas_tpw/perturb/diag/render.py +543 -0
- odas_tpw/perturb/discover.py +40 -0
- odas_tpw/perturb/fp07_cal.py +354 -0
- odas_tpw/perturb/gps.py +458 -0
- odas_tpw/perturb/hotel.py +572 -0
- odas_tpw/perturb/logging_setup.py +181 -0
- odas_tpw/perturb/merge.py +320 -0
- odas_tpw/perturb/mk_sections.py +310 -0
- odas_tpw/perturb/netcdf_schema.py +520 -0
- odas_tpw/perturb/pipeline.py +2654 -0
- odas_tpw/perturb/plot/__init__.py +15 -0
- odas_tpw/perturb/plot/cli.py +107 -0
- odas_tpw/perturb/plot/diagnostics.py +195 -0
- odas_tpw/perturb/plot/eps_chi.py +524 -0
- odas_tpw/perturb/plot/figure.py +679 -0
- odas_tpw/perturb/plot/gamma_scaling.py +1097 -0
- odas_tpw/perturb/plot/grid.py +112 -0
- odas_tpw/perturb/plot/layout.py +361 -0
- odas_tpw/perturb/plot/overview.py +547 -0
- odas_tpw/perturb/plot/presets/eps-chi.yaml +16 -0
- odas_tpw/perturb/plot/presets/profiles.yaml +32 -0
- odas_tpw/perturb/plot/presets/scalar.yaml +21 -0
- odas_tpw/perturb/plot/profiles.py +652 -0
- odas_tpw/perturb/plot/scalar.py +391 -0
- odas_tpw/perturb/plot/sections.py +506 -0
- odas_tpw/perturb/plot/xaxis.py +396 -0
- odas_tpw/perturb/qc_gate.py +177 -0
- odas_tpw/perturb/qc_rules.py +373 -0
- odas_tpw/perturb/resolve.py +306 -0
- odas_tpw/perturb/seawater.py +76 -0
- odas_tpw/perturb/speed.py +14 -0
- odas_tpw/perturb/trim.py +313 -0
- odas_tpw/processing/__init__.py +62 -0
- odas_tpw/processing/bottom.py +215 -0
- odas_tpw/processing/chi_combine.py +300 -0
- odas_tpw/processing/ct_align.py +172 -0
- odas_tpw/processing/epsilon_combine.py +210 -0
- odas_tpw/processing/mixing.py +630 -0
- odas_tpw/processing/thorpe.py +458 -0
- odas_tpw/processing/top_trim.py +274 -0
- odas_tpw/pyturb/__init__.py +3 -0
- odas_tpw/pyturb/__main__.py +5 -0
- odas_tpw/pyturb/_compat.py +204 -0
- odas_tpw/pyturb/_profind.py +124 -0
- odas_tpw/pyturb/bin.py +113 -0
- odas_tpw/pyturb/cli.py +175 -0
- odas_tpw/pyturb/eps.py +353 -0
- odas_tpw/pyturb/merge.py +101 -0
- odas_tpw/pyturb/p2nc.py +76 -0
- odas_tpw/rsi/__init__.py +31 -0
- odas_tpw/rsi/adapter.py +314 -0
- odas_tpw/rsi/bench.py +904 -0
- odas_tpw/rsi/binning.py +193 -0
- odas_tpw/rsi/channels.py +376 -0
- odas_tpw/rsi/chi_io.py +980 -0
- odas_tpw/rsi/cli.py +1411 -0
- odas_tpw/rsi/combine.py +120 -0
- odas_tpw/rsi/config.py +126 -0
- odas_tpw/rsi/config_patch.py +790 -0
- odas_tpw/rsi/convert.py +651 -0
- odas_tpw/rsi/deconvolve.py +119 -0
- odas_tpw/rsi/diss_look.py +343 -0
- odas_tpw/rsi/dissipation.py +669 -0
- odas_tpw/rsi/helpers.py +527 -0
- odas_tpw/rsi/mixing_look.py +509 -0
- odas_tpw/rsi/p_file.py +771 -0
- odas_tpw/rsi/pipeline.py +924 -0
- odas_tpw/rsi/profile.py +624 -0
- odas_tpw/rsi/quick_look.py +758 -0
- odas_tpw/rsi/sensor_inventory.py +684 -0
- odas_tpw/rsi/shear_noise.py +128 -0
- odas_tpw/rsi/speed.py +221 -0
- odas_tpw/rsi/vehicle.py +74 -0
- odas_tpw/rsi/viewer_base.py +1071 -0
- odas_tpw/rsi/window.py +459 -0
- odas_tpw/scor160/__init__.py +36 -0
- odas_tpw/scor160/cli.py +242 -0
- odas_tpw/scor160/compare.py +484 -0
- odas_tpw/scor160/despike.py +227 -0
- odas_tpw/scor160/goodman.py +288 -0
- odas_tpw/scor160/io.py +690 -0
- odas_tpw/scor160/l2.py +311 -0
- odas_tpw/scor160/l3.py +279 -0
- odas_tpw/scor160/l4.py +850 -0
- odas_tpw/scor160/nasmyth.py +208 -0
- odas_tpw/scor160/ocean.py +287 -0
- odas_tpw/scor160/profile.py +231 -0
- odas_tpw/scor160/spectral.py +482 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
# Jun-2026, Claude and Pat Welch, pat@mousebrains.com
|
|
2
|
+
"""Post-acquisition patching of the embedded configuration string in RSI ``.p`` files.
|
|
3
|
+
|
|
4
|
+
It is common for an instrument's onboard configuration (calibration coefficients,
|
|
5
|
+
vehicle, cruise metadata) to be wrong or incomplete at acquisition time and only
|
|
6
|
+
corrected afterwards. This module rewrites selected fields of the INI-style
|
|
7
|
+
configuration string embedded in record 0 of a ``.p`` file, writing the result to
|
|
8
|
+
a NEW file. The original is never modified, and the full original configuration is
|
|
9
|
+
embedded (commented out) in the patched file for provenance and recovery.
|
|
10
|
+
|
|
11
|
+
Design (verified against RSI ``patch_setupstr.m`` and the ``PFile`` reader):
|
|
12
|
+
|
|
13
|
+
* The data records are copied byte-for-byte. Only record 0's header word 11
|
|
14
|
+
(``config_size``) is updated to the new configuration length. No reader (this
|
|
15
|
+
package's ``PFile``, RSI ``read_odas.m`` / ``odas_p2mat.m``) consumes the stale
|
|
16
|
+
``config_size`` carried in the data-record headers, so they are left untouched.
|
|
17
|
+
* The header version is preserved (RSI's full rewrite downgrades v6.1 → v6.0).
|
|
18
|
+
* Acquisition-defining parameters (the ``[matrix]`` stanza and ``[root]``
|
|
19
|
+
``rate``/``recsize``/``no-fast``/``no-slow``) are unreachable by design — they
|
|
20
|
+
cannot be addressed through the edit spec, so they can never be corrupted.
|
|
21
|
+
|
|
22
|
+
The edit spec is a small YAML file (see :func:`scaffold_yaml`). Every value must be
|
|
23
|
+
a quoted string so the text written to the file is exactly what the user typed —
|
|
24
|
+
this avoids YAML coercing ``0.1130`` → ``0.113`` or ``2.5e5`` → ``250000.0``.
|
|
25
|
+
|
|
26
|
+
The text editor mirrors :func:`odas_tpw.rsi.p_file.parse_config` tokenization
|
|
27
|
+
exactly (comment stripped at the first ``;``, section/assignment regexes on the
|
|
28
|
+
stripped line, keys compared case-insensitively, channel ``name`` values verbatim)
|
|
29
|
+
so that what the editor targets is exactly what ``PFile`` later reads.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import difflib
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import struct
|
|
38
|
+
import sys
|
|
39
|
+
import warnings
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from odas_tpw.rsi.p_file import (
|
|
45
|
+
_H,
|
|
46
|
+
HEADER_BYTES,
|
|
47
|
+
HEADER_WORDS,
|
|
48
|
+
_detect_endian,
|
|
49
|
+
parse_config,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
try: # package version for the provenance banner
|
|
53
|
+
from importlib.metadata import version as _pkg_version
|
|
54
|
+
|
|
55
|
+
_VERSION = _pkg_version("microstructure-tpw")
|
|
56
|
+
except Exception: # pragma: no cover - fallback only when metadata is unavailable
|
|
57
|
+
_VERSION = "unknown"
|
|
58
|
+
|
|
59
|
+
# Matches RSI patch_setupstr.m so RSI's -revert can still recover the original.
|
|
60
|
+
CONFIG_MARKER = "; ### Original Configuration String Below ###"
|
|
61
|
+
TOOL_NAME = "rsi-tpw patch-config"
|
|
62
|
+
|
|
63
|
+
# Top-level keys allowed in the edit spec. Acquisition stanzas (root, matrix) are
|
|
64
|
+
# deliberately absent: they cannot be addressed and therefore cannot be corrupted.
|
|
65
|
+
ALLOWED_TOP_LEVEL = {"note", "author", "instrument_info", "cruise_info", "channels"}
|
|
66
|
+
EDITABLE_SECTIONS = {"instrument_info", "cruise_info"}
|
|
67
|
+
# 'name' identifies a channel and (in any stanza) creates an RSI section alias
|
|
68
|
+
# (setupstr.m). It must never be set through this tool.
|
|
69
|
+
RESERVED_KEY = "name"
|
|
70
|
+
|
|
71
|
+
# Same tokenization as parse_config (p_file.py).
|
|
72
|
+
_SECTION_RE = re.compile(r"^\[(.+)\]$")
|
|
73
|
+
_ASSIGN_RE = re.compile(r"^(.+?)\s*=\s*(.*)$")
|
|
74
|
+
# Splits a raw assignment line so the value can be replaced while leading
|
|
75
|
+
# whitespace, the '=' alignment, trailing whitespace and any inline comment
|
|
76
|
+
# are all preserved.
|
|
77
|
+
_LINE_RE = re.compile(
|
|
78
|
+
r"^(?P<lead>\s*)(?P<key>[^=\s][^=]*?)(?P<sep>\s*=\s*)(?P<val>.*?)(?P<trail>\s*)$"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Edit spec
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class EditSpec:
|
|
89
|
+
"""A parsed, validated set of configuration edits."""
|
|
90
|
+
|
|
91
|
+
note: str
|
|
92
|
+
author: str
|
|
93
|
+
sections: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
94
|
+
channels: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def touches_channels(self) -> bool:
|
|
98
|
+
return bool(self.channels)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Change:
|
|
103
|
+
"""A single applied edit (for provenance comments and CLI reporting)."""
|
|
104
|
+
|
|
105
|
+
where: str # e.g. "instrument_info" or "channel sh1"
|
|
106
|
+
key: str # original-case key as written to the file
|
|
107
|
+
old: str | None # None when the key is being added
|
|
108
|
+
new: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check_value(value: object, ctx: str) -> str:
|
|
112
|
+
"""Validate one edit-spec value and return its verbatim text.
|
|
113
|
+
|
|
114
|
+
Values must be quoted strings in the YAML. A bare ``0.1130`` / ``2.5e5`` /
|
|
115
|
+
``yes`` would be coerced by the YAML loader to a float/bool and lose its exact
|
|
116
|
+
text, so non-string scalars are rejected with guidance to quote them.
|
|
117
|
+
"""
|
|
118
|
+
if isinstance(value, bool) or not isinstance(value, str):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"{ctx}: value must be quoted text to preserve it exactly "
|
|
121
|
+
f'(e.g. {ctx.rsplit(".", 1)[-1]}: "0.1130"); got {type(value).__name__} {value!r}'
|
|
122
|
+
)
|
|
123
|
+
text = str(value)
|
|
124
|
+
if not text.isascii():
|
|
125
|
+
raise ValueError(f"{ctx}: value must be ASCII (the .p config is ASCII); got {text!r}")
|
|
126
|
+
if ";" in text:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"{ctx}: value must not contain ';' (the config parser truncates at it); got {text!r}"
|
|
129
|
+
)
|
|
130
|
+
# A newline (e.g. from a YAML block scalar or '\n' escape) would break out
|
|
131
|
+
# of the key=value assignment and inject arbitrary stanzas — bypassing the
|
|
132
|
+
# guarantee that [matrix]/[root] acquisition parameters are unaddressable.
|
|
133
|
+
if any(ord(c) < 0x20 for c in text):
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"{ctx}: value must be a single line of printable text "
|
|
136
|
+
f"(no newlines or control characters); got {text!r}"
|
|
137
|
+
)
|
|
138
|
+
return text
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_keymap(raw: object, ctx: str) -> dict[str, str]:
|
|
142
|
+
"""Validate one stanza/channel mapping from the spec and return key→text."""
|
|
143
|
+
if not isinstance(raw, dict):
|
|
144
|
+
raise ValueError(f"{ctx}: expected a mapping of key: value, got {type(raw).__name__}")
|
|
145
|
+
out: dict[str, str] = {}
|
|
146
|
+
for key, value in raw.items():
|
|
147
|
+
key = str(key)
|
|
148
|
+
if key.strip().lower() == RESERVED_KEY:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"{ctx}.{key}: '{RESERVED_KEY}' identifies a channel/section and cannot be set"
|
|
151
|
+
)
|
|
152
|
+
# The key is written verbatim as ``{key} = {value}``. Validate it as
|
|
153
|
+
# strictly as the value: a newline/control char would split the
|
|
154
|
+
# assignment and a ';'/'['/']'/'=' would inject a stanza or comment,
|
|
155
|
+
# defeating the guarantee that acquisition params are unaddressable.
|
|
156
|
+
if not key.isascii() or any(ord(c) < 0x20 for c in key) or any(c in key for c in "=;[]"):
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"{ctx}: key {key!r} must be a single line of ASCII text with no "
|
|
159
|
+
f"'=', ';', '[' or ']' (the key is written verbatim as 'key = value')"
|
|
160
|
+
)
|
|
161
|
+
out[key] = _check_value(value, f"{ctx}.{key}")
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def load_edit_spec(path: str | Path) -> EditSpec:
|
|
166
|
+
"""Load and validate a YAML edit spec.
|
|
167
|
+
|
|
168
|
+
Raises ``ValueError`` with an actionable message on any malformed input —
|
|
169
|
+
unknown top-level keys, a missing ``note``, an unquoted/non-ASCII value, or a
|
|
170
|
+
reserved ``name`` key.
|
|
171
|
+
"""
|
|
172
|
+
from ruamel.yaml import YAML
|
|
173
|
+
|
|
174
|
+
path = Path(path)
|
|
175
|
+
yaml = YAML(typ="rt")
|
|
176
|
+
yaml.preserve_quotes = True
|
|
177
|
+
with open(path, encoding="utf-8") as fh:
|
|
178
|
+
data = yaml.load(fh)
|
|
179
|
+
if data is None:
|
|
180
|
+
raise ValueError(f"{path.name}: edit spec is empty")
|
|
181
|
+
if not isinstance(data, dict):
|
|
182
|
+
raise ValueError(f"{path.name}: edit spec must be a YAML mapping")
|
|
183
|
+
|
|
184
|
+
unknown = {str(k) for k in data} - ALLOWED_TOP_LEVEL
|
|
185
|
+
if unknown:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"{path.name}: unknown top-level key(s) {sorted(unknown)}; "
|
|
188
|
+
f"valid keys: {sorted(ALLOWED_TOP_LEVEL)}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
note = data.get("note")
|
|
192
|
+
if not isinstance(note, str) or not note.strip():
|
|
193
|
+
raise ValueError(f"{path.name}: a non-empty 'note' describing the change is required")
|
|
194
|
+
if not note.isascii() or any(ord(c) < 0x20 for c in note.strip()):
|
|
195
|
+
raise ValueError(f"{path.name}: 'note' must be a single line of ASCII text")
|
|
196
|
+
note = note.strip()
|
|
197
|
+
|
|
198
|
+
author = data.get("author")
|
|
199
|
+
if author is not None and (
|
|
200
|
+
not isinstance(author, str) or not author.isascii() or any(ord(c) < 0x20 for c in author)
|
|
201
|
+
):
|
|
202
|
+
raise ValueError(f"{path.name}: 'author' must be a single line of ASCII text")
|
|
203
|
+
author = str(author).strip() if author else (os.environ.get("USER") or "unknown")
|
|
204
|
+
|
|
205
|
+
sections: dict[str, dict[str, str]] = {}
|
|
206
|
+
for sec in EDITABLE_SECTIONS:
|
|
207
|
+
if sec in data:
|
|
208
|
+
sections[sec] = _check_keymap(data[sec], sec)
|
|
209
|
+
|
|
210
|
+
channels: dict[str, dict[str, str]] = {}
|
|
211
|
+
if "channels" in data:
|
|
212
|
+
chans = data["channels"]
|
|
213
|
+
if not isinstance(chans, dict):
|
|
214
|
+
raise ValueError(f"{path.name}: 'channels' must be a mapping of channel-name: {{...}}")
|
|
215
|
+
for name, kv in chans.items():
|
|
216
|
+
channels[str(name)] = _check_keymap(kv, f"channels.{name}")
|
|
217
|
+
|
|
218
|
+
if not sections and not channels:
|
|
219
|
+
raise ValueError(f"{path.name}: edit spec contains no edits")
|
|
220
|
+
|
|
221
|
+
return EditSpec(note=note, author=author, sections=sections, channels=channels)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# Config-text parsing and editing (pure string -> string)
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _code_part(line: str) -> str:
|
|
230
|
+
"""Portion of a raw line before the first ';' (matches parse_config)."""
|
|
231
|
+
idx = line.find(";")
|
|
232
|
+
return line if idx < 0 else line[:idx]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _split_eol(line: str) -> tuple[str, str]:
|
|
236
|
+
for term in ("\r\n", "\r", "\n"):
|
|
237
|
+
if line.endswith(term):
|
|
238
|
+
return line[: -len(term)], term
|
|
239
|
+
return line, ""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _detect_eol(text: str) -> str:
|
|
243
|
+
if "\r\n" in text:
|
|
244
|
+
return "\r\n"
|
|
245
|
+
if "\r" in text:
|
|
246
|
+
return "\r"
|
|
247
|
+
return "\n"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_blocks(raw_lines: list[str]) -> list[dict[str, Any]]:
|
|
251
|
+
"""Walk lines exactly as parse_config does, recording per-stanza key locations.
|
|
252
|
+
|
|
253
|
+
Each block is ``{"section", "name", "keys", "header_idx", "last_key_idx"}``
|
|
254
|
+
where ``keys`` maps a lower-cased key to ``(line_index, original_key, value)``.
|
|
255
|
+
Commented lines (including ones that mention ``[matrix]`` in prose) strip to
|
|
256
|
+
empty and are skipped, so they never create a phantom section or key.
|
|
257
|
+
"""
|
|
258
|
+
blocks: list[dict[str, Any]] = []
|
|
259
|
+
cur: dict[str, Any] = {
|
|
260
|
+
"section": "root",
|
|
261
|
+
"name": None,
|
|
262
|
+
"keys": {},
|
|
263
|
+
"header_idx": -1,
|
|
264
|
+
"last_key_idx": -1,
|
|
265
|
+
}
|
|
266
|
+
blocks.append(cur)
|
|
267
|
+
for i, line in enumerate(raw_lines):
|
|
268
|
+
stripped = _code_part(line).strip()
|
|
269
|
+
if not stripped:
|
|
270
|
+
continue
|
|
271
|
+
ms = _SECTION_RE.match(stripped)
|
|
272
|
+
if ms:
|
|
273
|
+
cur = {
|
|
274
|
+
"section": ms.group(1).strip().lower(),
|
|
275
|
+
"name": None,
|
|
276
|
+
"keys": {},
|
|
277
|
+
"header_idx": i,
|
|
278
|
+
"last_key_idx": i,
|
|
279
|
+
}
|
|
280
|
+
blocks.append(cur)
|
|
281
|
+
continue
|
|
282
|
+
ma = _ASSIGN_RE.match(stripped)
|
|
283
|
+
if ma:
|
|
284
|
+
orig = ma.group(1).strip()
|
|
285
|
+
val = ma.group(2).strip()
|
|
286
|
+
kl = orig.lower()
|
|
287
|
+
cur["keys"][kl] = (i, orig, val)
|
|
288
|
+
cur["last_key_idx"] = i
|
|
289
|
+
if cur["section"] == "channel" and kl == RESERVED_KEY:
|
|
290
|
+
cur["name"] = val
|
|
291
|
+
return blocks
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _rewrite_value(raw_line: str, new_value: str) -> str:
|
|
295
|
+
"""Replace the value in an assignment line, preserving everything else."""
|
|
296
|
+
body, eol = _split_eol(raw_line)
|
|
297
|
+
idx = body.find(";")
|
|
298
|
+
code, comment = (body, "") if idx < 0 else (body[:idx], body[idx:])
|
|
299
|
+
m = _LINE_RE.match(code)
|
|
300
|
+
if m is None: # pragma: no cover - only assignment lines are ever passed here
|
|
301
|
+
return raw_line
|
|
302
|
+
return f"{m['lead']}{m['key']}{m['sep']}{new_value}{m['trail']}{comment}{eol}"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _comment_lines(text: str, eol: str) -> str:
|
|
306
|
+
"""Prefix every line of *text* with '; ' (for the embedded original)."""
|
|
307
|
+
out = []
|
|
308
|
+
for line in text.splitlines(keepends=True):
|
|
309
|
+
body, term = _split_eol(line)
|
|
310
|
+
out.append(f"; {body}{term or eol}")
|
|
311
|
+
return "".join(out)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _find_self_original_marker(config_str: str) -> int:
|
|
315
|
+
"""Index of the CONFIG_MARKER that begins *this tool's* frozen original block.
|
|
316
|
+
|
|
317
|
+
Returns -1 when no qualifying marker exists. The marker must (a) start at
|
|
318
|
+
column 0 (be the start of the string or immediately follow an EOL) and (b)
|
|
319
|
+
have every subsequent non-blank line be a ``; ``-prefixed comment — exactly
|
|
320
|
+
what :func:`_comment_lines` writes. This avoids mis-detecting a marker that
|
|
321
|
+
merely appears inside an ORIGINAL config (e.g. an RSI-pre-patched file whose
|
|
322
|
+
uncommented stanzas live below the marker, or a descriptive comment that
|
|
323
|
+
happens to contain the marker text), which would otherwise freeze those
|
|
324
|
+
stanzas and make them silently un-editable.
|
|
325
|
+
"""
|
|
326
|
+
search_from = 0
|
|
327
|
+
while True:
|
|
328
|
+
idx = config_str.find(CONFIG_MARKER, search_from)
|
|
329
|
+
if idx < 0:
|
|
330
|
+
return -1
|
|
331
|
+
at_line_start = idx == 0 or config_str[idx - 1] in "\r\n"
|
|
332
|
+
if at_line_start:
|
|
333
|
+
# Everything after the marker line must be a fully commented block.
|
|
334
|
+
post = config_str[idx + len(CONFIG_MARKER) :]
|
|
335
|
+
if all(
|
|
336
|
+
(not (b := _split_eol(ln)[0].strip())) or b.startswith(";")
|
|
337
|
+
for ln in post.splitlines(keepends=True)
|
|
338
|
+
):
|
|
339
|
+
return idx
|
|
340
|
+
search_from = idx + len(CONFIG_MARKER)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def edit_config_text(
|
|
344
|
+
config_str: str,
|
|
345
|
+
spec: EditSpec,
|
|
346
|
+
*,
|
|
347
|
+
add_keys: bool = False,
|
|
348
|
+
when: str = "",
|
|
349
|
+
source_label: str = "",
|
|
350
|
+
) -> tuple[str, list[Change]]:
|
|
351
|
+
"""Apply *spec* to a configuration string.
|
|
352
|
+
|
|
353
|
+
Returns ``(new_config_text, changes)``. When ``changes`` is empty the input is
|
|
354
|
+
returned unchanged (the caller then skips writing the file). Raises
|
|
355
|
+
``ValueError`` for unknown stanzas/keys (unless ``add_keys``), unknown or
|
|
356
|
+
ambiguous channel names, or a missing target section.
|
|
357
|
+
"""
|
|
358
|
+
# Apply-boundary re-validation: a directly-constructed EditSpec (bypassing
|
|
359
|
+
# the validated load_edit_spec) is still key/value-checked here, so the
|
|
360
|
+
# stanza-injection guard holds on the programmatic API, not only the CLI.
|
|
361
|
+
for _sec, _kv in spec.sections.items():
|
|
362
|
+
_check_keymap(_kv, _sec)
|
|
363
|
+
for _name, _kv in spec.channels.items():
|
|
364
|
+
_check_keymap(_kv, f"channels.{_name}")
|
|
365
|
+
|
|
366
|
+
eol = _detect_eol(config_str)
|
|
367
|
+
|
|
368
|
+
# Separate any previously-embedded original block; we only edit above it and
|
|
369
|
+
# re-attach it verbatim (idempotent re-patch, mirroring patch_setupstr.m).
|
|
370
|
+
# Only a marker WE wrote (at column 0, followed by a fully commented block)
|
|
371
|
+
# is treated as frozen; a marker living inside an original config stays
|
|
372
|
+
# active so its stanzas remain editable.
|
|
373
|
+
marker_idx = _find_self_original_marker(config_str)
|
|
374
|
+
if marker_idx >= 0:
|
|
375
|
+
active = config_str[:marker_idx]
|
|
376
|
+
original_block: str | None = config_str[marker_idx:]
|
|
377
|
+
else:
|
|
378
|
+
active = config_str
|
|
379
|
+
original_block = None
|
|
380
|
+
|
|
381
|
+
raw_lines = active.splitlines(keepends=True)
|
|
382
|
+
blocks = _parse_blocks(raw_lines)
|
|
383
|
+
|
|
384
|
+
sect_block: dict[str, dict[str, Any]] = {}
|
|
385
|
+
sect_keys: dict[str, dict[str, Any]] = {}
|
|
386
|
+
chan_by_name: dict[str, list[dict[str, Any]]] = {}
|
|
387
|
+
for b in blocks:
|
|
388
|
+
if b["section"] == "channel":
|
|
389
|
+
if b["name"] is not None:
|
|
390
|
+
chan_by_name.setdefault(b["name"], []).append(b)
|
|
391
|
+
else:
|
|
392
|
+
sect_block[b["section"]] = b
|
|
393
|
+
sect_keys.setdefault(b["section"], {}).update(b["keys"])
|
|
394
|
+
|
|
395
|
+
changes: list[Change] = []
|
|
396
|
+
replace: dict[int, tuple[str, str]] = {}
|
|
397
|
+
inserts: dict[int, list[tuple[str, str]]] = {}
|
|
398
|
+
|
|
399
|
+
def provenance(ch: Change) -> str:
|
|
400
|
+
# Sanitize every interpolated field (when/author/note/old/new) the same
|
|
401
|
+
# way _build_banner does: a stray newline in a directly-constructed
|
|
402
|
+
# EditSpec or a non-trivial 'when' must not break out of the '; ' comment
|
|
403
|
+
# prefix and inject active config (defense-in-depth; mirrors the banner).
|
|
404
|
+
head = f"; [PATCH {_one_line(when)} {_one_line(spec.author)}]".rstrip()
|
|
405
|
+
note = _one_line(spec.note)
|
|
406
|
+
new = _one_line(ch.new)
|
|
407
|
+
# Include the stanza/channel context and quote both values so the
|
|
408
|
+
# original and new value are unambiguous (e.g. which channel's 'SN',
|
|
409
|
+
# and that an empty old value really was empty).
|
|
410
|
+
if ch.old is None:
|
|
411
|
+
return f'{head} {ch.where} {ch.key}: (added) = "{new}" ({note}){eol}'
|
|
412
|
+
return f'{head} {ch.where} {ch.key}: "{_one_line(ch.old)}" -> "{new}" ({note}){eol}'
|
|
413
|
+
|
|
414
|
+
def schedule(
|
|
415
|
+
keys: dict, block: dict, where: str, key: str, new_value: str, *, is_inst_sn: bool
|
|
416
|
+
) -> None:
|
|
417
|
+
kl = key.lower()
|
|
418
|
+
if kl in keys:
|
|
419
|
+
idx, orig, cur = keys[kl]
|
|
420
|
+
if cur == new_value:
|
|
421
|
+
return # already at the target value — not a change
|
|
422
|
+
ch = Change(where, orig, cur, new_value)
|
|
423
|
+
changes.append(ch)
|
|
424
|
+
replace[idx] = (provenance(ch), _rewrite_value(raw_lines[idx], new_value))
|
|
425
|
+
if is_inst_sn:
|
|
426
|
+
warnings.warn(
|
|
427
|
+
f"{where}: changing the instrument serial number 'sn' "
|
|
428
|
+
f"({cur!r} -> {new_value!r}); this is rarely correct",
|
|
429
|
+
stacklevel=2,
|
|
430
|
+
)
|
|
431
|
+
else:
|
|
432
|
+
if not add_keys:
|
|
433
|
+
avail = sorted({orig for _i, orig, _v in keys.values()})
|
|
434
|
+
raise ValueError(
|
|
435
|
+
f"{where}: unknown key '{key}'; existing keys: {avail} "
|
|
436
|
+
f"(use --add-keys to add a new one)"
|
|
437
|
+
)
|
|
438
|
+
ch = Change(where, key, None, new_value)
|
|
439
|
+
changes.append(ch)
|
|
440
|
+
ins = block["last_key_idx"] + 1
|
|
441
|
+
inserts.setdefault(ins, []).append((provenance(ch), f"{key} = {new_value}{eol}"))
|
|
442
|
+
|
|
443
|
+
for section, kv in spec.sections.items():
|
|
444
|
+
if section not in sect_block:
|
|
445
|
+
raise ValueError(f"[{section}] section is not present in this file")
|
|
446
|
+
for key, val in kv.items():
|
|
447
|
+
schedule(
|
|
448
|
+
sect_keys[section],
|
|
449
|
+
sect_block[section],
|
|
450
|
+
section,
|
|
451
|
+
key,
|
|
452
|
+
val,
|
|
453
|
+
is_inst_sn=(section == "instrument_info" and key.lower() == "sn"),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
for name, kv in spec.channels.items():
|
|
457
|
+
blist = chan_by_name.get(name)
|
|
458
|
+
if not blist:
|
|
459
|
+
raise ValueError(
|
|
460
|
+
f"channel '{name}' not found; available channels: {sorted(chan_by_name)}"
|
|
461
|
+
)
|
|
462
|
+
if len(blist) > 1:
|
|
463
|
+
raise ValueError(
|
|
464
|
+
f"channel name '{name}' appears {len(blist)} times; cannot target it unambiguously"
|
|
465
|
+
)
|
|
466
|
+
for key, val in kv.items():
|
|
467
|
+
schedule(blist[0]["keys"], blist[0], f"channel {name}", key, val, is_inst_sn=False)
|
|
468
|
+
|
|
469
|
+
if not changes:
|
|
470
|
+
return config_str, []
|
|
471
|
+
|
|
472
|
+
# Reassemble the active region with provenance comments interleaved.
|
|
473
|
+
out: list[str] = []
|
|
474
|
+
for i, line in enumerate(raw_lines):
|
|
475
|
+
for prov, newl in inserts.get(i, []):
|
|
476
|
+
out.append(prov)
|
|
477
|
+
out.append(newl)
|
|
478
|
+
if i in replace:
|
|
479
|
+
prov, newl = replace[i]
|
|
480
|
+
out.append(prov)
|
|
481
|
+
out.append(newl)
|
|
482
|
+
else:
|
|
483
|
+
out.append(line)
|
|
484
|
+
for prov, newl in inserts.get(len(raw_lines), []):
|
|
485
|
+
out.append(prov)
|
|
486
|
+
out.append(newl)
|
|
487
|
+
edited_active = "".join(out)
|
|
488
|
+
|
|
489
|
+
banner = _build_banner(when, spec.author, spec.note, source_label, eol)
|
|
490
|
+
new_config = banner + edited_active
|
|
491
|
+
if not new_config.endswith(("\n", "\r")):
|
|
492
|
+
new_config += eol
|
|
493
|
+
if original_block is not None:
|
|
494
|
+
new_config += original_block
|
|
495
|
+
else:
|
|
496
|
+
new_config += eol + CONFIG_MARKER + eol + _comment_lines(active, eol)
|
|
497
|
+
return new_config, changes
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _one_line(text: str) -> str:
|
|
501
|
+
"""Collapse any control characters to spaces so an interpolated field can
|
|
502
|
+
never break out of its leading ``; `` comment prefix in the banner."""
|
|
503
|
+
return "".join(" " if ord(c) < 0x20 else c for c in text)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _build_banner(when: str, author: str, note: str, source_label: str, eol: str) -> str:
|
|
507
|
+
# note/author are control-char-validated upstream; source_label is a
|
|
508
|
+
# machine-derived path; 'when' is caller-supplied (public API). Collapse all
|
|
509
|
+
# of them defensively so a stray newline cannot spill past the '; ' prefix
|
|
510
|
+
# and become active config.
|
|
511
|
+
when, author, note, source_label = (
|
|
512
|
+
_one_line(when),
|
|
513
|
+
_one_line(author),
|
|
514
|
+
_one_line(note),
|
|
515
|
+
_one_line(source_label),
|
|
516
|
+
)
|
|
517
|
+
bar = "; " + "=" * 70 + eol
|
|
518
|
+
lines = [
|
|
519
|
+
bar,
|
|
520
|
+
f"; PATCHED {when} by {author} -- {TOOL_NAME} {_VERSION}".rstrip() + eol,
|
|
521
|
+
]
|
|
522
|
+
if source_label:
|
|
523
|
+
lines.append(f"; source: {source_label}{eol}")
|
|
524
|
+
lines.append(f"; note: {note}{eol}")
|
|
525
|
+
lines.append(bar)
|
|
526
|
+
return "".join(lines)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# ---------------------------------------------------------------------------
|
|
530
|
+
# Binary I/O
|
|
531
|
+
# ---------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def read_config_text(path: str | Path) -> str:
|
|
535
|
+
"""Read just the embedded configuration string (latin-1, lossless)."""
|
|
536
|
+
path = Path(path)
|
|
537
|
+
with open(path, "rb") as f:
|
|
538
|
+
head = f.read(HEADER_BYTES)
|
|
539
|
+
if len(head) < HEADER_BYTES:
|
|
540
|
+
raise ValueError(f"{path.name}: file too small for a header")
|
|
541
|
+
endian = _detect_endian(head, path)
|
|
542
|
+
words = struct.unpack(f"{endian}{HEADER_WORDS}H", head)
|
|
543
|
+
header_size = words[_H["header_size"]]
|
|
544
|
+
config_size = words[_H["config_size"]]
|
|
545
|
+
# Bounds-validate header_size: a corrupt source advertising header_size
|
|
546
|
+
# < HEADER_BYTES would otherwise read config from inside its own header,
|
|
547
|
+
# matching the guard extract_pfile_segment already enforces.
|
|
548
|
+
if header_size < HEADER_BYTES:
|
|
549
|
+
raise ValueError(f"{path.name}: invalid header_size={header_size}")
|
|
550
|
+
f.seek(0, 2)
|
|
551
|
+
file_size = f.tell()
|
|
552
|
+
if header_size + config_size > file_size:
|
|
553
|
+
raise ValueError(
|
|
554
|
+
f"{path.name}: header_size+config_size ({header_size + config_size}) exceeds "
|
|
555
|
+
f"file size ({file_size}); truncated or corrupt"
|
|
556
|
+
)
|
|
557
|
+
f.seek(header_size)
|
|
558
|
+
cfg = f.read(config_size)
|
|
559
|
+
if len(cfg) != config_size:
|
|
560
|
+
raise ValueError(f"{path.name}: config string truncated")
|
|
561
|
+
return cfg.decode("latin-1")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def write_patched_pfile(src: str | Path, dst: str | Path, new_config_text: str) -> Path:
|
|
565
|
+
"""Write *dst* = *src* with its embedded config replaced by *new_config_text*.
|
|
566
|
+
|
|
567
|
+
Copies the original header (patching only ``config_size``) and every data
|
|
568
|
+
record byte-for-byte. Refuses legacy (header major version < 6) files and any
|
|
569
|
+
configuration that would overflow the uint16 ``config_size`` field.
|
|
570
|
+
"""
|
|
571
|
+
src = Path(src)
|
|
572
|
+
dst = Path(dst)
|
|
573
|
+
new_bytes = new_config_text.encode("latin-1")
|
|
574
|
+
with open(src, "rb") as f:
|
|
575
|
+
head = f.read(HEADER_BYTES)
|
|
576
|
+
if len(head) < HEADER_BYTES:
|
|
577
|
+
raise ValueError(f"{src.name}: file too small for a header")
|
|
578
|
+
endian = _detect_endian(head, src)
|
|
579
|
+
words = struct.unpack(f"{endian}{HEADER_WORDS}H", head)
|
|
580
|
+
header_size = words[_H["header_size"]]
|
|
581
|
+
config_size = words[_H["config_size"]]
|
|
582
|
+
# Bounds-validate header_size before it is used to size header_region /
|
|
583
|
+
# pack config_size: a corrupt header_size < HEADER_BYTES would otherwise
|
|
584
|
+
# raise a cryptic struct.error (uncatchable by the CLI's ValueError/OSError
|
|
585
|
+
# handler) or silently write a truncated (<128-byte) header.
|
|
586
|
+
if header_size < HEADER_BYTES:
|
|
587
|
+
raise ValueError(f"{src.name}: invalid header_size={header_size}")
|
|
588
|
+
major = words[_H["header_version"]] >> 8
|
|
589
|
+
if major < 6:
|
|
590
|
+
raise ValueError(
|
|
591
|
+
f"{src.name}: header version {major}.x (< 6) is not supported by patch-config"
|
|
592
|
+
)
|
|
593
|
+
if len(new_bytes) > 0xFFFF:
|
|
594
|
+
raise ValueError(
|
|
595
|
+
f"{src.name}: patched config is {len(new_bytes)} bytes, exceeds the "
|
|
596
|
+
f"65535-byte limit of the config_size header field"
|
|
597
|
+
)
|
|
598
|
+
# Guard against a truncated/corrupt source whose header advertises a
|
|
599
|
+
# first record larger than the file: seeking past EOF would otherwise
|
|
600
|
+
# copy zero data records and silently produce a data-less output.
|
|
601
|
+
file_size = src.stat().st_size
|
|
602
|
+
first_record_size = header_size + config_size
|
|
603
|
+
if first_record_size >= file_size:
|
|
604
|
+
raise ValueError(
|
|
605
|
+
f"{src.name}: header_size+config_size ({first_record_size}) is not smaller "
|
|
606
|
+
f"than the file ({file_size} bytes); the source is truncated or has no data records"
|
|
607
|
+
)
|
|
608
|
+
f.seek(0)
|
|
609
|
+
header_region = bytearray(f.read(header_size))
|
|
610
|
+
struct.pack_into(f"{endian}H", header_region, _H["config_size"] * 2, len(new_bytes))
|
|
611
|
+
|
|
612
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
613
|
+
# Preserve the no-overwrite intent up front (cheap, and avoids work on a
|
|
614
|
+
# collision); the final os.replace re-checks just before committing.
|
|
615
|
+
if dst.exists():
|
|
616
|
+
raise FileExistsError(f"{dst} already exists; remove it or choose another --out")
|
|
617
|
+
f.seek(first_record_size) # start of the first data record
|
|
618
|
+
# Atomic write: stream into a sibling temp file, fsync, then os.replace
|
|
619
|
+
# into place. A mid-copy failure (disk full, I/O error, interrupt) thus
|
|
620
|
+
# leaves only the temp file (which we unlink), never a truncated but
|
|
621
|
+
# valid-looking "patched" .p in the final destination. "xb" keeps the
|
|
622
|
+
# temp create exclusive so concurrent patches can't share it.
|
|
623
|
+
tmp = dst.with_name(f"{dst.name}.tmp.{os.getpid()}")
|
|
624
|
+
try:
|
|
625
|
+
with open(tmp, "xb") as out:
|
|
626
|
+
out.write(header_region)
|
|
627
|
+
out.write(new_bytes)
|
|
628
|
+
while True:
|
|
629
|
+
chunk = f.read(1024 * 1024)
|
|
630
|
+
if not chunk:
|
|
631
|
+
break
|
|
632
|
+
out.write(chunk)
|
|
633
|
+
out.flush()
|
|
634
|
+
os.fsync(out.fileno())
|
|
635
|
+
if dst.exists():
|
|
636
|
+
raise FileExistsError(f"{dst} already exists; remove it or choose another --out")
|
|
637
|
+
# os.link (not os.replace): atomically fails if dst already exists,
|
|
638
|
+
# preserving the no-clobber-under-concurrency guarantee the prior
|
|
639
|
+
# open(dst, "xb") had — os.replace would silently overwrite a file a
|
|
640
|
+
# racing process created between the check above and the commit.
|
|
641
|
+
os.link(tmp, dst)
|
|
642
|
+
tmp.unlink()
|
|
643
|
+
except BaseException:
|
|
644
|
+
tmp.unlink(missing_ok=True)
|
|
645
|
+
raise
|
|
646
|
+
return dst
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
# Template scaffolding
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def scaffold_yaml(pfile_path: str | Path) -> str:
|
|
655
|
+
"""Build a commented edit-spec template pre-filled from *pfile_path*."""
|
|
656
|
+
p = Path(pfile_path)
|
|
657
|
+
cfg = parse_config(read_config_text(p))
|
|
658
|
+
ii = cfg.get("instrument_info", {})
|
|
659
|
+
ci = cfg.get("cruise_info", {})
|
|
660
|
+
chans = cfg.get("channels", [])
|
|
661
|
+
|
|
662
|
+
lines = [
|
|
663
|
+
f"# rsi-tpw patch-config edit spec (generated from {p.name})",
|
|
664
|
+
"#",
|
|
665
|
+
"# HOW TO USE:",
|
|
666
|
+
'# 1. Change the values you want. QUOTE every value, e.g. sens: "0.0812"',
|
|
667
|
+
"# 2. Delete every line you are NOT changing (only real edits are applied).",
|
|
668
|
+
f"# 3. rsi-tpw patch-config {p.name} --edits THIS.yaml --out patched/",
|
|
669
|
+
"#",
|
|
670
|
+
"# Your original file is never modified. If nothing actually changes, no",
|
|
671
|
+
"# file is written.",
|
|
672
|
+
"",
|
|
673
|
+
'note: "describe what you changed and why"',
|
|
674
|
+
'# author: "your name"',
|
|
675
|
+
"",
|
|
676
|
+
"instrument_info:",
|
|
677
|
+
f' vehicle: "{ii.get("vehicle", "")}" # the field that actually affects processing',
|
|
678
|
+
]
|
|
679
|
+
if ii.get("model") is not None:
|
|
680
|
+
lines.append(f' # model: "{ii.get("model", "")}"')
|
|
681
|
+
lines.append("")
|
|
682
|
+
|
|
683
|
+
if ci:
|
|
684
|
+
lines.append("# cruise_info:")
|
|
685
|
+
for key, val in ci.items():
|
|
686
|
+
lines.append(f'# {key}: "{val}"')
|
|
687
|
+
lines.append("")
|
|
688
|
+
|
|
689
|
+
lines.append("# channels: # per-channel calibration (usually one instrument at a time)")
|
|
690
|
+
for ch in chans:
|
|
691
|
+
name = ch.get("name")
|
|
692
|
+
if not name:
|
|
693
|
+
continue
|
|
694
|
+
shown = [k for k in ("sens", "sn", "cal_date", "coef0", "coef1") if k in ch]
|
|
695
|
+
if not shown:
|
|
696
|
+
continue
|
|
697
|
+
lines.append(f"# {name}:")
|
|
698
|
+
for key in shown:
|
|
699
|
+
lines.append(f'# {key}: "{ch[key]}"')
|
|
700
|
+
lines.append("")
|
|
701
|
+
return "\n".join(lines) + "\n"
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# ---------------------------------------------------------------------------
|
|
705
|
+
# Orchestration
|
|
706
|
+
# ---------------------------------------------------------------------------
|
|
707
|
+
|
|
708
|
+
# Editing any channel key is per-instrument calibration; applying it to many files
|
|
709
|
+
# at once is usually a mistake, so it requires an explicit opt-in.
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def patch_files(
|
|
713
|
+
srcs: list[Path],
|
|
714
|
+
out_dir: str | Path,
|
|
715
|
+
spec: EditSpec,
|
|
716
|
+
*,
|
|
717
|
+
dry_run: bool = False,
|
|
718
|
+
add_keys: bool = False,
|
|
719
|
+
batch_cal: bool = False,
|
|
720
|
+
when: str = "",
|
|
721
|
+
) -> list[tuple[Path, Path | None, list[Change]]]:
|
|
722
|
+
"""Apply *spec* to each source file, writing patched copies into *out_dir*.
|
|
723
|
+
|
|
724
|
+
Returns ``(src, dst_or_None, changes)`` per file. ``dst`` is ``None`` when the
|
|
725
|
+
file was a no-op (no values changed) or for a dry run. Progress is printed.
|
|
726
|
+
"""
|
|
727
|
+
out_dir = Path(out_dir)
|
|
728
|
+
if spec.touches_channels and len(srcs) > 1 and not batch_cal:
|
|
729
|
+
raise ValueError(
|
|
730
|
+
"refusing to apply per-channel calibration edits to multiple files "
|
|
731
|
+
"(calibrations are per-instrument); pass --batch-cal if this is intended"
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
# Outputs are keyed by basename (out_dir / src.name), so two inputs that
|
|
735
|
+
# share a basename (e.g. dirA/SAME.p and dirB/SAME.p) would collide: the
|
|
736
|
+
# first is written and the second aborts the whole batch with a misleading
|
|
737
|
+
# "already exists" error. Detect the real cause up front, before any output.
|
|
738
|
+
by_name: dict[str, list[Path]] = {}
|
|
739
|
+
for src in srcs:
|
|
740
|
+
by_name.setdefault(Path(src).name, []).append(Path(src))
|
|
741
|
+
collisions = {name: paths for name, paths in by_name.items() if len(paths) > 1}
|
|
742
|
+
if collisions:
|
|
743
|
+
detail = "; ".join(
|
|
744
|
+
f"{name}: {', '.join(str(p) for p in paths)}"
|
|
745
|
+
for name, paths in sorted(collisions.items())
|
|
746
|
+
)
|
|
747
|
+
raise ValueError(
|
|
748
|
+
f"multiple source files share a basename and would map to the same output "
|
|
749
|
+
f"in {out_dir}: {detail}; patch them separately into distinct --out directories"
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
results: list[tuple[Path, Path | None, list[Change]]] = []
|
|
753
|
+
for src in srcs:
|
|
754
|
+
src = Path(src)
|
|
755
|
+
dst = out_dir / src.name
|
|
756
|
+
if dst.resolve() == src.resolve():
|
|
757
|
+
raise ValueError(
|
|
758
|
+
f"--out would overwrite the original {src}; choose a different directory"
|
|
759
|
+
)
|
|
760
|
+
cfg = read_config_text(src)
|
|
761
|
+
new_text, changes = edit_config_text(
|
|
762
|
+
cfg, spec, add_keys=add_keys, when=when, source_label=str(src.resolve())
|
|
763
|
+
)
|
|
764
|
+
if not changes:
|
|
765
|
+
print(f"{src.name}: no changes; not written")
|
|
766
|
+
results.append((src, None, []))
|
|
767
|
+
continue
|
|
768
|
+
if dry_run:
|
|
769
|
+
print(f"--- {src.name} (dry run, {len(changes)} change(s)) ---")
|
|
770
|
+
diff = difflib.unified_diff(
|
|
771
|
+
cfg.splitlines(), new_text.splitlines(), src.name, dst.name, lineterm=""
|
|
772
|
+
)
|
|
773
|
+
for line in diff:
|
|
774
|
+
print(line)
|
|
775
|
+
results.append((src, None, changes))
|
|
776
|
+
continue
|
|
777
|
+
if dst.exists():
|
|
778
|
+
raise FileExistsError(f"{dst} already exists; remove it or choose another --out")
|
|
779
|
+
write_patched_pfile(src, dst, new_text)
|
|
780
|
+
print(f"{src.name}: {len(changes)} change(s) -> {dst}")
|
|
781
|
+
for ch in changes:
|
|
782
|
+
arrow = f'(added) = "{ch.new}"' if ch.old is None else f'"{ch.old}" -> "{ch.new}"'
|
|
783
|
+
print(f" {ch.where} {ch.key}: {arrow}")
|
|
784
|
+
results.append((src, dst, changes))
|
|
785
|
+
return results
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _print_error_and_exit(message: str) -> None: # pragma: no cover - CLI glue
|
|
789
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
790
|
+
sys.exit(1)
|