| """Label-isolated manifest parsing and per-file batch execution.""" |
|
|
| from __future__ import annotations |
|
|
| import csv |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Callable, Generic, Literal, TypeVar |
|
|
| from .audio import AudioError, SUPPORTED_AUDIO_EXTENSIONS |
|
|
|
|
| DEFAULT_MAX_MANIFEST_BYTES = 1024 * 1024 |
| T = TypeVar("T") |
|
|
|
|
| class ManifestError(ValueError): |
| """A manifest-level validation failure safe to show to an authenticated user.""" |
|
|
| def __init__(self, code: str, message: str) -> None: |
| super().__init__(message) |
| self.code = code |
| self.public_message = message |
|
|
|
|
| class BatchFileError(ValueError): |
| """A path or file failure associated with one manifest row.""" |
|
|
| def __init__(self, code: str, message: str) -> None: |
| super().__init__(message) |
| self.code = code |
| self.public_message = message |
|
|
|
|
| @dataclass(frozen=True) |
| class ManifestEntry: |
| """The complete manifest contract visible to inference code.""" |
|
|
| name: str |
|
|
|
|
| @dataclass(frozen=True) |
| class FileFailure: |
| code: str |
| message: str |
|
|
|
|
| @dataclass(frozen=True) |
| class BatchItemResult(Generic[T]): |
| name: str |
| status: Literal["ok", "error"] |
| value: T | None = None |
| failure: FileFailure | None = None |
|
|
|
|
| @dataclass(frozen=True) |
| class BatchReport(Generic[T]): |
| items: tuple[BatchItemResult[T], ...] |
|
|
| @property |
| def success_count(self) -> int: |
| return sum(item.status == "ok" for item in self.items) |
|
|
| @property |
| def failure_count(self) -> int: |
| return sum(item.status == "error" for item in self.items) |
|
|
|
|
| def _validate_manifest_name(name: str) -> str: |
| if not name or name != name.strip(): |
| raise ManifestError("invalid_name", "Manifest filenames must be non-empty and trimmed.") |
| if "/" in name or "\\" in name or name in {".", ".."}: |
| raise ManifestError("unsafe_name", "Manifest filenames must be root-level basenames.") |
| if any(ord(character) < 32 for character in name): |
| raise ManifestError("invalid_name", "Manifest filenames contain invalid characters.") |
| if Path(name).suffix.lower() not in SUPPORTED_AUDIO_EXTENSIONS: |
| raise ManifestError("unsupported_extension", "Manifest contains an unsupported audio extension.") |
| return name |
|
|
|
|
| def parse_manifest_for_inference( |
| path: str | Path, |
| *, |
| max_bytes: int = DEFAULT_MAX_MANIFEST_BYTES, |
| ) -> tuple[ManifestEntry, ...]: |
| """Read only filenames; result_json is deliberately neither parsed nor retained.""" |
|
|
| manifest = Path(path) |
| if manifest.is_symlink() or not manifest.is_file(): |
| raise ManifestError("unsafe_manifest", "Manifest must be a regular file.") |
| if manifest.stat().st_size <= 0 or manifest.stat().st_size > max_bytes: |
| raise ManifestError("manifest_size", "Manifest is empty or exceeds the configured limit.") |
|
|
| try: |
| with manifest.open("r", encoding="utf-8-sig", newline="") as stream: |
| reader = csv.DictReader(stream) |
| if ( |
| reader.fieldnames is None |
| or len(reader.fieldnames) != 2 |
| or set(reader.fieldnames) != {"name", "result_json"} |
| ): |
| raise ManifestError( |
| "invalid_columns", |
| "Manifest must contain exactly name and result_json columns.", |
| ) |
| entries: list[ManifestEntry] = [] |
| seen: set[str] = set() |
| for row in reader: |
| if None in row: |
| raise ManifestError("invalid_row", "Manifest contains a malformed CSV row.") |
| name = _validate_manifest_name(row.get("name", "")) |
| key = name.casefold() |
| if key in seen: |
| raise ManifestError("duplicate_name", "Manifest contains duplicate filenames.") |
| seen.add(key) |
| entries.append(ManifestEntry(name=name)) |
| except UnicodeDecodeError as exc: |
| raise ManifestError("invalid_encoding", "Manifest must be UTF-8 encoded.") from exc |
| except csv.Error as exc: |
| raise ManifestError("invalid_csv", "Manifest is not valid CSV.") from exc |
|
|
| if not entries: |
| raise ManifestError("empty_manifest", "Manifest must contain at least one audio row.") |
| return tuple(entries) |
|
|
|
|
| def _resolve_batch_file(batch_root: Path, entry: ManifestEntry) -> Path: |
| candidate = batch_root / entry.name |
| if candidate.is_symlink(): |
| raise BatchFileError("unsafe_file", "The listed audio file is unsafe.") |
| try: |
| resolved = candidate.resolve(strict=True) |
| except OSError as exc: |
| raise BatchFileError("missing_file", "The listed audio file is missing.") from exc |
| if resolved.parent != batch_root or not resolved.is_file(): |
| raise BatchFileError("unsafe_file", "The listed audio file is unsafe.") |
| return resolved |
|
|
|
|
| def process_batch( |
| entries: tuple[ManifestEntry, ...], |
| batch_root: str | Path, |
| file_processor: Callable[[Path], T], |
| progress_callback: Callable[[int, int, str], None] | None = None, |
| ) -> BatchReport[T]: |
| """Process entries independently and never pass manifest labels to the processor.""" |
|
|
| root = Path(batch_root) |
| if root.is_symlink() or not root.is_dir(): |
| raise ManifestError("unsafe_batch_root", "Batch root must be a regular directory.") |
| root = root.resolve(strict=True) |
|
|
| results: list[BatchItemResult[T]] = [] |
| total = len(entries) |
| for index, entry in enumerate(entries, start=1): |
| if progress_callback is not None: |
| progress_callback(index - 1, total, entry.name) |
| try: |
| path = _resolve_batch_file(root, entry) |
| value = file_processor(path) |
| results.append(BatchItemResult(name=entry.name, status="ok", value=value)) |
| except AudioError as exc: |
| results.append( |
| BatchItemResult( |
| name=entry.name, |
| status="error", |
| failure=FileFailure(exc.code, exc.public_message), |
| ) |
| ) |
| except BatchFileError as exc: |
| results.append( |
| BatchItemResult( |
| name=entry.name, |
| status="error", |
| failure=FileFailure(exc.code, exc.public_message), |
| ) |
| ) |
| except Exception: |
| results.append( |
| BatchItemResult( |
| name=entry.name, |
| status="error", |
| failure=FileFailure("processing_failed", "File processing failed."), |
| ) |
| ) |
| if progress_callback is not None: |
| progress_callback(index, total, entry.name) |
| return BatchReport(items=tuple(results)) |
|
|