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/__init__.py +30 -0
- utterverify/__main__.py +3 -0
- utterverify/_version.py +24 -0
- utterverify/bundle.py +646 -0
- utterverify/cli.py +406 -0
- utterverify/config.py +261 -0
- utterverify/core.py +419 -0
- utterverify/doctor.py +509 -0
- utterverify/errors.py +17 -0
- utterverify/py.typed +0 -0
- utterverify/types.py +102 -0
- utterverify-0.1.0.dist-info/METADATA +251 -0
- utterverify-0.1.0.dist-info/RECORD +17 -0
- utterverify-0.1.0.dist-info/WHEEL +5 -0
- utterverify-0.1.0.dist-info/entry_points.txt +2 -0
- utterverify-0.1.0.dist-info/licenses/LICENSE +201 -0
- utterverify-0.1.0.dist-info/top_level.txt +1 -0
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)
|