refactor: dynamic ConversionRequest validation, remove 'or default' from adapters

- __post_init__: _apply_none_defaults() iterates dataclasses.fields() dynamically
- _NUMERIC_CONSTRAINTS and _ENUM_CONSTRAINTS dicts replace per-field if chains
- Both adapters pass values as-is (no 'or default' fallbacks)
- 18 validation tests + updated adapter tests for Enum assertions
This commit is contained in:
Artem Akymenko
2026-07-22 11:33:44 +00:00
parent 0805e9fdae
commit d3682e7672
5 changed files with 202 additions and 35 deletions
+71
View File
@@ -9,6 +9,7 @@ into a ConversionRequest before calling ConversionService.run().
from __future__ import annotations
import dataclasses
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -16,12 +17,37 @@ from typing import Any, Dict, List, Optional
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
class ConversionRequestError(ValueError):
"""Raised when ConversionRequest has invalid field values."""
# Numeric field constraints: attr -> (min, max)
_NUMERIC_CONSTRAINTS: dict[str, tuple[float, float | None]] = {
"max_subtitle_words": (1, 500),
"speed": (0.5, 3.0),
"supertonic_total_steps": (2, 15),
"silence_between_chapters": (0.0, None),
"chapter_intro_delay": (0.0, None),
}
# Enum-like fields that must be in allowed set
_ENUM_CONSTRAINTS: dict[str, tuple[str, ...]] = {
"chunk_level": ("paragraph", "sentence"),
"speaker_mode": ("single", "multi"),
}
@dataclass
class ConversionRequest:
"""Normalized request for a conversion job.
Only contains fields that describe the conversion task itself.
UI-only fields (display, logging, user prompts) stay in adapters.
Validation runs on creation via __post_init__:
- None values → replaced with field default (from declaration)
- Numeric fields → clamped to valid range
- String enums → validated against allowed set
"""
# --- Source ---
@@ -83,3 +109,48 @@ class ConversionRequest:
cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None
generate_epub3: bool = False
def __post_init__(self) -> None:
"""Resolve None → default, then validate and clamp."""
_apply_none_defaults(self)
if not self.tts_provider:
self.tts_provider = "kokoro"
_clamp_numerics(self)
_validate_enums(self)
def _apply_none_defaults(obj: ConversionRequest) -> None:
"""Replace None values with field defaults from dataclass declaration."""
for f in dataclasses.fields(obj):
if getattr(obj, f.name) is not None:
continue
if f.default is not dataclasses.MISSING:
setattr(obj, f.name, f.default)
elif f.default_factory is not dataclasses.MISSING:
setattr(obj, f.name, f.default_factory())
def _clamp_numerics(obj: ConversionRequest) -> None:
"""Clamp numeric fields to valid ranges."""
for attr, (min_v, max_v) in _NUMERIC_CONSTRAINTS.items():
val = getattr(obj, attr)
if val is None:
continue
if not isinstance(val, (int, float)):
raise ConversionRequestError(
f"{attr} must be a number, got {type(val).__name__}"
)
clamped = max(min_v, float(val))
if max_v is not None:
clamped = min(max_v, clamped)
setattr(obj, attr, clamped)
def _validate_enums(obj: ConversionRequest) -> None:
"""Validate string enum fields against allowed values."""
for attr, allowed in _ENUM_CONSTRAINTS.items():
val = getattr(obj, attr)
if val not in allowed:
raise ConversionRequestError(
f"{attr} must be one of {allowed}, got {val!r}"
)
+10 -10
View File
@@ -60,28 +60,28 @@ def build_conversion_request_from_thread(thread: Any) -> ConversionRequest:
direct_text=thread.file_name if is_direct_text else None,
original_filename=original_filename,
# TTS Settings
language=thread.lang_code or "a",
language=thread.lang_code,
tts_provider="kokoro", # PyQt uses Kokoro by default
voice=thread.voice or "M1",
voice=thread.voice,
voice_profile=getattr(thread, "voice_profile", None),
speed=thread.speed or 1.0,
speed=thread.speed,
use_gpu=thread.use_gpu,
supertonic_total_steps=getattr(thread, "supertonic_total_steps", None) or 5,
supertonic_total_steps=getattr(thread, "supertonic_total_steps", 5),
# Output Format
output_format=thread.output_format or "wav",
subtitle_mode=thread.subtitle_mode or "Disabled",
output_format=thread.output_format,
subtitle_mode=thread.subtitle_mode,
subtitle_format=getattr(thread, "subtitle_format", "srt"),
max_subtitle_words=getattr(thread, "max_subtitle_words", None) or 50,
max_subtitle_words=getattr(thread, "max_subtitle_words", 50),
# Save Options
save_mode=thread.save_option or "save_next_to_input",
save_mode=thread.save_option,
output_folder=output_folder,
save_chapters_separately=getattr(thread, "save_chapters_separately", False),
merge_chapters_at_end=getattr(thread, "merge_chapters_at_end", True),
separate_chapters_format=getattr(thread, "separate_chapters_format", "wav"),
save_as_project=getattr(thread, "save_as_project", False),
# Timing
silence_between_chapters=getattr(thread, "silence_duration", None) or 2.0,
chapter_intro_delay=getattr(thread, "chapter_intro_delay", None) or 0.0,
silence_between_chapters=getattr(thread, "silence_duration", 2.0),
chapter_intro_delay=getattr(thread, "chapter_intro_delay", 0.0),
# Content Processing
replace_single_newlines=getattr(thread, "replace_single_newlines", False),
read_title_intro=getattr(thread, "read_title_intro", False),
+15 -15
View File
@@ -38,28 +38,28 @@ def build_conversion_request_from_job(job: Any) -> ConversionRequest:
source_path=Path(job.stored_path) if job.stored_path else None,
original_filename=job.original_filename,
# TTS Settings
language=job.language or "a",
tts_provider=job.tts_provider or "kokoro",
voice=job.voice or "M1",
language=job.language,
tts_provider=job.tts_provider,
voice=job.voice,
voice_profile=job.voice_profile,
speed=job.speed or 1.0,
speed=job.speed,
use_gpu=job.use_gpu,
supertonic_total_steps=job.supertonic_total_steps or 5,
supertonic_total_steps=job.supertonic_total_steps,
# Output Format
output_format=job.output_format or "wav",
subtitle_mode=job.subtitle_mode or "Disabled",
subtitle_format=job.subtitle_format or "srt",
max_subtitle_words=job.max_subtitle_words or 50,
output_format=job.output_format,
subtitle_mode=job.subtitle_mode,
subtitle_format=job.subtitle_format,
max_subtitle_words=job.max_subtitle_words,
# Save Options
save_mode=job.save_mode or "save_next_to_input",
save_mode=job.save_mode,
output_folder=Path(job.output_folder) if job.output_folder else None,
save_chapters_separately=job.save_chapters_separately,
merge_chapters_at_end=job.merge_chapters_at_end,
separate_chapters_format=job.separate_chapters_format or "wav",
separate_chapters_format=job.separate_chapters_format,
save_as_project=job.save_as_project,
# Timing
silence_between_chapters=job.silence_between_chapters or 2.0,
chapter_intro_delay=job.chapter_intro_delay or 0.0,
silence_between_chapters=job.silence_between_chapters,
chapter_intro_delay=job.chapter_intro_delay,
# Content Processing
replace_single_newlines=job.replace_single_newlines,
read_title_intro=job.read_title_intro,
@@ -74,8 +74,8 @@ def build_conversion_request_from_job(job: Any) -> ConversionRequest:
# Chapter/Chunk Configuration
chapter_overrides=job.chapters or [],
chunks=job.chunks or [],
chunk_level=job.chunk_level or "paragraph",
speaker_mode=job.speaker_mode or "single",
chunk_level=job.chunk_level,
speaker_mode=job.speaker_mode,
speakers=job.speakers or {},
# Metadata
metadata_tags=job.metadata_tags or {},