utterverify 0.1.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.
utterverify/cli.py ADDED
@@ -0,0 +1,406 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .bundle import configure_bundle, init_bundle_config, verify_bundle
13
+ from .config import (
14
+ CONFIG_FILENAME,
15
+ UNSET,
16
+ VerifyConfig,
17
+ _UnsetType,
18
+ default_config,
19
+ load_config,
20
+ update_config,
21
+ )
22
+ from .core import verify_renderplan
23
+ from .doctor import DoctorResult, doctor_bundle, run_doctor
24
+ from .errors import BundleError, ConfigError, UtterVerifyError
25
+
26
+
27
+ def _parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="utterverify",
30
+ description="Validate VoiceRender speech WAVs against RenderPlan text with SpeechScope.",
31
+ )
32
+ parser.add_argument("--version", action="version", version=f"utterverify {__version__}")
33
+ commands = parser.add_subparsers(dest="command", required=True)
34
+
35
+ direct_config = commands.add_parser(
36
+ "configure", help="Create or update a direct verification config"
37
+ )
38
+ direct_config.add_argument("directory", type=Path)
39
+ direct_config.add_argument("--backend", "--engine", dest="backend")
40
+ direct_config.add_argument("--device")
41
+ direct_config.add_argument("--model")
42
+ direct_config.add_argument("--unset-model", action="store_true")
43
+ direct_config.add_argument("--normalization")
44
+ direct_config.add_argument(
45
+ "--use-language-hint", action=argparse.BooleanOptionalAction, default=None
46
+ )
47
+ direct_config.add_argument(
48
+ "--fail-on-review", action=argparse.BooleanOptionalAction, default=None
49
+ )
50
+ direct_config.add_argument("--fail-fast", action=argparse.BooleanOptionalAction, default=None)
51
+
52
+ direct = commands.add_parser("verify", help="Verify one RenderPlan handoff")
53
+ direct.add_argument("plan", type=Path)
54
+ direct.add_argument("--render-dir", type=Path, required=True)
55
+ direct.add_argument("--output-dir", type=Path, required=True)
56
+ direct.add_argument("--json", action="store_true")
57
+
58
+ bundle = commands.add_parser("bundle", help="Canonical .ssmd-bundle workflows")
59
+ bundle_commands = bundle.add_subparsers(dest="bundle_command", required=True)
60
+
61
+ init = bundle_commands.add_parser("init", help="Create utterverify/<variant>/utterverify.toml")
62
+ init.add_argument("bundle", type=Path)
63
+ init.add_argument("--variant")
64
+ init.add_argument("--backend", "--engine", dest="backend", default="redux")
65
+ init.add_argument("--device", default="cpu")
66
+ init.add_argument("--model")
67
+ init.add_argument("--normalization", default="basic-v1")
68
+ init.add_argument("--use-language-hint", action="store_true")
69
+ init.add_argument("--fail-on-review", action="store_true")
70
+ init.add_argument("--fail-fast", action="store_true")
71
+ init.add_argument("--force", action="store_true")
72
+
73
+ configure = bundle_commands.add_parser("configure", help="Update a bundle validation config")
74
+ configure.add_argument("bundle", type=Path)
75
+ configure.add_argument("--variant")
76
+ configure.add_argument("--backend", "--engine", dest="backend")
77
+ configure.add_argument("--device")
78
+ configure.add_argument("--model")
79
+ configure.add_argument("--unset-model", action="store_true")
80
+ configure.add_argument("--normalization")
81
+ configure.add_argument(
82
+ "--use-language-hint", action=argparse.BooleanOptionalAction, default=None
83
+ )
84
+ configure.add_argument("--fail-on-review", action=argparse.BooleanOptionalAction, default=None)
85
+ configure.add_argument("--fail-fast", action=argparse.BooleanOptionalAction, default=None)
86
+
87
+ verify = bundle_commands.add_parser("verify", help="Verify a rendered bundle variant")
88
+ verify.add_argument("bundle", type=Path)
89
+ verify.add_argument("--variant")
90
+ verify.add_argument("--chapters", default="all")
91
+ verify.add_argument("--json", action="store_true")
92
+
93
+ doctor = commands.add_parser("doctor", help="Check local validation-stack readiness")
94
+ doctor.add_argument("bundle", type=Path, nargs="?")
95
+ doctor.add_argument("--variant")
96
+ doctor.add_argument("--config", type=Path)
97
+ doctor.add_argument("--backend", "--engine", dest="backend")
98
+ doctor.add_argument("--device")
99
+ doctor.add_argument("--model")
100
+ doctor.add_argument("--json", action="store_true")
101
+ return parser
102
+
103
+
104
+ def _configure_direct(args: argparse.Namespace) -> Path:
105
+ if args.model is not None and args.unset_model:
106
+ raise ConfigError("--model and --unset-model cannot be used together")
107
+ directory = Path(args.directory).expanduser()
108
+ if directory.is_symlink():
109
+ raise ConfigError(f"verification config directory must not be a symlink: {directory}")
110
+ try:
111
+ directory.mkdir(parents=True, exist_ok=True)
112
+ except OSError as exc:
113
+ raise ConfigError(f"cannot create verification config directory: {directory}") from exc
114
+ if not directory.is_dir():
115
+ raise ConfigError(f"verification config path is not a directory: {directory}")
116
+
117
+ config_path = directory / CONFIG_FILENAME
118
+ if config_path.is_symlink():
119
+ raise ConfigError(f"verification config must not be a symlink: {config_path}")
120
+ existing = load_config(config_path) if config_path.exists() else default_config()
121
+ if (
122
+ args.backend is not None
123
+ and args.backend != existing.backend
124
+ and existing.model is not None
125
+ and args.model is None
126
+ and not args.unset_model
127
+ ):
128
+ raise ConfigError(
129
+ "changing the backend while a model is configured requires --model or --unset-model"
130
+ )
131
+
132
+ effective_backend = args.backend or existing.backend
133
+ model: str | None | _UnsetType = UNSET
134
+ if args.unset_model:
135
+ model = None
136
+ elif args.model is not None:
137
+ candidate = Path(args.model).expanduser()
138
+ if candidate.exists():
139
+ if not candidate.is_file():
140
+ raise ConfigError(f"whisper.cpp model path is not a regular file: {candidate}")
141
+ model = str(candidate.resolve())
142
+ elif effective_backend == "whispercpp" and not args.model.startswith("whispercpp:"):
143
+ raise ConfigError(
144
+ "whisper.cpp model must be an existing local file or a canonical "
145
+ "SpeechScope model reference such as whispercpp:tiny.en-q5_1"
146
+ )
147
+ else:
148
+ model = args.model
149
+
150
+ update_config(
151
+ config_path,
152
+ backend=args.backend,
153
+ device=args.device,
154
+ model=model,
155
+ normalization=args.normalization,
156
+ use_language_hint=args.use_language_hint,
157
+ fail_on_review=args.fail_on_review,
158
+ fail_fast=args.fail_fast,
159
+ )
160
+ return config_path
161
+
162
+
163
+ def main(argv: list[str] | None = None) -> int:
164
+ args = _parser().parse_args(argv)
165
+ try:
166
+ if args.command == "configure":
167
+ path = _configure_direct(args)
168
+ print(path)
169
+ return 0
170
+
171
+ if args.command == "verify":
172
+ unit_result = verify_renderplan(
173
+ args.plan,
174
+ render_dir=args.render_dir,
175
+ output_dir=args.output_dir,
176
+ )
177
+ payload: dict[str, object] = {
178
+ "id": unit_result.id,
179
+ "status": unit_result.status,
180
+ "render_plan_id": unit_result.render_plan_id,
181
+ "source_plan_id": unit_result.source_plan_id,
182
+ "render_profile_id": unit_result.render_profile_id,
183
+ "manifest_id": unit_result.manifest_id,
184
+ "segments": [_segment_payload(segment) for segment in unit_result.segments],
185
+ "segment_count": len(unit_result.segments),
186
+ "passed": unit_result.passed,
187
+ "reviewed": unit_result.reviewed,
188
+ "failed": unit_result.failed,
189
+ "errors": unit_result.errors,
190
+ "output": str(unit_result.output_dir),
191
+ "report": str(unit_result.output_dir / "utterverify-report.toml"),
192
+ }
193
+ if args.json:
194
+ print(json.dumps(payload, ensure_ascii=False))
195
+ else:
196
+ _print_unit(payload, unit_result.segments)
197
+ config = load_config(args.output_dir / CONFIG_FILENAME)
198
+ if unit_result.status == "error":
199
+ return 2
200
+ if unit_result.status == "fail" or (
201
+ unit_result.status == "review" and config.fail_on_review
202
+ ):
203
+ return 1
204
+ return 0
205
+
206
+ if args.command == "doctor":
207
+ if args.bundle is not None and args.config is not None:
208
+ raise BundleError("doctor BUNDLE and --config are mutually exclusive")
209
+ if args.variant is not None and args.bundle is None:
210
+ raise BundleError("--variant is valid only when doctor is given a bundle")
211
+ if args.bundle is not None:
212
+ doctor_result = doctor_bundle(
213
+ args.bundle,
214
+ variant=args.variant,
215
+ backend=args.backend,
216
+ device=args.device,
217
+ model=args.model if args.model is not None else UNSET,
218
+ )
219
+ else:
220
+ doctor_result = run_doctor(
221
+ config_path=args.config,
222
+ backend=args.backend,
223
+ device=args.device,
224
+ model=args.model if args.model is not None else UNSET,
225
+ )
226
+ _print_doctor(doctor_result, json_output=args.json)
227
+ if doctor_result.input_error:
228
+ return 2
229
+ return 0 if doctor_result.ok else 3
230
+
231
+ if args.bundle_command == "init":
232
+ config = VerifyConfig(
233
+ backend=args.backend,
234
+ device=args.device,
235
+ model=args.model,
236
+ normalization=args.normalization,
237
+ use_language_hint=args.use_language_hint,
238
+ fail_on_review=args.fail_on_review,
239
+ fail_fast=args.fail_fast,
240
+ )
241
+ path = init_bundle_config(
242
+ args.bundle,
243
+ variant=args.variant,
244
+ config=config,
245
+ overwrite=args.force,
246
+ )
247
+ print(path)
248
+ return 0
249
+
250
+ if args.bundle_command == "configure":
251
+ path = configure_bundle(
252
+ args.bundle,
253
+ variant=args.variant,
254
+ backend=args.backend,
255
+ device=args.device,
256
+ model=args.model if args.model is not None else UNSET,
257
+ unset_model=args.unset_model,
258
+ normalization=args.normalization,
259
+ use_language_hint=args.use_language_hint,
260
+ fail_on_review=args.fail_on_review,
261
+ fail_fast=args.fail_fast,
262
+ )
263
+ print(path)
264
+ return 0
265
+
266
+ result = verify_bundle(
267
+ args.bundle,
268
+ variant=args.variant,
269
+ chapters=args.chapters,
270
+ on_start=_print_bundle_start,
271
+ on_progress=_print_bundle_progress,
272
+ )
273
+ unit_payloads: list[dict[str, object]] = []
274
+ segment_payloads: list[dict[str, object]] = []
275
+ for unit in result.units:
276
+ unit_segments = [_segment_payload(segment) for segment in unit.segments]
277
+ unit_payloads.append(
278
+ {
279
+ "id": unit.id,
280
+ "status": unit.status,
281
+ "renderplan": str(unit.renderplan),
282
+ "render_plan_id": unit.render_plan_id,
283
+ "source_plan_id": unit.source_plan_id,
284
+ "render_profile_id": unit.render_profile_id,
285
+ "manifest_id": unit.manifest_id,
286
+ "segments": unit_segments,
287
+ }
288
+ )
289
+ segment_payloads.extend({"unit_id": unit.id, **segment} for segment in unit_segments)
290
+ payload = {
291
+ "bundle": str(result.bundle),
292
+ "kind": result.kind,
293
+ "variant": result.variant,
294
+ "status": result.status,
295
+ "segments": segment_payloads,
296
+ "segment_count": result.segments,
297
+ "passed": result.passed,
298
+ "reviewed": result.reviewed,
299
+ "failed": result.failed,
300
+ "errors": result.errors,
301
+ "units": unit_payloads,
302
+ "config": str(result.config_path),
303
+ "config_created": result.config_created,
304
+ "report": str(result.report_path),
305
+ "exit_code": result.exit_code,
306
+ }
307
+ if args.json:
308
+ print(json.dumps(payload, ensure_ascii=False))
309
+ else:
310
+ print("UtterVerify bundle summary", file=sys.stderr)
311
+ print(f"status: {result.status}", file=sys.stderr)
312
+ print(f"segments: {result.segments}", file=sys.stderr)
313
+ print(f"pass: {result.passed}", file=sys.stderr)
314
+ print(f"review: {result.reviewed}", file=sys.stderr)
315
+ print(f"fail: {result.failed}", file=sys.stderr)
316
+ print(f"errors: {result.errors}", file=sys.stderr)
317
+ print(f"config: {result.config_path}", file=sys.stderr)
318
+ print(f"report: {result.report_path}", file=sys.stderr)
319
+ for unit in result.units:
320
+ for segment in unit.segments:
321
+ _print_segment(segment, unit_id=unit.id, file=sys.stderr)
322
+ return result.exit_code
323
+ except UtterVerifyError as exc:
324
+ print(f"utterverify: {exc}", file=sys.stderr)
325
+ return 2
326
+
327
+
328
+ def _print_doctor(result: DoctorResult, *, json_output: bool) -> None:
329
+ if json_output:
330
+ print(json.dumps(result.to_dict(), ensure_ascii=False))
331
+ return
332
+ print("UtterVerify doctor")
333
+ print(f"backend: {result.backend}")
334
+ print(f"device: {result.device}")
335
+ model = result.model if result.model else "<none>"
336
+ source = f" ({result.model_source})" if result.model_source != "none" else ""
337
+ print(f"model: {model}{source}")
338
+ print()
339
+ for check in result.checks:
340
+ print(f"{check.status.upper():4} {check.name.replace('_', '-'):24} {check.detail}")
341
+ if check.hint:
342
+ print(f" hint: {check.hint}")
343
+ print("\nready" if result.ok else "\nnot ready")
344
+
345
+
346
+ def _print_bundle_start(start) -> None:
347
+ print("UtterVerify bundle verify", file=sys.stderr)
348
+ print(f" workspace: {start.workspace}", file=sys.stderr)
349
+ print(f" kind: {start.kind}", file=sys.stderr)
350
+ print(f" variant: {start.variant}", file=sys.stderr)
351
+ print(f" chapters: {start.selector}", file=sys.stderr)
352
+ print(f" backend: {start.backend}", file=sys.stderr)
353
+ print(f" model: {start.model or '<none>'}", file=sys.stderr)
354
+ print(file=sys.stderr)
355
+
356
+
357
+ def _print_bundle_progress(progress) -> None:
358
+ print(
359
+ f"[{progress.index}/{progress.total}] {progress.id}: {progress.status}",
360
+ file=sys.stderr,
361
+ )
362
+
363
+
364
+ def _segment_payload(segment) -> dict[str, object]:
365
+ return {
366
+ "number": segment.number,
367
+ "segment_id": segment.segment_id,
368
+ "reference_text": segment.reference_text,
369
+ "wav": str(segment.wav),
370
+ "wav_sha256": segment.wav_sha256,
371
+ "report": str(segment.report) if segment.report is not None else None,
372
+ "status": segment.status,
373
+ "language": segment.language,
374
+ "message": segment.message,
375
+ }
376
+
377
+
378
+ def _print_segment(segment, *, unit_id: str | None = None, file=None) -> None:
379
+ prefix = f"{unit_id} " if unit_id is not None else ""
380
+ message = f" — {segment.message}" if segment.message else ""
381
+ print(
382
+ f"{segment.status.upper():12} {prefix}{segment.number:06d} "
383
+ f"{segment.segment_id} {segment.wav_sha256}{message}",
384
+ file=sys.stdout if file is None else file,
385
+ )
386
+
387
+
388
+ def _print_unit(payload: Mapping[str, object], segments) -> None:
389
+ print("UtterVerify RenderPlan validation")
390
+ for name in (
391
+ "status",
392
+ "segment_count",
393
+ "passed",
394
+ "reviewed",
395
+ "failed",
396
+ "errors",
397
+ "output",
398
+ "report",
399
+ ):
400
+ print(f"{name + ':':10} {payload[name]}")
401
+ for segment in segments:
402
+ _print_segment(segment)
403
+
404
+
405
+ if __name__ == "__main__":
406
+ raise SystemExit(main())
utterverify/config.py ADDED
@@ -0,0 +1,261 @@
1
+ """Persisted validation configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ import tomllib
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import tomlkit
13
+
14
+ from .errors import ConfigError
15
+
16
+ CONFIG_FILENAME = "utterverify.toml"
17
+ CONFIG_FORMAT = "utterverify.config"
18
+ CONFIG_SCHEMA_VERSION = 1
19
+
20
+
21
+ class _UnsetType:
22
+ """Sentinel distinguishing an omitted setting from an explicit unset."""
23
+
24
+
25
+ UNSET = _UnsetType()
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class VerifyConfig:
30
+ backend: str = "redux"
31
+ device: str = "cpu"
32
+ model: str | None = None
33
+ normalization: str = "basic-v1"
34
+ use_language_hint: bool = False
35
+ max_wer: float | None = None
36
+ max_compound_wer: float | None = 0.0
37
+ max_cer: float | None = 0.0
38
+ max_mer: float | None = None
39
+ min_reference_timestamp_coverage: float | None = None
40
+ max_invalid_timestamp_count: int | None = None
41
+ missing_timing: str = "review"
42
+ fail_on_review: bool = False
43
+ fail_fast: bool = False
44
+ extra: dict[str, Any] = field(default_factory=dict, compare=False, repr=False)
45
+
46
+ def threshold_kwargs(self) -> dict[str, Any]:
47
+ values: dict[str, Any] = {"schema_version": "1", "missing_timing": self.missing_timing}
48
+ for name in (
49
+ "max_wer",
50
+ "max_compound_wer",
51
+ "max_cer",
52
+ "max_mer",
53
+ "min_reference_timestamp_coverage",
54
+ "max_invalid_timestamp_count",
55
+ ):
56
+ value = getattr(self, name)
57
+ if value is not None:
58
+ values[name] = value
59
+ return values
60
+
61
+
62
+ def default_config() -> VerifyConfig:
63
+ return VerifyConfig()
64
+
65
+
66
+ def ensure_config(
67
+ path: str | Path, config: VerifyConfig | None = None
68
+ ) -> tuple[VerifyConfig, bool]:
69
+ target = Path(path)
70
+ if target.exists():
71
+ return load_config(target), False
72
+ selected = config or default_config()
73
+ write_config(target, selected)
74
+ return selected, True
75
+
76
+
77
+ def write_config(path: str | Path, config: VerifyConfig, *, overwrite: bool = False) -> Path:
78
+ target = Path(path)
79
+ if target.exists() and not overwrite:
80
+ raise ConfigError(f"configuration already exists: {target}")
81
+ target.parent.mkdir(parents=True, exist_ok=True)
82
+ doc = tomlkit.document()
83
+ doc.add("format", CONFIG_FORMAT)
84
+ doc.add("schema_version", CONFIG_SCHEMA_VERSION)
85
+ doc.add(tomlkit.nl())
86
+
87
+ scope = tomlkit.table()
88
+ scope.add("backend", config.backend)
89
+ scope.add("device", config.device)
90
+ if config.model is not None:
91
+ scope.add("model", config.model)
92
+ scope.add("normalization", config.normalization)
93
+ scope.add("use_language_hint", config.use_language_hint)
94
+ doc.add("speechscope", scope)
95
+
96
+ thresholds = tomlkit.table()
97
+ thresholds.add("schema_version", "1")
98
+ for name in (
99
+ "max_wer",
100
+ "max_compound_wer",
101
+ "max_cer",
102
+ "max_mer",
103
+ "min_reference_timestamp_coverage",
104
+ "max_invalid_timestamp_count",
105
+ ):
106
+ value = getattr(config, name)
107
+ if value is not None:
108
+ thresholds.add(name, value)
109
+ thresholds.add("missing_timing", config.missing_timing)
110
+ doc.add("thresholds", thresholds)
111
+
112
+ run = tomlkit.table()
113
+ run.add("fail_on_review", config.fail_on_review)
114
+ run.add("fail_fast", config.fail_fast)
115
+ doc.add("run", run)
116
+ _atomic_text(target, tomlkit.dumps(doc))
117
+ return target
118
+
119
+
120
+ def load_config(path: str | Path) -> VerifyConfig:
121
+ source = Path(path)
122
+ try:
123
+ data = tomllib.loads(source.read_text(encoding="utf-8"))
124
+ except (OSError, tomllib.TOMLDecodeError) as exc:
125
+ raise ConfigError(f"cannot read configuration {source}: {exc}") from exc
126
+ if data.get("format") != CONFIG_FORMAT or data.get("schema_version") != CONFIG_SCHEMA_VERSION:
127
+ raise ConfigError("unsupported utterverify.toml format or schema_version")
128
+ speechscope = data.get("speechscope")
129
+ thresholds = data.get("thresholds")
130
+ run = data.get("run", {})
131
+ if (
132
+ not isinstance(speechscope, dict)
133
+ or not isinstance(thresholds, dict)
134
+ or not isinstance(run, dict)
135
+ ):
136
+ raise ConfigError("speechscope, thresholds, and run must be TOML tables")
137
+
138
+ backend = _nonempty_text(speechscope.get("backend"), "speechscope.backend")
139
+ device = _nonempty_text(speechscope.get("device"), "speechscope.device")
140
+ normalization = _nonempty_text(speechscope.get("normalization"), "speechscope.normalization")
141
+ model = speechscope.get("model")
142
+ if model is not None:
143
+ model = _nonempty_text(model, "speechscope.model")
144
+ use_language_hint = speechscope.get("use_language_hint", False)
145
+ if type(use_language_hint) is not bool:
146
+ raise ConfigError("speechscope.use_language_hint must be boolean")
147
+ if thresholds.get("schema_version", "1") != "1":
148
+ raise ConfigError("thresholds.schema_version must be '1'")
149
+ missing_timing = thresholds.get("missing_timing", "review")
150
+ if missing_timing not in {"review", "fail"}:
151
+ raise ConfigError("thresholds.missing_timing must be 'review' or 'fail'")
152
+
153
+ numbers: dict[str, Any] = {}
154
+ for name in (
155
+ "max_wer",
156
+ "max_compound_wer",
157
+ "max_cer",
158
+ "max_mer",
159
+ "min_reference_timestamp_coverage",
160
+ ):
161
+ value = thresholds.get(name)
162
+ if value is not None:
163
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
164
+ raise ConfigError(f"thresholds.{name} must be a non-negative number")
165
+ if name == "min_reference_timestamp_coverage" and value > 1:
166
+ raise ConfigError("thresholds.min_reference_timestamp_coverage must be <= 1")
167
+ numbers[name] = float(value)
168
+ else:
169
+ numbers[name] = None
170
+ invalid_count = thresholds.get("max_invalid_timestamp_count")
171
+ if invalid_count is not None and (type(invalid_count) is not int or invalid_count < 0):
172
+ raise ConfigError("thresholds.max_invalid_timestamp_count must be a non-negative integer")
173
+ for flag in ("fail_on_review", "fail_fast"):
174
+ if flag in run and type(run[flag]) is not bool:
175
+ raise ConfigError(f"run.{flag} must be boolean")
176
+
177
+ return VerifyConfig(
178
+ backend=backend,
179
+ device=device,
180
+ model=model,
181
+ normalization=normalization,
182
+ use_language_hint=use_language_hint,
183
+ max_invalid_timestamp_count=invalid_count,
184
+ missing_timing=missing_timing,
185
+ fail_on_review=run.get("fail_on_review", False),
186
+ fail_fast=run.get("fail_fast", False),
187
+ **numbers,
188
+ )
189
+
190
+
191
+ def _nonempty_text(value: Any, name: str) -> str:
192
+ if not isinstance(value, str) or not value.strip():
193
+ raise ConfigError(f"{name} must be non-empty text")
194
+ return value
195
+
196
+
197
+ def _atomic_text(path: Path, content: str) -> None:
198
+ temp_name: str | None = None
199
+ try:
200
+ with tempfile.NamedTemporaryFile(
201
+ mode="w",
202
+ encoding="utf-8",
203
+ newline="\n",
204
+ dir=path.parent,
205
+ prefix=f".{path.name}.",
206
+ suffix=".tmp",
207
+ delete=False,
208
+ ) as handle:
209
+ temp_name = handle.name
210
+ handle.write(content)
211
+ os.replace(temp_name, path)
212
+ finally:
213
+ if temp_name is not None and os.path.exists(temp_name):
214
+ os.unlink(temp_name)
215
+
216
+
217
+ def update_config(
218
+ path: str | Path,
219
+ *,
220
+ backend: str | None = None,
221
+ device: str | None = None,
222
+ model: str | None | _UnsetType = UNSET,
223
+ normalization: str | None = None,
224
+ use_language_hint: bool | None = None,
225
+ fail_on_review: bool | None = None,
226
+ fail_fast: bool | None = None,
227
+ ) -> VerifyConfig:
228
+ """Update only supplied config keys, preserving unrelated TOML content."""
229
+ target = Path(path)
230
+ if not target.exists():
231
+ write_config(target, default_config())
232
+ load_config(target)
233
+ try:
234
+ document = tomlkit.parse(target.read_text(encoding="utf-8"))
235
+ except (OSError, tomlkit.exceptions.ParseError) as exc:
236
+ raise ConfigError(f"cannot update configuration {target}: {exc}") from exc
237
+
238
+ if "run" not in document:
239
+ document["run"] = tomlkit.table()
240
+ speechscope = document["speechscope"]
241
+ run = document["run"]
242
+ if backend is not None:
243
+ speechscope["backend"] = backend
244
+ if device is not None:
245
+ speechscope["device"] = device
246
+ if model is not UNSET:
247
+ if model is None:
248
+ speechscope.pop("model", None)
249
+ else:
250
+ speechscope["model"] = model
251
+ if normalization is not None:
252
+ speechscope["normalization"] = normalization
253
+ if use_language_hint is not None:
254
+ speechscope["use_language_hint"] = use_language_hint
255
+ if fail_on_review is not None:
256
+ run["fail_on_review"] = fail_on_review
257
+ if fail_fast is not None:
258
+ run["fail_fast"] = fail_fast
259
+
260
+ _atomic_text(target, tomlkit.dumps(document))
261
+ return load_config(target)