refactor: group ConversionRequest fields into config objects

Domain config types (domain/config_types.py):
- PronunciationConfig: pronunciation/heteronym/normalization overrides
- SubtitleConfig: mode, format, max_words
- CoverConfig: path, mime

Domain functions now accept config objects:
- build_tts_context(subtitle=, pronunciation=) instead of 9 individual params
- make_subtitle_writer(subtitle=) instead of 3 params
- process_and_write_subtitles(subtitle=) instead of 2 params
- embed_m4b_metadata(cover=) instead of 2 params
- build_epub3_package(cover=) instead of 2 params

ConversionRequest: 18 flat fields + 8 config objects
Application/config.py re-exports domain types
All tests updated to new API
This commit is contained in:
Artem Akymenko
2026-07-28 13:41:45 +03:00
parent 146cc81271
commit 953bef1e71
19 changed files with 354 additions and 257 deletions
+54 -46
View File
@@ -11,6 +11,12 @@ from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from abogen.application.conversion_config import (
CoverConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
)
from abogen.application.conversion_request import ConversionRequest
from abogen.application.conversion_models import (
ChapterPlan,
@@ -137,8 +143,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello world",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
@@ -156,8 +161,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
@@ -177,8 +181,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
events.cancelled = True
@@ -204,8 +207,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="<<CHAPTER_MARKER:Ch1>>\nText A\n<<CHAPTER_MARKER:Ch2>>\nText B",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
@@ -221,8 +223,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Body text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
read_title_intro=True,
read_closing_outro=True,
metadata_tags={"title": "Test Book", "author": "Author"},
@@ -256,9 +257,10 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
normalization_overrides={"normalization_numbers": False},
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
pronunciation=PronunciationConfig(
normalization_overrides={"normalization_numbers": False},
),
)
events = FakeEvents()
@@ -273,9 +275,10 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
normalization_overrides={"normalization_apostrophe_mode": "llm"},
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
pronunciation=PronunciationConfig(
normalization_overrides={"normalization_apostrophe_mode": "llm"},
),
)
events = FakeEvents()
@@ -290,8 +293,7 @@ class TestConversionService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
events = FakeEvents()
@@ -314,8 +316,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
layout = resolve_output_layout(req)
@@ -332,7 +333,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
source_path=source,
voice="M1",
save_mode="save_next_to_input",
save=SaveConfig(mode="save_next_to_input"),
)
layout = resolve_output_layout(req)
@@ -346,9 +347,11 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save_as_project=True,
save=SaveConfig(
mode="custom_folder",
output_folder=Path(tmpdir),
save_as_project=True,
),
original_filename="test.wav",
)
layout = resolve_output_layout(req)
@@ -388,7 +391,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
separate_chapters_format="wav",
save=SaveConfig(separate_chapters_format="wav"),
)
path = resolve_chapter_path(layout, req, "Chapter 1", 1)
@@ -407,7 +410,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
separate_chapters_format="wav",
save=SaveConfig(separate_chapters_format="wav"),
)
path = resolve_chapter_path(layout, req, "", 3)
@@ -421,7 +424,7 @@ class TestOutputLayoutService:
direct_text="Hello",
voice="M1",
output_format="m4b",
merge_chapters_at_end=False,
save=SaveConfig(merge_chapters_at_end=False),
)
assert should_merge_output(req) is True
@@ -432,7 +435,7 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_chapters_separately=False,
save=SaveConfig(save_chapters_separately=False),
)
assert should_merge_output(req) is True
@@ -443,8 +446,10 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_chapters_separately=True,
merge_chapters_at_end=True,
save=SaveConfig(
save_chapters_separately=True,
merge_chapters_at_end=True,
),
)
assert should_merge_output(req) is True
@@ -455,8 +460,10 @@ class TestOutputLayoutService:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_chapters_separately=True,
merge_chapters_at_end=False,
save=SaveConfig(
save_chapters_separately=True,
merge_chapters_at_end=False,
),
)
assert should_merge_output(req) is False
@@ -501,11 +508,13 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(
mode="custom_folder",
output_folder=Path(tmpdir),
save_chapters_separately=True,
merge_chapters_at_end=False,
),
output_format="m4b",
save_chapters_separately=True,
merge_chapters_at_end=False,
)
plan = ConversionPlan(
request=req,
@@ -546,10 +555,12 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save_chapters_separately=True,
merge_chapters_at_end=True,
save=SaveConfig(
mode="custom_folder",
output_folder=Path(tmpdir),
save_chapters_separately=True,
merge_chapters_at_end=True,
),
)
plan = ConversionPlan(
request=req,
@@ -599,8 +610,7 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -657,8 +667,7 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -698,8 +707,7 @@ class TestExecutorGaps:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
silence_between_chapters=1.0,
)
plan = ConversionPlan(
+7 -11
View File
@@ -14,7 +14,8 @@ from unittest.mock import MagicMock, patch
from dataclasses import dataclass, field
from typing import Any
from abogen.domain.enums import Language
from abogen.domain.config_types import SubtitleConfig
from abogen.domain.enums import Language, SubtitleMode
from abogen.domain.conversion_engine import (
synthesize_text,
SynthParams,
@@ -253,8 +254,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles(
[],
writer,
subtitle_mode="Sentence",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=10.0,
@@ -270,8 +270,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles(
tokens,
writer,
subtitle_mode="Sentence",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=2.0,
@@ -292,8 +291,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles(
tokens,
writer,
subtitle_mode="Line",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.LINE, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=3.0,
@@ -311,8 +309,7 @@ class TestProcessAndWriteSubtitles:
process_and_write_subtitles(
tokens,
writer,
subtitle_mode="Disabled",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.DISABLED, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=2.0,
@@ -366,8 +363,7 @@ class TestFullPipeline:
process_and_write_subtitles(
tokens,
subtitle_writer,
subtitle_mode="Sentence",
max_subtitle_words=5,
subtitle=SubtitleConfig(mode=SubtitleMode.SENTENCE, max_words=5),
language=Language.EN_US,
use_spacy_segmentation=False,
fallback_end_time=stats.current_time,
+8 -16
View File
@@ -12,6 +12,7 @@ from unittest.mock import MagicMock
import numpy as np
import pytest
from abogen.application.conversion_config import SaveConfig
from abogen.application.conversion_executor import execute_conversion
from abogen.application.conversion_models import (
ChapterPlan,
@@ -150,8 +151,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Hello world",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -198,10 +198,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save_chapters_separately=True,
merge_chapters_at_end=True,
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir), save_chapters_separately=True, merge_chapters_at_end=True),
)
plan = ConversionPlan(
request=req,
@@ -262,8 +259,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -315,8 +311,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -377,8 +372,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -424,8 +418,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Hello world",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
@@ -471,8 +464,7 @@ class TestExecuteConversion:
req = ConversionRequest(
direct_text="Text",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = ConversionPlan(
request=req,
+2 -2
View File
@@ -125,12 +125,12 @@ class TestBuildConversionPlan:
def test_output_layout(self):
"""Output layout is resolved from request."""
from abogen.application.conversion_config import SaveConfig
with tempfile.TemporaryDirectory() as tmpdir:
req = ConversionRequest(
direct_text="Hello",
voice="M1",
save_mode="custom_folder",
output_folder=Path(tmpdir),
save=SaveConfig(mode="custom_folder", output_folder=Path(tmpdir)),
)
plan = build_conversion_plan(req)
+31 -25
View File
@@ -16,7 +16,14 @@ from pathlib import Path
from unittest.mock import MagicMock
from abogen.application.conversion_request import ConversionRequest, ConversionRequestError
from abogen.application.conversion_config import ChapterChunkConfig, WordSubstitutionConfig
from abogen.application.conversion_config import (
ChapterChunkConfig,
CoverConfig,
PronunciationConfig,
SaveConfig,
SubtitleConfig,
WordSubstitutionConfig,
)
from abogen.domain.enums import Language, OutputFormat, SaveMode, SubtitleFormat, SubtitleMode
from abogen.domain.normalization import TTSContext
from abogen.domain.settings_core import settings_defaults
@@ -202,23 +209,11 @@ class TestConversionRequestValidation:
def test_defaults_are_valid(self):
req = ConversionRequest()
assert req.max_subtitle_words == 50
assert req.subtitle.max_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
assert req.subtitle.mode == SubtitleMode.DISABLED
def test_speed_clamped_below_min(self):
req = ConversionRequest(speed=0.1)
@@ -256,10 +251,6 @@ class TestConversionRequestValidation:
with pytest.raises(ValueError, match="speaker_mode"):
ConversionRequest(chapter_chunk=ChapterChunkConfig(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")
@@ -276,12 +267,27 @@ class TestConversionRequestValidation:
req = ConversionRequest(
language=Language.FR,
output_format=OutputFormat.MP3,
subtitle_mode=SubtitleMode.SENTENCE,
subtitle_format=SubtitleFormat.ASS,
save_mode=SaveMode.CUSTOM_FOLDER,
subtitle=SubtitleConfig(
mode=SubtitleMode.SENTENCE,
format=SubtitleFormat.ASS,
),
save=SaveConfig(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
assert req.subtitle.mode == SubtitleMode.SENTENCE
assert req.subtitle.format == SubtitleFormat.ASS
assert req.save.mode == SaveMode.CUSTOM_FOLDER
def test_config_objects_constructed(self):
req = ConversionRequest(
subtitle=SubtitleConfig(max_words=100),
cover=CoverConfig(path=Path("/tmp/cover.jpg"), mime="image/jpeg"),
pronunciation=PronunciationConfig(normalization_overrides={"key": "val"}),
save=SaveConfig(save_as_project=True),
)
assert req.subtitle.max_words == 100
assert req.cover.path == Path("/tmp/cover.jpg")
assert req.cover.mime == "image/jpeg"
assert req.pronunciation.normalization_overrides == {"key": "val"}
assert req.save.save_as_project is True
+16 -10
View File
@@ -2,7 +2,8 @@
import pytest
from unittest.mock import patch, MagicMock
from abogen.domain.enums import Language
from abogen.domain.config_types import PronunciationConfig, SubtitleConfig
from abogen.domain.enums import Language, SubtitleMode
from abogen.domain.normalization import prepare_text_for_tts, normalize_text_for_pipeline, build_tts_context, TTSContext
@@ -156,16 +157,16 @@ class TestBuildTtsContext:
assert isinstance(ctx, TTSContext)
def test_default_split_pattern(self):
ctx = build_tts_context(language=Language.EN_US, subtitle_mode="Disabled")
ctx = build_tts_context(language=Language.EN_US, subtitle="Disabled")
assert isinstance(ctx.split_pattern, str)
assert len(ctx.split_pattern) > 0
def test_english_uses_newline_split(self):
ctx = build_tts_context(language=Language.EN_US, subtitle_mode="Disabled")
ctx = build_tts_context(language=Language.EN_US, subtitle="Disabled")
assert ctx.split_pattern == "\n"
def test_cjk_uses_punctuation_split(self):
ctx = build_tts_context(language=Language.JA, subtitle_mode="Disabled")
ctx = build_tts_context(language=Language.JA, subtitle="Disabled")
assert r"\n" in ctx.split_pattern
def test_pronunciation_overrides_compiled(self):
@@ -178,7 +179,7 @@ class TestBuildTtsContext:
]
ctx = build_tts_context(
language=Language.EN_US,
pronunciation_overrides=overrides,
pronunciation=PronunciationConfig(pronunciation_overrides=overrides),
)
assert ctx.pronunciation_rules is not None
assert len(ctx.pronunciation_rules) >= 1
@@ -193,7 +194,7 @@ class TestBuildTtsContext:
]
ctx = build_tts_context(
language=Language.EN_US,
manual_overrides=overrides,
pronunciation=PronunciationConfig(manual_overrides=overrides),
)
assert ctx.pronunciation_rules is not None
assert len(ctx.pronunciation_rules) >= 1
@@ -207,8 +208,10 @@ class TestBuildTtsContext:
]
ctx = build_tts_context(
language=Language.EN_US,
pronunciation_overrides=pronunciation,
manual_overrides=manual,
pronunciation=PronunciationConfig(
pronunciation_overrides=pronunciation,
manual_overrides=manual,
),
)
found_right = any(
r.get("replacement") == "RIGHT" for r in ctx.pronunciation_rules
@@ -229,7 +232,7 @@ class TestBuildTtsContext:
]
ctx = build_tts_context(
language=Language.EN_US,
heteronym_overrides=overrides,
pronunciation=PronunciationConfig(heteronym_overrides=overrides),
)
assert ctx.heteronym_rules is not None
@@ -244,7 +247,10 @@ class TestBuildTtsContext:
def test_normalization_overrides_stored(self):
overrides = {"normalization_numbers": False}
ctx = build_tts_context(language=Language.EN_US, normalization_overrides=overrides)
ctx = build_tts_context(
language=Language.EN_US,
pronunciation=PronunciationConfig(normalization_overrides=overrides),
)
assert ctx.normalization_overrides is overrides
def test_speakers_used_for_pronunciation(self):