mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
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:
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {},
|
||||
|
||||
@@ -146,6 +146,7 @@ class TestWebUIAdapter:
|
||||
|
||||
def test_none_defaults_handled(self):
|
||||
from abogen.webui.conversion_adapter import build_conversion_request_from_job
|
||||
from abogen.domain.enums import Language, OutputFormat, SubtitleMode, SaveMode
|
||||
|
||||
job = self._make_job(
|
||||
language=None,
|
||||
@@ -161,12 +162,13 @@ class TestWebUIAdapter:
|
||||
)
|
||||
req = build_conversion_request_from_job(job)
|
||||
|
||||
assert req.language == "a"
|
||||
assert req.voice == "M1"
|
||||
# None values pass through adapter; ConversionRequest.__post_init__
|
||||
# applies defaults and clamping for numeric fields.
|
||||
assert req.language == Language.EN_US
|
||||
assert req.speed == 1.0
|
||||
assert req.output_format == "wav"
|
||||
assert req.subtitle_mode == "Disabled"
|
||||
assert req.save_mode == "save_next_to_input"
|
||||
assert req.output_format == OutputFormat.WAV
|
||||
assert req.subtitle_mode == SubtitleMode.DISABLED
|
||||
assert req.save_mode == SaveMode.SAVE_NEXT_TO_INPUT
|
||||
assert req.silence_between_chapters == 2.0
|
||||
assert req.chapter_intro_delay == 0.0
|
||||
assert req.supertonic_total_steps == 5
|
||||
@@ -369,6 +371,7 @@ class TestPyQtAdapter:
|
||||
|
||||
def test_none_defaults_handled(self):
|
||||
from abogen.pyqt.conversion_adapter import build_conversion_request_from_thread
|
||||
from abogen.domain.enums import Language, OutputFormat, SubtitleMode, SaveMode
|
||||
|
||||
thread = self._make_thread(
|
||||
lang_code=None,
|
||||
@@ -384,12 +387,13 @@ class TestPyQtAdapter:
|
||||
)
|
||||
req = build_conversion_request_from_thread(thread)
|
||||
|
||||
assert req.language == "a"
|
||||
assert req.voice == "M1"
|
||||
# None values pass through adapter; ConversionRequest.__post_init__
|
||||
# applies defaults and clamping for numeric fields.
|
||||
assert req.language == Language.EN_US
|
||||
assert req.speed == 1.0
|
||||
assert req.output_format == "wav"
|
||||
assert req.subtitle_mode == "Disabled"
|
||||
assert req.save_mode == "save_next_to_input"
|
||||
assert req.output_format == OutputFormat.WAV
|
||||
assert req.subtitle_mode == SubtitleMode.DISABLED
|
||||
assert req.save_mode == SaveMode.SAVE_NEXT_TO_INPUT
|
||||
assert req.silence_between_chapters == 2.0
|
||||
assert req.chapter_intro_delay == 0.0
|
||||
assert req.supertonic_total_steps == 5
|
||||
|
||||
@@ -15,6 +15,8 @@ import pytest
|
||||
from pathlib import Path
|
||||
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.settings_core import settings_defaults
|
||||
from abogen.domain.split_pattern import get_split_pattern
|
||||
@@ -192,3 +194,93 @@ class TestLoggingProtocol:
|
||||
progress_fn(100, 1000, "0:05:00")
|
||||
assert len(progress_calls) == 1
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user