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/bundle.py ADDED
@@ -0,0 +1,646 @@
1
+ """Canonical SSMD bundle orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import tempfile
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path, PurePosixPath, PureWindowsPath
13
+ from typing import Any, Literal
14
+
15
+ import tomlkit
16
+
17
+ from .config import (
18
+ CONFIG_FILENAME,
19
+ UNSET,
20
+ VerifyConfig,
21
+ _UnsetType,
22
+ default_config,
23
+ ensure_config,
24
+ load_config,
25
+ update_config,
26
+ write_config,
27
+ )
28
+ from .core import (
29
+ _load_handoff,
30
+ _not_evaluated_loaded,
31
+ _open_verifier,
32
+ _preflight_loaded,
33
+ _verify_loaded,
34
+ )
35
+ from .errors import BundleError, HandoffError, UtterVerifyError
36
+ from .types import BundleVerifyResult, UnitStatus, UnitVerifyResult
37
+
38
+ BOOK_FORMAT = "ssmdconvert.book"
39
+ DOCUMENT_FORMAT = "ssmdconvert.document"
40
+ REPORT_FILENAME = "utterverify-report.toml"
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class _BundleIdentity:
45
+ root: Path
46
+ kind: Literal["book", "document"]
47
+ manifest: dict[str, Any]
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class _Unit:
52
+ id: str
53
+ source_number: int
54
+ renderplan: Path
55
+ render_dir: Path
56
+ set_entry: Any | None = None
57
+ plan_set: Any | None = None
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class BundleVerifyStart:
62
+ workspace: Path
63
+ kind: Literal["book", "document"]
64
+ variant: str
65
+ selector: str
66
+ selected_unit_ids: tuple[str, ...]
67
+ config_path: Path
68
+ output_dir: Path
69
+ backend: str
70
+ model: str | None
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class BundleVerifyProgress:
75
+ id: str
76
+ source_number: int
77
+ index: int
78
+ total: int
79
+ status: str
80
+
81
+
82
+ def _bundle_output_directory(identity: _BundleIdentity, variant: str) -> Path:
83
+ """Create a safe UtterVerify output directory confined to the bundle."""
84
+ variant_name = _safe_variant(variant)
85
+ parent = identity.root / "utterverify"
86
+ if parent.is_symlink():
87
+ raise BundleError("bundle utterverify output directory must not be a symlink")
88
+ try:
89
+ parent.mkdir(parents=True, exist_ok=True)
90
+ except OSError as exc:
91
+ raise BundleError(f"cannot create UtterVerify output directory: {parent}") from exc
92
+ if not parent.is_dir():
93
+ raise BundleError(f"bundle utterverify output path is not a directory: {parent}")
94
+
95
+ output = parent / variant_name
96
+ if output.is_symlink():
97
+ raise BundleError(f"UtterVerify variant output directory must not be a symlink: {output}")
98
+ try:
99
+ output.mkdir(parents=True, exist_ok=True)
100
+ resolved = output.resolve(strict=True)
101
+ resolved.relative_to(identity.root)
102
+ except (OSError, ValueError) as exc:
103
+ raise BundleError(f"UtterVerify output directory escapes bundle: {output}") from exc
104
+ if output.is_symlink() or not resolved.is_dir():
105
+ raise BundleError(f"invalid UtterVerify output directory: {output}")
106
+ return resolved
107
+
108
+
109
+ def discover_bundle_variants(bundle: str | Path) -> tuple[str, ...]:
110
+ """Return complete safe variants present in both upstream bundle components."""
111
+ return _discover_variants(_load_bundle(bundle))
112
+
113
+
114
+ def resolve_bundle_variant(bundle: str | Path, variant: str | None) -> str:
115
+ """Select an explicit variant or the only complete variant in a bundle."""
116
+ return _resolve_variant(_load_bundle(bundle), variant)
117
+
118
+
119
+ def _discover_variants(identity: _BundleIdentity) -> tuple[str, ...]:
120
+ renderplan = _variant_directories(identity, "renderplan")
121
+ voicerender = _variant_directories(identity, "voicerender")
122
+ candidates = renderplan & voicerender
123
+ for candidate in candidates:
124
+ _safe_variant(candidate)
125
+ return tuple(sorted(candidates))
126
+
127
+
128
+ def _variant_directories(identity: _BundleIdentity, component: str) -> set[str]:
129
+ parent = identity.root / component
130
+ if parent.is_symlink():
131
+ raise BundleError(f"{component} directory must not be a symlink")
132
+ if not parent.exists():
133
+ return set()
134
+ try:
135
+ resolved = parent.resolve(strict=True)
136
+ resolved.relative_to(identity.root)
137
+ except (OSError, ValueError) as exc:
138
+ raise BundleError(f"{component} directory is missing or escapes the bundle") from exc
139
+ if not resolved.is_dir():
140
+ raise BundleError(f"{component} path is not a directory")
141
+ return {child.name for child in resolved.iterdir() if not child.is_symlink() and child.is_dir()}
142
+
143
+
144
+ def _resolve_variant(identity: _BundleIdentity, variant: str | None) -> str:
145
+ if variant is not None:
146
+ selected = _safe_variant(variant)
147
+ for component in ("renderplan", "voicerender"):
148
+ path = identity.root / component / selected
149
+ if path.is_symlink():
150
+ raise BundleError(
151
+ f"{component} variant directory must not be a symlink: {selected}"
152
+ )
153
+ _contained_dir(path, identity.root)
154
+ return selected
155
+
156
+ candidates = _discover_variants(identity)
157
+ if not candidates:
158
+ raise BundleError(
159
+ "no complete rendered variant exists under both renderplan/ and voicerender/"
160
+ )
161
+ if len(candidates) > 1:
162
+ choices = "\n".join(f" {candidate}" for candidate in candidates)
163
+ raise BundleError(
164
+ f"multiple rendered variants are available:\n{choices}\nselect one with --variant"
165
+ )
166
+ return candidates[0]
167
+
168
+
169
+ def init_bundle_config(
170
+ bundle: str | Path,
171
+ *,
172
+ variant: str | None = None,
173
+ config: VerifyConfig | None = None,
174
+ overwrite: bool = False,
175
+ ) -> Path:
176
+ identity = _load_bundle(bundle)
177
+ variant_name = _resolve_variant(identity, variant)
178
+ out = _bundle_output_directory(identity, variant_name)
179
+ path = out / CONFIG_FILENAME
180
+ if path.is_symlink():
181
+ raise BundleError(f"UtterVerify config must not be a symlink: {path}")
182
+ if path.exists() and not overwrite:
183
+ return path
184
+ write_config(path, config or default_config(), overwrite=overwrite)
185
+ return path
186
+
187
+
188
+ def configure_bundle(
189
+ bundle: str | Path,
190
+ *,
191
+ variant: str | None = None,
192
+ backend: str | None = None,
193
+ device: str | None = None,
194
+ model: str | None | _UnsetType = UNSET,
195
+ unset_model: bool = False,
196
+ normalization: str | None = None,
197
+ use_language_hint: bool | None = None,
198
+ fail_on_review: bool | None = None,
199
+ fail_fast: bool | None = None,
200
+ ) -> Path:
201
+ """Create or update the selected variant's UtterVerify configuration."""
202
+ if model is not UNSET and unset_model:
203
+ raise BundleError("--model and --unset-model cannot be used together")
204
+ identity = _load_bundle(bundle)
205
+ variant_name = _resolve_variant(identity, variant)
206
+ config_path = _bundle_output_directory(identity, variant_name) / CONFIG_FILENAME
207
+ if config_path.is_symlink():
208
+ raise BundleError(f"UtterVerify config must not be a symlink: {config_path}")
209
+ existing = load_config(config_path) if config_path.exists() else default_config()
210
+ if (
211
+ backend is not None
212
+ and backend != existing.backend
213
+ and existing.model is not None
214
+ and model is UNSET
215
+ and not unset_model
216
+ ):
217
+ raise BundleError(
218
+ "changing the backend while a model is configured requires --model or --unset-model"
219
+ )
220
+
221
+ effective_backend = backend or existing.backend
222
+ selected_model = model
223
+ if unset_model:
224
+ selected_model = None
225
+ elif isinstance(model, str) and effective_backend == "whispercpp":
226
+ selected_model = _canonicalize_whisper_model(model)
227
+
228
+ update_config(
229
+ config_path,
230
+ backend=backend,
231
+ device=device,
232
+ model=selected_model,
233
+ normalization=normalization,
234
+ use_language_hint=use_language_hint,
235
+ fail_on_review=fail_on_review,
236
+ fail_fast=fail_fast,
237
+ )
238
+ return config_path
239
+
240
+
241
+ def _canonicalize_whisper_model(value: str) -> str:
242
+ candidate = Path(value).expanduser()
243
+ if candidate.exists():
244
+ if not candidate.is_file():
245
+ raise BundleError(f"whisper.cpp model path is not a regular file: {candidate}")
246
+ return str(candidate.resolve())
247
+ try:
248
+ from speechscope import ModelManager
249
+
250
+ model = ModelManager(packaged_catalog=True).show(value, backend="whispercpp")
251
+ ref = model.get("ref")
252
+ if not isinstance(ref, str) or not ref.startswith("whispercpp:"):
253
+ raise ValueError("catalog returned a non-canonical whisper.cpp model reference")
254
+ return ref
255
+ except Exception as exc:
256
+ raise BundleError(
257
+ f"cannot resolve whisper.cpp model {value!r} from a local file or SpeechScope catalog: {exc}"
258
+ ) from exc
259
+
260
+
261
+ def verify_bundle(
262
+ bundle: str | Path,
263
+ *,
264
+ variant: str | None = None,
265
+ chapters: str = "all",
266
+ on_start: Callable[[BundleVerifyStart], None] | None = None,
267
+ on_progress: Callable[[BundleVerifyProgress], None] | None = None,
268
+ ) -> BundleVerifyResult:
269
+ identity = _load_bundle(bundle)
270
+ variant_name = _resolve_variant(identity, variant)
271
+ output_dir = _bundle_output_directory(identity, variant_name)
272
+
273
+ # Deliberately first owned artifact: config exists before RenderPlan loading and
274
+ # before SpeechScope backend/model initialization.
275
+ config_path = output_dir / CONFIG_FILENAME
276
+ if config_path.is_symlink():
277
+ raise BundleError(f"UtterVerify config must not be a symlink: {config_path}")
278
+ config, created = ensure_config(config_path)
279
+ config_sha256 = hashlib.sha256(config_path.read_bytes()).hexdigest()
280
+
281
+ units = _resolve_units(identity, variant_name, chapters)
282
+ prepared: list[tuple[_Unit, Any, Any, tuple[tuple[Any, str, Path, str], ...], Path]] = []
283
+ for unit in units:
284
+ unit_name = _safe_variant(unit.id)
285
+ plan, manifest = _load_handoff(
286
+ unit.renderplan,
287
+ unit.render_dir,
288
+ plan_set=unit.plan_set,
289
+ set_entry=unit.set_entry,
290
+ )
291
+ verified_wavs = _preflight_loaded(plan, manifest, unit.render_dir)
292
+ unit_output = output_dir / unit_name
293
+ if unit_output.is_symlink():
294
+ raise BundleError(
295
+ f"UtterVerify unit output directory must not be a symlink: {unit_output}"
296
+ )
297
+ try:
298
+ unit_output.mkdir(parents=True, exist_ok=True)
299
+ unit_output.resolve(strict=True).relative_to(output_dir)
300
+ except (OSError, ValueError) as exc:
301
+ raise BundleError(
302
+ f"UtterVerify unit output escapes output directory: {unit_output}"
303
+ ) from exc
304
+ prepared.append((unit, plan, manifest, verified_wavs, unit_output))
305
+
306
+ if on_start is not None:
307
+ on_start(
308
+ BundleVerifyStart(
309
+ workspace=identity.root,
310
+ kind=identity.kind,
311
+ variant=variant_name,
312
+ selector=chapters,
313
+ selected_unit_ids=tuple(unit.id for unit in units),
314
+ config_path=config_path,
315
+ output_dir=output_dir,
316
+ backend=config.backend,
317
+ model=config.model,
318
+ )
319
+ )
320
+ verifier = _open_verifier(config)
321
+ results: list[UnitVerifyResult] = []
322
+ try:
323
+ with verifier:
324
+ for index, (unit, plan, manifest, verified_wavs, unit_output) in enumerate(
325
+ prepared, start=1
326
+ ):
327
+ result = _verify_loaded(
328
+ plan,
329
+ manifest,
330
+ plan_path=unit.renderplan,
331
+ render_root=unit.render_dir,
332
+ output_dir=unit_output,
333
+ config=config,
334
+ unit_id=unit.id,
335
+ verifier=verifier,
336
+ preflight=verified_wavs,
337
+ )
338
+ results.append(result)
339
+ if on_progress is not None:
340
+ on_progress(
341
+ BundleVerifyProgress(
342
+ id=unit.id,
343
+ source_number=unit.source_number,
344
+ index=index,
345
+ total=len(units),
346
+ status=result.status,
347
+ )
348
+ )
349
+ if config.fail_fast and result.status in {"fail", "error"}:
350
+ for (
351
+ skipped_unit,
352
+ skipped_plan,
353
+ skipped_manifest,
354
+ skipped_wavs,
355
+ skipped_output,
356
+ ) in prepared[index:]:
357
+ results.append(
358
+ _not_evaluated_loaded(
359
+ skipped_plan,
360
+ skipped_manifest,
361
+ plan_path=skipped_unit.renderplan,
362
+ render_root=skipped_unit.render_dir,
363
+ output_dir=skipped_output,
364
+ unit_id=skipped_unit.id,
365
+ preflight=skipped_wavs,
366
+ message=(
367
+ "not evaluated because fail-fast stopped after "
368
+ f"unit {unit.id} failed"
369
+ ),
370
+ )
371
+ )
372
+ break
373
+ except UtterVerifyError:
374
+ raise
375
+ except Exception as exc:
376
+ raise HandoffError(f"SpeechScope backend failed: {exc}") from exc
377
+
378
+ status = _bundle_status(results)
379
+ report_path = output_dir / REPORT_FILENAME
380
+ bundle_result = BundleVerifyResult(
381
+ bundle=identity.root,
382
+ kind=identity.kind,
383
+ variant=variant_name,
384
+ output_dir=output_dir,
385
+ config_path=config_path,
386
+ report_path=report_path,
387
+ config_created=created,
388
+ units=tuple(results),
389
+ status=status,
390
+ fail_on_review=config.fail_on_review,
391
+ speechscope_backend=config.backend,
392
+ speechscope_device=config.device,
393
+ speechscope_model=config.model,
394
+ normalization=config.normalization,
395
+ use_language_hint=config.use_language_hint,
396
+ config_sha256=config_sha256,
397
+ )
398
+ _write_report(bundle_result)
399
+ return bundle_result
400
+
401
+
402
+ def _load_bundle(value: str | Path) -> _BundleIdentity:
403
+ path = Path(value).expanduser()
404
+ if path.is_symlink():
405
+ raise BundleError("bundle root must not be a symlink")
406
+ try:
407
+ root = path.resolve(strict=True)
408
+ except OSError as exc:
409
+ raise BundleError(f"cannot resolve bundle root: {path}") from exc
410
+ if not root.is_dir() or not root.name.endswith(".ssmd-bundle"):
411
+ raise BundleError("bundle input must be the canonical NAME.ssmd-bundle directory")
412
+ manifest_path = root / "manifest.json"
413
+ if manifest_path.is_symlink() or not manifest_path.is_file():
414
+ raise BundleError("bundle manifest.json must be a regular non-symlink file")
415
+ try:
416
+ if manifest_path.stat().st_size > 2 * 1024 * 1024:
417
+ raise BundleError("bundle manifest.json exceeds the 2 MiB size limit")
418
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
419
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
420
+ raise BundleError(f"cannot read bundle manifest: {exc}") from exc
421
+ if not isinstance(data, dict) or data.get("schema_version") != 1:
422
+ raise BundleError("unsupported bundle manifest schema")
423
+ fmt = data.get("format")
424
+ if fmt == BOOK_FORMAT:
425
+ kind: Literal["book", "document"] = "book"
426
+ elif fmt == DOCUMENT_FORMAT:
427
+ kind = "document"
428
+ else:
429
+ raise BundleError(f"unsupported bundle format: {fmt!r}")
430
+ return _BundleIdentity(root=root, kind=kind, manifest=data)
431
+
432
+
433
+ def _resolve_units(identity: _BundleIdentity, variant: str, chapters: str) -> tuple[_Unit, ...]:
434
+ renderplan_variant = _contained_dir(identity.root / "renderplan" / variant, identity.root)
435
+ render_variant = _contained_dir(identity.root / "voicerender" / variant, identity.root)
436
+ if identity.kind == "document":
437
+ if chapters.strip().casefold() != "all":
438
+ raise BundleError("document bundles only support --chapters all")
439
+ document = identity.manifest.get("document")
440
+ document_id = document.get("id") if isinstance(document, dict) else None
441
+ if document_id != "document-0001":
442
+ raise BundleError("document bundle must use canonical id document-0001")
443
+ plan = _contained_file(renderplan_variant / "document.renderplan.toml", renderplan_variant)
444
+ render_dir = _contained_dir(render_variant / document_id, render_variant)
445
+ return (_Unit(document_id, 1, plan, render_dir),)
446
+
447
+ set_path = _contained_file(renderplan_variant / "renderplan-set.toml", renderplan_variant)
448
+ try:
449
+ from renderplan import load_renderplan_set, validate_renderplan_set
450
+
451
+ plan_set = load_renderplan_set(set_path)
452
+ validate_renderplan_set(plan_set)
453
+ chapter_entries = plan_set.chapters
454
+ except Exception as exc:
455
+ raise BundleError(f"invalid renderplan-set.toml: {exc}") from exc
456
+ selected = _select_numbers(chapters, {item.source_number for item in chapter_entries})
457
+ units: list[_Unit] = []
458
+ for entry in chapter_entries:
459
+ if entry.source_number not in selected:
460
+ continue
461
+ plan = _contained_posix_file(renderplan_variant, entry.renderplan)
462
+ render_dir = _contained_dir(render_variant / entry.id, render_variant)
463
+ units.append(
464
+ _Unit(
465
+ entry.id,
466
+ entry.source_number,
467
+ plan,
468
+ render_dir,
469
+ set_entry=entry,
470
+ plan_set=plan_set,
471
+ )
472
+ )
473
+ return tuple(units)
474
+
475
+
476
+ def _safe_variant(value: str) -> str:
477
+ if (
478
+ not value
479
+ or value in {".", ".."}
480
+ or "/" in value
481
+ or "\\" in value
482
+ or "\x00" in value
483
+ or Path(value).name != value
484
+ or PureWindowsPath(value).name != value
485
+ ):
486
+ raise BundleError("--variant must be an exact safe directory basename")
487
+ return value
488
+
489
+
490
+ def _select_numbers(selector: str, available: set[int]) -> set[int]:
491
+ if selector.strip().casefold() == "all":
492
+ return set(available)
493
+ result: set[int] = set()
494
+ for part in selector.split(","):
495
+ match = re.fullmatch(r"\s*(\d+)(?:\s*-\s*(\d+))?\s*", part)
496
+ if not match:
497
+ raise BundleError(f"invalid chapter selector: {selector}")
498
+ start = int(match.group(1))
499
+ end = int(match.group(2) or start)
500
+ if start < 1 or end < start:
501
+ raise BundleError(f"invalid chapter selector: {selector}")
502
+ result.update(range(start, end + 1))
503
+ missing = result - available
504
+ if missing:
505
+ raise BundleError(f"chapter selector names unavailable source numbers: {sorted(missing)}")
506
+ return result
507
+
508
+
509
+ def _contained_dir(path: Path, root: Path) -> Path:
510
+ try:
511
+ resolved = path.resolve(strict=True)
512
+ except OSError as exc:
513
+ raise BundleError(f"required directory is missing: {path}") from exc
514
+ try:
515
+ resolved.relative_to(root.resolve(strict=True))
516
+ except ValueError as exc:
517
+ raise BundleError(f"directory escapes bundle root: {path}") from exc
518
+ if not resolved.is_dir():
519
+ raise BundleError(f"expected directory: {path}")
520
+ return resolved
521
+
522
+
523
+ def _contained_file(path: Path, root: Path) -> Path:
524
+ if path.is_symlink():
525
+ raise BundleError(f"required file must not be a symlink: {path}")
526
+ try:
527
+ resolved = path.resolve(strict=True)
528
+ except OSError as exc:
529
+ raise BundleError(f"required file is missing: {path}") from exc
530
+ try:
531
+ resolved.relative_to(root.resolve(strict=True))
532
+ except ValueError as exc:
533
+ raise BundleError(f"file escapes root: {path}") from exc
534
+ if not resolved.is_file():
535
+ raise BundleError(f"expected regular file: {path}")
536
+ return resolved
537
+
538
+
539
+ def _contained_posix_file(root: Path, relative: str) -> Path:
540
+ if not isinstance(relative, str) or not relative or "\\" in relative or "\x00" in relative:
541
+ raise BundleError("RenderPlan set contains an unsafe path")
542
+ posix = PurePosixPath(relative)
543
+ if posix.is_absolute() or any(part in {"", ".", ".."} for part in relative.split("/")):
544
+ raise BundleError("RenderPlan set contains an unsafe path")
545
+ return _contained_file(root.joinpath(*posix.parts), root)
546
+
547
+
548
+ def _bundle_status(units: list[UnitVerifyResult]) -> UnitStatus:
549
+ if any(unit.status == "error" for unit in units):
550
+ return "error"
551
+ if any(unit.status == "fail" for unit in units):
552
+ return "fail"
553
+ if any(unit.status == "review" for unit in units):
554
+ return "review"
555
+ return "pass"
556
+
557
+
558
+ def _write_report(result: BundleVerifyResult) -> None:
559
+ doc = tomlkit.document()
560
+ doc.add("format", "utterverify.bundle-report")
561
+ doc.add("schema_version", 1)
562
+ doc.add("kind", result.kind)
563
+ doc.add("variant", result.variant)
564
+ doc.add("status", result.status)
565
+ doc.add("config", result.config_path.name)
566
+ doc.add("segments", result.segments)
567
+ doc.add("passed", result.passed)
568
+ doc.add("reviewed", result.reviewed)
569
+ doc.add("failed", result.failed)
570
+ doc.add("errors", result.errors)
571
+ doc.add("exit_code", result.exit_code)
572
+ speechscope = tomlkit.table()
573
+ speechscope.add("backend", result.speechscope_backend)
574
+ speechscope.add("device", result.speechscope_device)
575
+ if result.speechscope_model is not None:
576
+ speechscope.add("model", result.speechscope_model)
577
+ speechscope.add("normalization", result.normalization)
578
+ speechscope.add("use_language_hint", result.use_language_hint)
579
+ if result.config_sha256 is not None:
580
+ speechscope.add("config_sha256", result.config_sha256)
581
+ doc.add("speechscope", speechscope)
582
+ units = tomlkit.aot()
583
+ for unit in result.units:
584
+ table = tomlkit.table()
585
+ table.add("id", unit.id)
586
+ table.add("status", unit.status)
587
+ table.add("render_plan_id", unit.render_plan_id)
588
+ table.add("source_plan_id", unit.source_plan_id)
589
+ table.add("render_profile_id", unit.render_profile_id)
590
+ if unit.manifest_id is not None:
591
+ table.add("manifest_id", unit.manifest_id)
592
+ table.add("renderplan", _rel_or_abs(unit.renderplan, result.bundle))
593
+ table.add("render_dir", _rel_or_abs(unit.render_dir, result.bundle))
594
+ table.add("output_dir", _rel_or_abs(unit.output_dir, result.bundle))
595
+ table.add("segments", len(unit.segments))
596
+ table.add("passed", unit.passed)
597
+ table.add("reviewed", unit.reviewed)
598
+ table.add("failed", unit.failed)
599
+ table.add("errors", unit.errors)
600
+ segment_tables = tomlkit.aot()
601
+ for segment in unit.segments:
602
+ segment_table = tomlkit.table()
603
+ segment_table.add("number", segment.number)
604
+ segment_table.add("segment_id", segment.segment_id)
605
+ segment_table.add("reference_text", segment.reference_text)
606
+ segment_table.add("wav", _rel_or_abs(segment.wav, result.bundle))
607
+ segment_table.add("wav_sha256", segment.wav_sha256)
608
+ if segment.report is not None:
609
+ segment_table.add("report", _rel_or_abs(segment.report, result.bundle))
610
+ segment_table.add("status", segment.status)
611
+ if segment.language is not None:
612
+ segment_table.add("language", segment.language)
613
+ if segment.message is not None:
614
+ segment_table.add("message", segment.message)
615
+ segment_tables.append(segment_table)
616
+ table.add("segment", segment_tables)
617
+ units.append(table)
618
+ doc.add("units", units)
619
+ _atomic_text(result.report_path, tomlkit.dumps(doc))
620
+
621
+
622
+ def _rel_or_abs(path: Path, root: Path) -> str:
623
+ try:
624
+ return path.relative_to(root).as_posix()
625
+ except ValueError:
626
+ return str(path)
627
+
628
+
629
+ def _atomic_text(path: Path, content: str) -> None:
630
+ temp_name: str | None = None
631
+ try:
632
+ with tempfile.NamedTemporaryFile(
633
+ mode="w",
634
+ encoding="utf-8",
635
+ newline="\n",
636
+ dir=path.parent,
637
+ prefix=f".{path.name}.",
638
+ suffix=".tmp",
639
+ delete=False,
640
+ ) as handle:
641
+ temp_name = handle.name
642
+ handle.write(content)
643
+ os.replace(temp_name, path)
644
+ finally:
645
+ if temp_name is not None and os.path.exists(temp_name):
646
+ os.unlink(temp_name)