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 from __future__ import annotations
import dataclasses
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional 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 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 @dataclass
class ConversionRequest: class ConversionRequest:
"""Normalized request for a conversion job. """Normalized request for a conversion job.
Only contains fields that describe the conversion task itself. Only contains fields that describe the conversion task itself.
UI-only fields (display, logging, user prompts) stay in adapters. 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 --- # --- Source ---
@@ -83,3 +109,48 @@ class ConversionRequest:
cover_image_path: Optional[Path] = None cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None cover_image_mime: Optional[str] = None
generate_epub3: bool = False 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, direct_text=thread.file_name if is_direct_text else None,
original_filename=original_filename, original_filename=original_filename,
# TTS Settings # TTS Settings
language=thread.lang_code or "a", language=thread.lang_code,
tts_provider="kokoro", # PyQt uses Kokoro by default tts_provider="kokoro", # PyQt uses Kokoro by default
voice=thread.voice or "M1", voice=thread.voice,
voice_profile=getattr(thread, "voice_profile", None), voice_profile=getattr(thread, "voice_profile", None),
speed=thread.speed or 1.0, speed=thread.speed,
use_gpu=thread.use_gpu, 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
output_format=thread.output_format or "wav", output_format=thread.output_format,
subtitle_mode=thread.subtitle_mode or "Disabled", subtitle_mode=thread.subtitle_mode,
subtitle_format=getattr(thread, "subtitle_format", "srt"), 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 Options
save_mode=thread.save_option or "save_next_to_input", save_mode=thread.save_option,
output_folder=output_folder, output_folder=output_folder,
save_chapters_separately=getattr(thread, "save_chapters_separately", False), save_chapters_separately=getattr(thread, "save_chapters_separately", False),
merge_chapters_at_end=getattr(thread, "merge_chapters_at_end", True), merge_chapters_at_end=getattr(thread, "merge_chapters_at_end", True),
separate_chapters_format=getattr(thread, "separate_chapters_format", "wav"), separate_chapters_format=getattr(thread, "separate_chapters_format", "wav"),
save_as_project=getattr(thread, "save_as_project", False), save_as_project=getattr(thread, "save_as_project", False),
# Timing # Timing
silence_between_chapters=getattr(thread, "silence_duration", None) or 2.0, silence_between_chapters=getattr(thread, "silence_duration", 2.0),
chapter_intro_delay=getattr(thread, "chapter_intro_delay", None) or 0.0, chapter_intro_delay=getattr(thread, "chapter_intro_delay", 0.0),
# Content Processing # Content Processing
replace_single_newlines=getattr(thread, "replace_single_newlines", False), replace_single_newlines=getattr(thread, "replace_single_newlines", False),
read_title_intro=getattr(thread, "read_title_intro", 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, source_path=Path(job.stored_path) if job.stored_path else None,
original_filename=job.original_filename, original_filename=job.original_filename,
# TTS Settings # TTS Settings
language=job.language or "a", language=job.language,
tts_provider=job.tts_provider or "kokoro", tts_provider=job.tts_provider,
voice=job.voice or "M1", voice=job.voice,
voice_profile=job.voice_profile, voice_profile=job.voice_profile,
speed=job.speed or 1.0, speed=job.speed,
use_gpu=job.use_gpu, 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
output_format=job.output_format or "wav", output_format=job.output_format,
subtitle_mode=job.subtitle_mode or "Disabled", subtitle_mode=job.subtitle_mode,
subtitle_format=job.subtitle_format or "srt", subtitle_format=job.subtitle_format,
max_subtitle_words=job.max_subtitle_words or 50, max_subtitle_words=job.max_subtitle_words,
# Save Options # 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, output_folder=Path(job.output_folder) if job.output_folder else None,
save_chapters_separately=job.save_chapters_separately, save_chapters_separately=job.save_chapters_separately,
merge_chapters_at_end=job.merge_chapters_at_end, 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, save_as_project=job.save_as_project,
# Timing # Timing
silence_between_chapters=job.silence_between_chapters or 2.0, silence_between_chapters=job.silence_between_chapters,
chapter_intro_delay=job.chapter_intro_delay or 0.0, chapter_intro_delay=job.chapter_intro_delay,
# Content Processing # Content Processing
replace_single_newlines=job.replace_single_newlines, replace_single_newlines=job.replace_single_newlines,
read_title_intro=job.read_title_intro, read_title_intro=job.read_title_intro,
@@ -74,8 +74,8 @@ def build_conversion_request_from_job(job: Any) -> ConversionRequest:
# Chapter/Chunk Configuration # Chapter/Chunk Configuration
chapter_overrides=job.chapters or [], chapter_overrides=job.chapters or [],
chunks=job.chunks or [], chunks=job.chunks or [],
chunk_level=job.chunk_level or "paragraph", chunk_level=job.chunk_level,
speaker_mode=job.speaker_mode or "single", speaker_mode=job.speaker_mode,
speakers=job.speakers or {}, speakers=job.speakers or {},
# Metadata # Metadata
metadata_tags=job.metadata_tags or {}, metadata_tags=job.metadata_tags or {},
+14 -10
View File
@@ -146,6 +146,7 @@ class TestWebUIAdapter:
def test_none_defaults_handled(self): def test_none_defaults_handled(self):
from abogen.webui.conversion_adapter import build_conversion_request_from_job from abogen.webui.conversion_adapter import build_conversion_request_from_job
from abogen.domain.enums import Language, OutputFormat, SubtitleMode, SaveMode
job = self._make_job( job = self._make_job(
language=None, language=None,
@@ -161,12 +162,13 @@ class TestWebUIAdapter:
) )
req = build_conversion_request_from_job(job) req = build_conversion_request_from_job(job)
assert req.language == "a" # None values pass through adapter; ConversionRequest.__post_init__
assert req.voice == "M1" # applies defaults and clamping for numeric fields.
assert req.language == Language.EN_US
assert req.speed == 1.0 assert req.speed == 1.0
assert req.output_format == "wav" assert req.output_format == OutputFormat.WAV
assert req.subtitle_mode == "Disabled" assert req.subtitle_mode == SubtitleMode.DISABLED
assert req.save_mode == "save_next_to_input" assert req.save_mode == SaveMode.SAVE_NEXT_TO_INPUT
assert req.silence_between_chapters == 2.0 assert req.silence_between_chapters == 2.0
assert req.chapter_intro_delay == 0.0 assert req.chapter_intro_delay == 0.0
assert req.supertonic_total_steps == 5 assert req.supertonic_total_steps == 5
@@ -369,6 +371,7 @@ class TestPyQtAdapter:
def test_none_defaults_handled(self): def test_none_defaults_handled(self):
from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
from abogen.domain.enums import Language, OutputFormat, SubtitleMode, SaveMode
thread = self._make_thread( thread = self._make_thread(
lang_code=None, lang_code=None,
@@ -384,12 +387,13 @@ class TestPyQtAdapter:
) )
req = build_conversion_request_from_thread(thread) req = build_conversion_request_from_thread(thread)
assert req.language == "a" # None values pass through adapter; ConversionRequest.__post_init__
assert req.voice == "M1" # applies defaults and clamping for numeric fields.
assert req.language == Language.EN_US
assert req.speed == 1.0 assert req.speed == 1.0
assert req.output_format == "wav" assert req.output_format == OutputFormat.WAV
assert req.subtitle_mode == "Disabled" assert req.subtitle_mode == SubtitleMode.DISABLED
assert req.save_mode == "save_next_to_input" assert req.save_mode == SaveMode.SAVE_NEXT_TO_INPUT
assert req.silence_between_chapters == 2.0 assert req.silence_between_chapters == 2.0
assert req.chapter_intro_delay == 0.0 assert req.chapter_intro_delay == 0.0
assert req.supertonic_total_steps == 5 assert req.supertonic_total_steps == 5
+92
View File
@@ -15,6 +15,8 @@ import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
from abogen.application.conversion_request import ConversionRequest, ConversionRequestError
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
from abogen.domain.normalization import TTSContext from abogen.domain.normalization import TTSContext
from abogen.domain.settings_core import settings_defaults from abogen.domain.settings_core import settings_defaults
from abogen.domain.split_pattern import get_split_pattern from abogen.domain.split_pattern import get_split_pattern
@@ -192,3 +194,93 @@ class TestLoggingProtocol:
progress_fn(100, 1000, "0:05:00") progress_fn(100, 1000, "0:05:00")
assert len(progress_calls) == 1 assert len(progress_calls) == 1
assert progress_calls[0] == (100, 1000, "0:05:00") assert progress_calls[0] == (100, 1000, "0:05:00")
class TestConversionRequestValidation:
"""Verify __post_init__ validation on ConversionRequest."""
def test_defaults_are_valid(self):
req = ConversionRequest()
assert req.max_subtitle_words == 50
assert req.speed == 1.0
assert req.supertonic_total_steps == 5
assert req.output_format == OutputFormat.WAV
assert req.subtitle_mode == SubtitleMode.DISABLED
def test_max_subtitle_words_clamped_below_min(self):
req = ConversionRequest(max_subtitle_words=0)
assert req.max_subtitle_words == 1
def test_max_subtitle_words_clamped_above_max(self):
req = ConversionRequest(max_subtitle_words=999)
assert req.max_subtitle_words == 500
def test_max_subtitle_words_valid(self):
req = ConversionRequest(max_subtitle_words=100)
assert req.max_subtitle_words == 100
def test_speed_clamped_below_min(self):
req = ConversionRequest(speed=0.1)
assert req.speed == 0.5
def test_speed_clamped_above_max(self):
req = ConversionRequest(speed=10.0)
assert req.speed == 3.0
def test_speed_valid(self):
req = ConversionRequest(speed=1.5)
assert req.speed == 1.5
def test_supertonic_steps_clamped_below_min(self):
req = ConversionRequest(supertonic_total_steps=0)
assert req.supertonic_total_steps == 2
def test_supertonic_steps_clamped_above_max(self):
req = ConversionRequest(supertonic_total_steps=100)
assert req.supertonic_total_steps == 15
def test_silence_between_chapters_clamped(self):
req = ConversionRequest(silence_between_chapters=-5.0)
assert req.silence_between_chapters == 0.0
def test_chapter_intro_delay_clamped(self):
req = ConversionRequest(chapter_intro_delay=-1.0)
assert req.chapter_intro_delay == 0.0
def test_invalid_chunk_level_raises(self):
with pytest.raises(ConversionRequestError, match="chunk_level"):
ConversionRequest(chunk_level="invalid")
def test_invalid_speaker_mode_raises(self):
with pytest.raises(ConversionRequestError, match="speaker_mode"):
ConversionRequest(speaker_mode="invalid")
def test_invalid_max_subtitle_words_type_raises(self):
with pytest.raises(ConversionRequestError, match="max_subtitle_words"):
ConversionRequest(max_subtitle_words="not_a_number")
def test_invalid_speed_type_raises(self):
with pytest.raises(ConversionRequestError, match="speed"):
ConversionRequest(speed="fast")
def test_invalid_silence_type_raises(self):
with pytest.raises(ConversionRequestError, match="silence_between_chapters"):
ConversionRequest(silence_between_chapters="loud")
def test_empty_tts_provider_defaults_to_kokoro(self):
req = ConversionRequest(tts_provider="")
assert req.tts_provider == "kokoro"
def test_enum_fields_accept_valid_values(self):
req = ConversionRequest(
language=Language.FR,
output_format=OutputFormat.MP3,
subtitle_mode=SubtitleMode.SENTENCE,
subtitle_format=SubtitleFormat.ASS,
save_mode=SaveMode.CUSTOM_FOLDER,
)
assert req.language == Language.FR
assert req.output_format == OutputFormat.MP3
assert req.subtitle_mode == SubtitleMode.SENTENCE
assert req.subtitle_format == SubtitleFormat.ASS
assert req.save_mode == SaveMode.CUSTOM_FOLDER