From 5ae153f841e69cc2c15945869cce02ba46177591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Fri, 9 Jan 2026 01:36:14 +0300 Subject: [PATCH] Reformat using black --- abogen/book_parser.py | 95 +-- abogen/chunking.py | 18 +- abogen/debug_tts_samples.py | 23 +- abogen/entity_analysis.py | 70 +- abogen/heteronym_overrides.py | 12 +- abogen/kokoro_text_normalization.py | 358 +++++++--- abogen/llm_client.py | 18 +- abogen/normalization_settings.py | 49 +- abogen/pronunciation_store.py | 67 +- abogen/spacy_contraction_resolver.py | 17 +- abogen/spacy_utils.py | 6 +- abogen/speaker_analysis.py | 89 ++- abogen/speaker_configs.py | 6 +- abogen/subtitle_utils.py | 2 + abogen/text_extractor.py | 147 +++- abogen/tts_supertonic.py | 26 +- abogen/utils.py | 30 +- abogen/voice_cache.py | 4 +- abogen/voice_profiles.py | 14 +- build_pypi.py | 3 +- tests/__init__.py | 4 +- tests/test_audiobookshelf_client.py | 5 +- tests/test_book_handler_regression.py | 23 +- tests/test_book_parser.py | 42 +- tests/test_calibre_opds.py | 659 +++++++++--------- tests/test_chapter_overrides.py | 22 +- tests/test_chunk_helpers.py | 4 +- tests/test_conversion_chapter_titles.py | 13 +- tests/test_conversion_series.py | 2 +- tests/test_conversion_voice_resolution.py | 16 +- .../test_date_normalization_comprehensive.py | 41 +- tests/test_debug_tts_samples.py | 39 +- tests/test_epub_content_slicing.py | 68 +- tests/test_epub_exporter.py | 46 +- tests/test_epub_heuristic_nav.py | 48 +- tests/test_epub_html_nav_parsing.py | 67 +- .../test_epub_missing_file_error_handling.py | 30 +- tests/test_epub_ncx_parsing.py | 44 +- tests/test_epub_standard_nav.py | 47 +- tests/test_output_paths.py | 20 +- tests/test_prepare_form.py | 12 +- .../test_preview_applies_manual_overrides.py | 10 +- tests/test_regression_fixes.py | 15 +- tests/test_service.py | 16 +- tests/test_speaker_analysis.py | 17 +- tests/test_text_extractor.py | 12 +- tests/test_text_normalization.py | 42 +- tests/test_voice_cache.py | 6 +- 48 files changed, 1530 insertions(+), 894 deletions(-) diff --git a/abogen/book_parser.py b/abogen/book_parser.py index d81d9c8..cebecc1 100644 --- a/abogen/book_parser.py +++ b/abogen/book_parser.py @@ -18,7 +18,9 @@ from abogen.subtitle_utils import clean_text, calculate_text_length _BRACKETED_NUMBERS_PATTERN = re.compile(r"\[\s*\d+\s*\]") _STANDALONE_PAGE_NUMBERS_PATTERN = re.compile(r"^\s*\d+\s*$", re.MULTILINE) _PAGE_NUMBERS_AT_END_PATTERN = re.compile(r"\s+\d+\s*$", re.MULTILINE) -_PAGE_NUMBERS_WITH_DASH_PATTERN = re.compile(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", re.MULTILINE) +_PAGE_NUMBERS_WITH_DASH_PATTERN = re.compile( + r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", re.MULTILINE +) class BaseBookParser(ABC): @@ -114,7 +116,7 @@ class PdfParser(BaseBookParser): for page_num in range(len(self.pdf_doc)): text = clean_text(self.pdf_doc[page_num].get_text()) - + # Clean up common PDF artifacts: # - Remove bracketed numbers often used for citations [1] text = _BRACKETED_NUMBERS_PATTERN.sub("", text) @@ -128,7 +130,7 @@ class PdfParser(BaseBookParser): page_id = f"page_{page_num + 1}" self.content_texts[page_id] = text self.content_lengths[page_id] = calculate_text_length(text) - + return self.content_texts, self.content_lengths def _extract_book_metadata(self): @@ -166,7 +168,7 @@ class MarkdownParser(BaseBookParser): def process_content(self, replace_single_newlines=True): if self.markdown_text is None: self.load() - + self._process_markdown_content() return self.content_texts, self.content_lengths @@ -255,11 +257,11 @@ class MarkdownParser(BaseBookParser): else: self.content_texts[chapter_id] = header_name self.content_lengths[chapter_id] = calculate_text_length(header_name) - + def get_chapters(self): chapters = super().get_chapters() if not chapters and "markdown_content" in self.content_texts: - chapters.append(("markdown_content", "Content")) + chapters.append(("markdown_content", "Content")) return chapters @@ -282,17 +284,19 @@ class EpubParser(BaseBookParser): # Patch ebooklib to skip missing files import types from ebooklib import epub as _epub_module - + reader_class = _epub_module.EpubReader orig_read_file = reader_class.read_file - + def safe_read_file(self, name): try: return orig_read_file(self, name) except KeyError: - logging.warning(f"Missing file in EPUB: {name}. Returning empty bytes.") + logging.warning( + f"Missing file in EPUB: {name}. Returning empty bytes." + ) return b"" - + reader_class.read_file = safe_read_file try: self.book = epub.read_epub(self.book_path) @@ -302,7 +306,7 @@ class EpubParser(BaseBookParser): def process_content(self, replace_single_newlines=True): if not self.book: self.load() - + self.book_metadata = self._extract_book_metadata() try: nav_item, nav_type = self._identify_nav_item() @@ -310,7 +314,7 @@ class EpubParser(BaseBookParser): except Exception as e: logging.warning(f"EPUB nav processing failed: {e}. Falling back to spine.") self._process_epub_content_spine_fallback() - + return self.content_texts, self.content_lengths def _extract_book_metadata(self): @@ -412,7 +416,7 @@ class EpubParser(BaseBookParser): ): """ Recursive parsing of NCX navigation nodes. - + Logic tested by: tests/test_epub_ncx_parsing.py """ nav_label = nav_point.find("navLabel") @@ -428,7 +432,9 @@ class EpubParser(BaseBookParser): if src: base_href, fragment = src.split("#", 1) if "#" in src else (src, None) - doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + doc_key, doc_idx = self._find_doc_key( + base_href, doc_order, doc_order_decoded + ) if not doc_key: current_entry_node["has_content"] = False else: @@ -458,19 +464,20 @@ class EpubParser(BaseBookParser): ) if title and ( - current_entry_node.get("has_content", False) or current_entry_node["children"] + current_entry_node.get("has_content", False) + or current_entry_node["children"] ): tree_structure_list.append(current_entry_node) def _extract_nav_li_title(self, li_element, link_element=None, span_element=None): """Helper to extract title from a nav
  • element, handling various structures.""" title = "Untitled Section" - + if link_element: title = link_element.get_text(strip=True) or title elif span_element: title = span_element.get_text(strip=True) or title - + # Fallback to direct text if title is empty or default # If we used link/span but got empty string, we try fallback. # If we didn't use link/span, we try fallback. @@ -480,11 +487,11 @@ class EpubParser(BaseBookParser): ).strip() if li_text: title = li_text - - # Second fallback: if we have a span but title is still empty, try span text again + + # Second fallback: if we have a span but title is still empty, try span text again # (covered by logic above mostly, but mirroring original logic's intense fallback) if (not title.strip() or title == "Untitled Section") and span_text: - title = span_text.get_text(strip=True) or title + title = span_text.get_text(strip=True) or title return title @@ -499,7 +506,7 @@ class EpubParser(BaseBookParser): ): """ Recursive parsing of HTML5 Navigation (li) nodes. - + Logic tested by: tests/test_epub_html_nav_parsing.py """ link = li_element.find("a", recursive=False) @@ -509,7 +516,7 @@ class EpubParser(BaseBookParser): if link and "href" in link.attrs: src = link["href"] - + title = self._extract_nav_li_title(li_element, link, span_text) current_entry_node["title"] = title @@ -521,7 +528,9 @@ class EpubParser(BaseBookParser): fragment = None if src: base_href, fragment = src.split("#", 1) if "#" in src else (src, None) - doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + doc_key, doc_idx = self._find_doc_key( + base_href, doc_order, doc_order_decoded + ) if doc_key is not None: position = find_position_func(doc_key, fragment) entry_data = { @@ -557,18 +566,21 @@ class EpubParser(BaseBookParser): # 1. Check ITEM_NAVIGATION nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) - + # 1.1 Support for EPUB 3 EpubNav which might be ITEM_DOCUMENT (9) but with properties=['nav'] if not nav_items: - # Look in ITEM_DOCUMENT for items with 'nav' property - for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): - if hasattr(item, 'get_type') and item.get_type() == ebooklib.ITEM_DOCUMENT: - # Check properties - ebooklib stores opf properties in list - # Some versions use item.properties, some need checking - props = getattr(item, 'properties', []) - if 'nav' in props: - nav_items.append(item) - + # Look in ITEM_DOCUMENT for items with 'nav' property + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + if ( + hasattr(item, "get_type") + and item.get_type() == ebooklib.ITEM_DOCUMENT + ): + # Check properties - ebooklib stores opf properties in list + # Some versions use item.properties, some need checking + props = getattr(item, "properties", []) + if "nav" in props: + nav_items.append(item) + if nav_items: nav_item = next( ( @@ -592,7 +604,11 @@ class EpubParser(BaseBookParser): # 2. NCX in NAV if not nav_item and nav_items: ncx_in_nav = next( - (item for item in nav_items if item.get_name().lower().endswith(".ncx")), + ( + item + for item in nav_items + if item.get_name().lower().endswith(".ncx") + ), None, ) if ncx_in_nav: @@ -624,9 +640,8 @@ class EpubParser(BaseBookParser): if not nav_item or not nav_type: raise ValueError("No navigation document found") - - return nav_item, nav_type + return nav_item, nav_type def _execute_nav_parsing_logic(self, nav_item, nav_type): """Parse the identified navigation item and slice content accordingly.""" @@ -826,10 +841,10 @@ class EpubParser(BaseBookParser): "has_content": True, }, ) - + def _process_epub_content_spine_fallback(self): """ - Process EPUB content using the spine (linear reading order) + Process EPUB content using the spine (linear reading order) when navigation processing fails. """ logging.info("Using spine fallback for EPUB processing.") @@ -877,7 +892,7 @@ class EpubParser(BaseBookParser): if text: self.content_texts[doc_href] = text self.content_lengths[doc_href] = calculate_text_length(text) - + def get_chapters(self): chapters = super().get_chapters() if not chapters: @@ -898,7 +913,7 @@ def get_book_parser(book_path, file_type=None): Factory function to get the appropriate parser instance. """ book_path = os.path.normpath(os.path.abspath(book_path)) - + if not file_type: if book_path.lower().endswith(".pdf"): file_type = "pdf" diff --git a/abogen/chunking.py b/abogen/chunking.py index db914ed..2d6f03b 100644 --- a/abogen/chunking.py +++ b/abogen/chunking.py @@ -80,7 +80,9 @@ def _normalize_whitespace(value: str) -> str: def _normalize_chunk_text(value: str) -> str: settings = get_runtime_settings() - config = build_apostrophe_config(settings=settings, base=_PIPELINE_APOSTROPHE_CONFIG) + config = build_apostrophe_config( + settings=settings, base=_PIPELINE_APOSTROPHE_CONFIG + ) normalized = normalize_for_pipeline(value, config=config, settings=settings) return _normalize_whitespace(normalized) @@ -158,12 +160,16 @@ def chunk_text( # Sentence level – flatten paragraphs into individual sentences sentence_index = 0 - for para_index, paragraph in enumerate(list(_iter_paragraphs(text)) or [text.strip()]): + for para_index, paragraph in enumerate( + list(_iter_paragraphs(text)) or [text.strip()] + ): normalized_para = _normalize_whitespace(paragraph) if not normalized_para: continue sentence_pairs = _split_sentences(paragraph) or [(normalized_para, paragraph)] - for sent_local_index, (normalized_sentence, raw_sentence) in enumerate(sentence_pairs): + for sent_local_index, (normalized_sentence, raw_sentence) in enumerate( + sentence_pairs + ): normalized_sentence = _normalize_whitespace(normalized_sentence) if not normalized_sentence: continue @@ -203,7 +209,9 @@ def _build_display_pattern(text: str) -> Pattern[str]: return pattern -def _search_source_span(source: str, normalized: str, start: int) -> Optional[Tuple[int, int]]: +def _search_source_span( + source: str, normalized: str, start: int +) -> Optional[Tuple[int, int]]: if not normalized: return None pattern = _build_display_pattern(normalized) @@ -264,4 +272,4 @@ def build_chunks_for_chapters( chunk_prefix=str(prefix), ) all_chunks.extend(chapter_chunks) - return all_chunks \ No newline at end of file + return all_chunks diff --git a/abogen/debug_tts_samples.py b/abogen/debug_tts_samples.py index 6fa4c52..f881101 100644 --- a/abogen/debug_tts_samples.py +++ b/abogen/debug_tts_samples.py @@ -287,12 +287,12 @@ def build_debug_epub(dest_path: Path, *, title: str = "abogen debug samples") -> dest_path.parent.mkdir(parents=True, exist_ok=True) chapter_lines: List[str] = [ - "", + '', "", - "", + '', "", f" {title}", - " ", + ' ', "", "", f"

    {title}

    ", @@ -303,7 +303,11 @@ def build_debug_epub(dest_path: Path, *, title: str = "abogen debug samples") -> safe_label = sample.label.replace("&", "and") chapter_lines.append(f"

    {safe_label}

    ") chapter_lines.append( - "

    " + marker_for(sample.code) + " " + sample.text + "

    " + "

    " + + marker_for(sample.code) + + " " + + sample.text + + "

    " ) chapter_lines += ["", ""] @@ -366,8 +370,15 @@ def build_debug_epub(dest_path: Path, *, title: str = "abogen debug samples") -> # Per EPUB spec: mimetype must be the first entry and stored (no compression). with zipfile.ZipFile(dest_path, "w") as zf: - zf.write(tmp_path / "mimetype", "mimetype", compress_type=zipfile.ZIP_STORED) - for source in (meta_inf / "container.xml", oebps / "content.opf", oebps / "chapter.xhtml", oebps / "nav.xhtml"): + zf.write( + tmp_path / "mimetype", "mimetype", compress_type=zipfile.ZIP_STORED + ) + for source in ( + meta_inf / "container.xml", + oebps / "content.opf", + oebps / "chapter.xhtml", + oebps / "nav.xhtml", + ): arcname = str(source.relative_to(tmp_path)).replace("\\", "/") zf.write(source, arcname, compress_type=zipfile.ZIP_DEFLATED) diff --git a/abogen/entity_analysis.py b/abogen/entity_analysis.py index 34ef2d2..4c0b561 100644 --- a/abogen/entity_analysis.py +++ b/abogen/entity_analysis.py @@ -82,7 +82,10 @@ _EXCLUDED_NER_LABELS = { "QUANTITY", } -_TITLE_PATTERN = re.compile(r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", re.IGNORECASE) +_TITLE_PATTERN = re.compile( + r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", + re.IGNORECASE, +) _POSSESSIVE_PATTERN = re.compile(r"(?:'s|’s|\u2019s)$", re.IGNORECASE) _NON_WORD_PATTERN = re.compile(r"[^\w\s'-]+") _MULTI_SPACE_PATTERN = re.compile(r"\s+") @@ -104,7 +107,9 @@ class EntityRecord: forms: Counter = field(default_factory=Counter) first_position: Optional[Tuple[int, int]] = None - def register(self, *, chapter_index: int, position: int, text: str, sentence: Optional[str]) -> None: + def register( + self, *, chapter_index: int, position: int, text: str, sentence: Optional[str] + ) -> None: self.count += 1 self.chapter_indices.add(chapter_index) self.forms[text] += 1 @@ -163,7 +168,9 @@ def _resolve_model_name(language: str) -> str: def _load_model(language: str) -> Any: if spacy is None: - raise EntityModelError("spaCy is not available. Install spaCy to enable entity extraction.") + raise EntityModelError( + "spaCy is not available. Install spaCy to enable entity extraction." + ) model_name = _resolve_model_name(language) cache_key = model_name.lower() @@ -249,7 +256,9 @@ def _extract_propn_tokens(doc: Any) -> Iterable[Any]: # type: ignore[override] yield doc[token.i : token.i + 1] -def _empty_result(cache_key: str, error: Optional[str] = None) -> EntityExtractionResult: +def _empty_result( + cache_key: str, error: Optional[str] = None +) -> EntityExtractionResult: payload = { "people": [], "entities": [], @@ -262,7 +271,9 @@ def _empty_result(cache_key: str, error: Optional[str] = None) -> EntityExtracti "model": None, } errors = [error] if error else [] - return EntityExtractionResult(summary=payload, cache_key=cache_key, elapsed=0.0, errors=errors) + return EntityExtractionResult( + summary=payload, cache_key=cache_key, elapsed=0.0, errors=errors + ) def extract_entities( @@ -319,18 +330,25 @@ def extract_entities( key = _token_key(cleaned) if not key: return - category = category_hint or ("people" if span.label_ == "PERSON" else "entities") + category = category_hint or ( + "people" if span.label_ == "PERSON" else "entities" + ) record_key = (category, key) record = records.get(record_key) if record is None: record = EntityRecord( key=record_key, label=cleaned, - kind=span.label_ or ("PROPN" if category == "entities" else "PERSON"), + kind=span.label_ + or ("PROPN" if category == "entities" else "PERSON"), category=category, ) records[record_key] = record - sentence = span.sent.text if hasattr(span, "sent") and span.sent is not None else None + sentence = ( + span.sent.text + if hasattr(span, "sent") and span.sent is not None + else None + ) record.register( chapter_index=chapter_index, position=span.start, @@ -361,7 +379,9 @@ def extract_entities( elapsed = time.perf_counter() - start - people_records = [record for record in records.values() if record.category == "people"] + people_records = [ + record for record in records.values() if record.category == "people" + ] people_keys = {record.key[1] for record in people_records} entity_records = [ record @@ -374,10 +394,16 @@ def extract_entities( people_records.sort(key=lambda rec: (-rec.count, rec.label)) entity_records.sort(key=lambda rec: (-rec.count, rec.label)) - people_payload = [record.as_dict(index + 1) for index, record in enumerate(people_records)] - entity_payload = [record.as_dict(index + 1) for index, record in enumerate(entity_records)] + people_payload = [ + record.as_dict(index + 1) for index, record in enumerate(people_records) + ] + entity_payload = [ + record.as_dict(index + 1) for index, record in enumerate(entity_records) + ] - index_payload = sorted(tokens_for_index.values(), key=lambda item: (-item["count"], item["token"])) + index_payload = sorted( + tokens_for_index.values(), key=lambda item: (-item["count"], item["token"]) + ) summary = { "people": people_payload, @@ -397,10 +423,14 @@ def extract_entities( }, } - return EntityExtractionResult(summary=summary, cache_key=cache_key, elapsed=elapsed, errors=[]) + return EntityExtractionResult( + summary=summary, cache_key=cache_key, elapsed=elapsed, errors=[] + ) -def search_tokens(index: Mapping[str, Any], query: str, *, limit: int = 15) -> List[Dict[str, Any]]: +def search_tokens( + index: Mapping[str, Any], query: str, *, limit: int = 15 +) -> List[Dict[str, Any]]: tokens = index.get("tokens") if isinstance(index, Mapping) else None if not isinstance(tokens, list) or not query: return [] @@ -411,14 +441,18 @@ def search_tokens(index: Mapping[str, Any], query: str, *, limit: int = 15) -> L for entry in tokens: token_label = str(entry.get("token", "")) normalized_label = token_label.lower() - if normalized in normalized_label or normalized in str(entry.get("normalized", "")): + if normalized in normalized_label or normalized in str( + entry.get("normalized", "") + ): results.append(entry) if len(results) >= limit: break return results -def merge_override(summary: Mapping[str, Any], overrides: Mapping[str, Mapping[str, Any]]) -> Dict[str, Any]: +def merge_override( + summary: Mapping[str, Any], overrides: Mapping[str, Mapping[str, Any]] +) -> Dict[str, Any]: if not isinstance(summary, Mapping): return {"people": [], "entities": []} merged_summary: Dict[str, Any] = dict(summary) @@ -430,7 +464,9 @@ def merge_override(summary: Mapping[str, Any], overrides: Mapping[str, Mapping[s for entry in items: if not isinstance(entry, Mapping): continue - normalized = _token_key(str(entry.get("normalized") or entry.get("label") or "")) + normalized = _token_key( + str(entry.get("normalized") or entry.get("label") or "") + ) merged = dict(entry) if normalized and normalized in overrides: merged_override = dict(overrides[normalized]) diff --git a/abogen/heteronym_overrides.py b/abogen/heteronym_overrides.py index dbb5384..08cbd07 100644 --- a/abogen/heteronym_overrides.py +++ b/abogen/heteronym_overrides.py @@ -152,7 +152,9 @@ def _word_boundary_pattern(token: str) -> re.Pattern[str]: if cached is not None: return cached escaped = re.escape(token) - pattern = re.compile(rf"(?i)(?'s|\u2019s|\u2019)?(?!\w)") + pattern = re.compile( + rf"(?i)(?'s|\u2019s|\u2019)?(?!\w)" + ) _WORD_BOUNDARY_CACHE[key] = pattern return pattern @@ -167,7 +169,9 @@ def _preserve_case(replacement: str, original: str) -> str: return replacement -def _build_replacement_sentence(sentence: str, token: str, replacement_token: str) -> str: +def _build_replacement_sentence( + sentence: str, token: str, replacement_token: str +) -> str: pattern = _word_boundary_pattern(token) def _repl(match: re.Match[str]) -> str: @@ -271,7 +275,9 @@ def extract_heteronym_overrides( options: List[Dict[str, Any]] = [] for variant in spec.variants: replacement_sentence = _build_replacement_sentence( - sentence, token=spec.token, replacement_token=variant.replacement_token + sentence, + token=spec.token, + replacement_token=variant.replacement_token, ) options.append( { diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py index 563e63d..17665ad 100644 --- a/abogen/kokoro_text_normalization.py +++ b/abogen/kokoro_text_normalization.py @@ -7,7 +7,18 @@ import os import locale from fractions import Fraction from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, + Tuple, +) import logging logger = logging.getLogger(__name__) @@ -16,7 +27,9 @@ try: # pragma: no cover - optional dependency guard from num2words import num2words except ImportError: num2words = None - logger.warning("num2words library not found. Number normalization will be disabled.") + logger.warning( + "num2words library not found. Number normalization will be disabled." + ) except Exception as e: # pragma: no cover - graceful degradation num2words = None logger.error(f"Failed to import num2words: {e}") @@ -41,34 +54,44 @@ CONTRACTION_CATEGORY_DEFAULTS: Dict[str, bool] = { # ---------- Configuration Dataclass ---------- + @dataclass class ApostropheConfig: - contraction_mode: str = "expand" # expand|collapse|keep - possessive_mode: str = "keep" # keep|collapse - plural_possessive_mode: str = "collapse" # keep|collapse - irregular_possessive_mode: str = "keep" # keep|expand (expand just means keep or add hints; modify if needed) - sibilant_possessive_mode: str = "mark" # keep|mark|approx - fantasy_mode: str = "keep" # keep|mark|collapse_internal - acronym_possessive_mode: str = "keep" # keep|collapse_add_s - decades_mode: str = "expand" # keep|expand - leading_elision_mode: str = "expand" # keep|expand - ambiguous_past_modal_mode: str = "contextual" # keep|expand_prefer_would|expand_prefer_had|contextual - add_phoneme_hints: bool = True # Whether to emit markers like ‹IZ› - fantasy_marker: str = "‹FAP›" # Marker inserted if fantasy_mode == mark - sibilant_iz_marker: str = "‹IZ›" # Marker for /ɪz/ insertion - joiner: str = "" # Replacement used when collapsing internal apostrophes - lowercase_for_matching: bool = True # Normalize to lower for rule matching (not output) - protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc. - convert_numbers: bool = True # Convert grouped numbers such as 12,500 to words - convert_currency: bool = True # Convert currency symbols to words - remove_footnotes: bool = True # Remove footnote indicators - number_lang: str = "en" # num2words language code - year_pronunciation_mode: str = "american" # off|american (extend if needed) - contraction_categories: Dict[str, bool] = field(default_factory=lambda: dict(CONTRACTION_CATEGORY_DEFAULTS)) + contraction_mode: str = "expand" # expand|collapse|keep + possessive_mode: str = "keep" # keep|collapse + plural_possessive_mode: str = "collapse" # keep|collapse + irregular_possessive_mode: str = ( + "keep" # keep|expand (expand just means keep or add hints; modify if needed) + ) + sibilant_possessive_mode: str = "mark" # keep|mark|approx + fantasy_mode: str = "keep" # keep|mark|collapse_internal + acronym_possessive_mode: str = "keep" # keep|collapse_add_s + decades_mode: str = "expand" # keep|expand + leading_elision_mode: str = "expand" # keep|expand + ambiguous_past_modal_mode: str = ( + "contextual" # keep|expand_prefer_would|expand_prefer_had|contextual + ) + add_phoneme_hints: bool = True # Whether to emit markers like ‹IZ› + fantasy_marker: str = "‹FAP›" # Marker inserted if fantasy_mode == mark + sibilant_iz_marker: str = "‹IZ›" # Marker for /ɪz/ insertion + joiner: str = "" # Replacement used when collapsing internal apostrophes + lowercase_for_matching: bool = ( + True # Normalize to lower for rule matching (not output) + ) + protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc. + convert_numbers: bool = True # Convert grouped numbers such as 12,500 to words + convert_currency: bool = True # Convert currency symbols to words + remove_footnotes: bool = True # Remove footnote indicators + number_lang: str = "en" # num2words language code + year_pronunciation_mode: str = "american" # off|american (extend if needed) + contraction_categories: Dict[str, bool] = field( + default_factory=lambda: dict(CONTRACTION_CATEGORY_DEFAULTS) + ) def is_contraction_enabled(self, category: str) -> bool: return self.contraction_categories.get(category, True) + # ---------- Dictionaries / Patterns ---------- # Common contraction expansions (type + expansion words) @@ -124,14 +147,18 @@ _FRACTION_RE = re.compile( _CURRENCY_RE = re.compile( r"(?P[$£€¥])\s*(?P\d{1,3}(?:,\d{3})*(?:\.\d+)?)(?:\s+(?Phundred|thousand|million|billion|trillion|quadrillion))?(?!\d)", - re.IGNORECASE + re.IGNORECASE, ) -_URL_RE = re.compile(r"(https?://)?(www\.)?(?P[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+)(/[^\s]*)?") +_URL_RE = re.compile( + r"(https?://)?(www\.)?(?P[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+)(/[^\s]*)?" +) _FOOTNOTE_RE = re.compile(r"([a-zA-Z]+)(\d+)") _BRACKET_FOOTNOTE_RE = re.compile(r"\[\d+\]") -_ISO_DATE_RE = re.compile(r"\b(?P\d{4})[/-](?P\d{1,2})[/-](?P\d{1,2})\b") +_ISO_DATE_RE = re.compile( + r"\b(?P\d{4})[/-](?P\d{1,2})[/-](?P\d{1,2})\b" +) _MDY_DATE_RE = re.compile( r"\b(?PJan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\.?\s+" r"(?P\d{1,2})(?:st|nd|rd|th)?\s*,\s*(?P\d{4})\b", @@ -143,7 +170,9 @@ _TIME_RE = re.compile( re.IGNORECASE, ) -_ADDRESS_ABBR_RE = re.compile(r"(?P\b\w+\s+)(?PSt|Rd|Ave|Blvd|Ln|Dr)\.(?=\s*(?:,|\.|!|\?|$))") +_ADDRESS_ABBR_RE = re.compile( + r"(?P\b\w+\s+)(?PSt|Rd|Ave|Blvd|Ln|Dr)\.(?=\s*(?:,|\.|!|\?|$))" +) _MONTH_MAP = { "jan": "January", @@ -218,25 +247,27 @@ def _normalize_dates(text: str, language: str) -> str: day = int(match.group("day")) if not (1 <= month <= 12 and 1 <= day <= 31): return match.group(0) - month_name = ( - [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ][month - 1] - ) + month_name = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ][month - 1] ordinal = _int_to_ordinal_words(day, language) or str(day) year_words = _year_to_words_american(year, language) - return f"{month_name} {ordinal}, {year_words}" if us else f"{ordinal} {month_name} {year_words}" + return ( + f"{month_name} {ordinal}, {year_words}" + if us + else f"{ordinal} {month_name} {year_words}" + ) def _format_mdy(match: re.Match[str]) -> str: month_raw = str(match.group("month") or "").strip().lower().rstrip(".") @@ -247,7 +278,11 @@ def _normalize_dates(text: str, language: str) -> str: year = int(match.group("year")) ordinal = _int_to_ordinal_words(day, language) or str(day) year_words = _year_to_words_american(year, language) - return f"{month_name} {ordinal}, {year_words}" if us else f"{ordinal} {month_name} {year_words}" + return ( + f"{month_name} {ordinal}, {year_words}" + if us + else f"{ordinal} {month_name} {year_words}" + ) out = _ISO_DATE_RE.sub(_format_iso, text) out = _MDY_DATE_RE.sub(_format_mdy, out) @@ -301,6 +336,7 @@ def _normalize_internet_slang(text: str) -> str: return re.sub(r"\b(?:pls|plz)\b", _replace, text, flags=re.IGNORECASE) + _DECIMAL_NUMBER_RE = re.compile( rf"(?-?(?:\d{{1,3}}(?:,\d{{3}})+|\d+)\.(?P\d+))(?![\w{_NUMBER_RANGE_CLASS}/])" ) @@ -308,7 +344,18 @@ _PLAIN_NUMBER_RE = re.compile( rf"(?{_NUMBER_CORE_PATTERN})(?![\w{_NUMBER_RANGE_CLASS}/])" ) -_DIGIT_WORDS = ("zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine") +_DIGIT_WORDS = ( + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", +) def _int_to_words(value: int, language: str) -> Optional[str]: @@ -384,7 +431,9 @@ def _pluralize_fraction_word(base: str) -> str: return base + "s" -def _fraction_denominator_word(denominator: int, numerator: int, language: str) -> Optional[str]: +def _fraction_denominator_word( + denominator: int, numerator: int, language: str +) -> Optional[str]: """Return spoken form for fraction denominator respecting plurality.""" if denominator == 0: return None @@ -405,7 +454,9 @@ def _fraction_denominator_word(denominator: int, numerator: int, language: str) return _pluralize_fraction_word(base) -def _format_fraction_words(numerator: int, denominator: int, language: str) -> Optional[str]: +def _format_fraction_words( + numerator: int, denominator: int, language: str +) -> Optional[str]: """Return spoken representation of a simple fraction.""" if denominator == 0: return None @@ -492,7 +543,9 @@ def _coerce_int_token(token: str) -> Optional[int]: return int(cleaned) except ValueError: return None -AMBIGUOUS_D_BASES = {"i","you","he","she","we","they"} + + +AMBIGUOUS_D_BASES = {"i", "you", "he", "she", "we", "they"} AMBIGUOUS_S_BASES = { "it", "that", @@ -520,6 +573,7 @@ def _is_ambiguous_s(token: str) -> bool: low = token.lower() return low.endswith("'s") and low[:-2] in AMBIGUOUS_S_BASES + # Irregular possessives that are not formed by simple + 's logic IRREGULAR_POSSESSIVES = { "children's": "children's", @@ -527,12 +581,12 @@ IRREGULAR_POSSESSIVES = { "women's": "women's", "people's": "people's", "geese's": "geese's", - "mouse's": "mouse's", # singular irregular + "mouse's": "mouse's", # singular irregular } SIBILANT_END_RE = re.compile(r"(?:[sxz]|(?:ch|sh))$", re.IGNORECASE) -DECADE_RE = re.compile(r"^'\d0s$", re.IGNORECASE) # '90s, '80s +DECADE_RE = re.compile(r"^'\d0s$", re.IGNORECASE) # '90s, '80s LEADING_ELISION = { "'tis": "it is", "'twas": "it was", @@ -546,7 +600,7 @@ CULTURAL_NAME_PATTERNS = [ re.compile(r"^O'[A-Z][a-z]+$"), re.compile(r"^D'[A-Z][a-z]+$"), re.compile(r"^L'[A-Za-z].*$"), - re.compile(r"^Mc[A-Z].*$"), # not apostrophe, but often relevant (kept anyway) + re.compile(r"^Mc[A-Z].*$"), # not apostrophe, but often relevant (kept anyway) ] ACRONYM_POSSESSIVE_RE = re.compile(r"^[A-Z]{2,}'s$") @@ -563,7 +617,7 @@ WORD_TOKEN_RE = re.compile( APOSTROPHE_CHARS = "’`´ꞌʼ" TERMINAL_PUNCTUATION = {".", "?", "!", "…", ";", ":"} -CLOSING_PUNCTUATION = '"\'”’)]}»›' +CLOSING_PUNCTUATION = "\"'”’)]}»›" ELLIPSIS_SUFFIXES = ("...", "…") _LINE_SPLIT_RE = re.compile(r"(\n+)") @@ -584,29 +638,38 @@ SUFFIX_ABBREVIATIONS = { } _TITLE_PATTERN = re.compile( - r"\b(?P" + "|".join(sorted(TITLE_ABBREVIATIONS.keys(), key=len, reverse=True)) + r")\.", + r"\b(?P" + + "|".join(sorted(TITLE_ABBREVIATIONS.keys(), key=len, reverse=True)) + + r")\.", re.IGNORECASE, ) _SUFFIX_PATTERN = re.compile( - r"\b(?P" + "|".join(sorted(SUFFIX_ABBREVIATIONS.keys(), key=len, reverse=True)) + r")\.", + r"\b(?P" + + "|".join(sorted(SUFFIX_ABBREVIATIONS.keys(), key=len, reverse=True)) + + r")\.", re.IGNORECASE, ) # ---------- Utility Functions ---------- + def normalize_unicode_apostrophes(text: str) -> str: text = unicodedata.normalize("NFKC", text) for ch in APOSTROPHE_CHARS: text = text.replace(ch, "'") return text + def tokenize(text: str) -> List[str]: # Simple tokenization preserving punctuation tokens return WORD_TOKEN_RE.findall(text) def tokenize_with_spans(text: str) -> List[Tuple[str, int, int]]: - return [(match.group(0), match.start(), match.end()) for match in WORD_TOKEN_RE.finditer(text)] + return [ + (match.group(0), match.start(), match.end()) + for match in WORD_TOKEN_RE.finditer(text) + ] def _cleanup_spacing(text: str) -> str: @@ -661,7 +724,9 @@ _ROMAN_COMPOSE_ORDER = [ (1, "I"), ] -_ROMAN_PREFIX_RE = re.compile(r"^(?P[IVXLCDM]+)(?P[\s\.:,;\-–—]*)", re.IGNORECASE) +_ROMAN_PREFIX_RE = re.compile( + r"^(?P[IVXLCDM]+)(?P[\s\.:,;\-–—]*)", re.IGNORECASE +) _ROMAN_TOKEN_RE = re.compile(r"^[IVXLCDM]+$") _ROMAN_CARDINAL_CONTEXTS = { @@ -816,7 +881,9 @@ def _token_is_cardinal_context(token: str) -> bool: return token.lower() in _ROMAN_CARDINAL_CONTEXTS -def _has_cardinal_leading_context(tokens: Sequence[Tuple[str, int, int]], index: int) -> bool: +def _has_cardinal_leading_context( + tokens: Sequence[Tuple[str, int, int]], index: int +) -> bool: j = index - 1 while j >= 0: token, *_ = tokens[j] @@ -927,7 +994,9 @@ def _normalize_roman_numerals(text: str, language: str) -> str: if len(token) >= 2: if token.isupper(): convert = True - elif numeric_value <= 200 and _has_cardinal_leading_context(tokens, index): + elif numeric_value <= 200 and _has_cardinal_leading_context( + tokens, index + ): convert = True elif len(token) == 1: # Only convert single letters if context is strong @@ -1002,7 +1071,10 @@ def _should_preserve_caps_word(word: str) -> bool: upper_base = base.upper() if upper_base in _ACRONYM_ALLOWLIST: return True - if all(ch in _ROMAN_NUMERAL_LETTERS for ch in letters.upper()) and len(letters) <= 7: + if ( + all(ch in _ROMAN_NUMERAL_LETTERS for ch in letters.upper()) + and len(letters) <= 7 + ): return True return False @@ -1128,7 +1200,7 @@ def normalize_roman_numeral_titles( roman_token = match.group("roman") separator = match.group("sep") or "" - rest = stripped[match.end():] + rest = stripped[match.end() :] if not separator and rest and rest[:1].isalnum(): normalized.append(title) @@ -1269,7 +1341,9 @@ def _apply_contraction_policy( return expand() -def _assemble_contraction_expansion(base_text: str, surface_text: str, expansion_word: str) -> str: +def _assemble_contraction_expansion( + base_text: str, surface_text: str, expansion_word: str +) -> str: if not expansion_word: return base_text @@ -1340,6 +1414,7 @@ def _classify_ambiguous_s(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: return candidates[0][0], token + def classify_token(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: """ Classify apostrophe usage and propose normalized form. @@ -1398,11 +1473,15 @@ def classify_token(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: return _case_preserving_words(token, words) collapse_value = token.replace("'", "") - normalized = _apply_contraction_policy(token, category=category, cfg=cfg, expand=_expand, collapse=collapse_value) + normalized = _apply_contraction_policy( + token, category=category, cfg=cfg, expand=_expand, collapse=collapse_value + ) return category, normalized # 6. Suffix contractions ('m handled separately) - if low.endswith("'m") and low[:-2] in SUFFIX_CONTRACTION_BASES.get("'m", ()): # pronoun I'm + if low.endswith("'m") and low[:-2] in SUFFIX_CONTRACTION_BASES.get( + "'m", () + ): # pronoun I'm def _expand_m() -> str: base = token[:-2] @@ -1488,7 +1567,10 @@ def classify_token(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: return "other", token.replace("'", cfg.joiner) return "other", token -def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tuple[str, List[Tuple[str,str,str]]]: + +def normalize_apostrophes( + text: str, cfg: ApostropheConfig | None = None +) -> Tuple[str, List[Tuple[str, str, str]]]: """ Normalize apostrophes per config. Returns normalized text AND a list of (original_token, category, normalized_token) @@ -1502,7 +1584,10 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup token_entries = tokenize_with_spans(text) use_contextual_s = cfg.contraction_mode == "expand" - use_contextual_d = cfg.contraction_mode == "expand" and cfg.ambiguous_past_modal_mode == "contextual" + use_contextual_d = ( + cfg.contraction_mode == "expand" + and cfg.ambiguous_past_modal_mode == "contextual" + ) need_contextual = False if (use_contextual_s or use_contextual_d) and token_entries: @@ -1514,7 +1599,9 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup need_contextual = True break - contextual_resolutions = resolve_ambiguous_contractions(text) if need_contextual else {} + contextual_resolutions = ( + resolve_ambiguous_contractions(text) if need_contextual else {} + ) results: List[Tuple[str, str, str]] = [] normalized_tokens: List[str] = [] @@ -1522,7 +1609,9 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup for tok, start, end in token_entries: category, norm = classify_token(tok, cfg) - resolution = contextual_resolutions.get((start, end)) if contextual_resolutions else None + resolution = ( + contextual_resolutions.get((start, end)) if contextual_resolutions else None + ) if resolution is not None and cfg.contraction_mode == "expand": if cfg.is_contraction_enabled(resolution.category): category = resolution.category @@ -1537,6 +1626,7 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup normalized_text = _cleanup_spacing(" ".join(filtered)) return normalized_text, results + def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: if not text: return text @@ -1609,14 +1699,14 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: first_two = value // 100 last_two = value % 100 - + prefix = _words(first_two) if not prefix: return None if last_two == 0: return f"{prefix} hundred" - + if last_two < 10: # Use "oh X" format (e.g. "nineteen oh five") return f"{prefix} oh {_DIGIT_WORDS[last_two]}" @@ -1673,9 +1763,11 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: # Check for "address" or "addresses" as a whole word has_address = bool(re.search(r"\baddress(es)?\b", context)) - + # Check for year markers as whole words - has_year_marker = bool(re.search(r"\b(bc|ad|bce|ce|b\.c\.|a\.d\.|b\.c\.e\.|c\.e\.)\b", context)) + has_year_marker = bool( + re.search(r"\b(bc|ad|bce|ce|b\.c\.|a\.d\.|b\.c\.e\.|c\.e\.)\b", context) + ) should_try_year = True if has_address and not has_year_marker: @@ -1740,7 +1832,7 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: def _replace_currency(match: re.Match[str]) -> str: if num2words is None: return match.group(0) - + symbol = match.group("symbol") amount_str = match.group("amount").replace(",", "") magnitude = match.group("magnitude") @@ -1756,17 +1848,17 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: integer_part, fraction_part = amount_str.split(".", 1) integer_val = int(integer_part) integer_words = _int_to_words(integer_val, language) - + # Spell out fraction digits digit_words = [] for digit in fraction_part: if digit.isdigit(): digit_words.append(_DIGIT_WORDS[int(digit)]) - + amount_spoken = f"{integer_words} point {' '.join(digit_words)}" else: amount_spoken = _int_to_words(int(amount), language) - + currency_names = { "$": "dollars", "£": "pounds", @@ -1774,7 +1866,7 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: "¥": "yen", } currency_name = currency_names.get(symbol, "dollars") - + return f"{amount_spoken} {magnitude} {currency_name}" # Handle $0.99 -> ninety-nine cents (avoid "zero dollars and..."). @@ -1787,7 +1879,9 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: except ValueError: cents_value = 0 if cents_value > 0: - cents_words = _int_to_words(cents_value, language) or str(cents_value) + cents_words = _int_to_words(cents_value, language) or str( + cents_value + ) subunit = { "$": "cent", "€": "cent", @@ -1797,7 +1891,11 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: if symbol == "£": subunit = "pence" if cents_value != 1 else "penny" else: - subunit = (subunit + "s") if cents_value != 1 and subunit not in {"pence", "yen"} else subunit + subunit = ( + (subunit + "s") + if cents_value != 1 and subunit not in {"pence", "yen"} + else subunit + ) return f"{cents_words} {subunit}".strip() currency_map = { @@ -1807,12 +1905,14 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: "¥": "JPY", } currency_code = currency_map.get(symbol, "USD") - + try: # Always use float to avoid num2words treating int as cents (if that's what it does) # or to ensure consistent behavior. - words = num2words(amount, to="currency", currency=currency_code, lang=language) - + words = num2words( + amount, to="currency", currency=currency_code, lang=language + ) + # Remove "zero cents" if present # Patterns: ", zero cents", " and zero cents" words = words.replace(", zero cents", "").replace(" and zero cents", "") @@ -1829,21 +1929,21 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: parts = [p for p in domain.split(".") if p] if parts and all(p.isalpha() and len(p) <= 2 for p in parts): return match.group(0) - + # Avoid matching numbers like 1.05 or 12.34.56 # If the domain consists only of digits and dots, ignore it (unless it has http/www prefix) has_prefix = match.group(1) or match.group(2) - if not has_prefix and all(c.isdigit() or c == '.' or c == '-' for c in domain): - # Check if it really looks like a number (e.g. 1.05) - # If it has multiple dots like 1.2.3.4 it might be an IP, which we might want to speak as dot? - # But 1.05 is definitely a number. - # Let's be safe: if it looks like a float, skip. - try: - float(domain) - return match.group(0) - except ValueError: - pass - + if not has_prefix and all(c.isdigit() or c == "." or c == "-" for c in domain): + # Check if it really looks like a number (e.g. 1.05) + # If it has multiple dots like 1.2.3.4 it might be an IP, which we might want to speak as dot? + # But 1.05 is definitely a number. + # Let's be safe: if it looks like a float, skip. + try: + float(domain) + return match.group(0) + except ValueError: + pass + if domain.startswith("www."): domain = domain[4:] spoken = domain.replace(".", " dot ") @@ -1865,15 +1965,23 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: normalized = _CURRENCY_RE.sub(_replace_currency, normalized) if cfg.convert_numbers: - normalized = _NUMBER_RANGE_RE.sub(lambda m: _replace_number_range(m, language), normalized) - normalized = _NUMBER_SPACE_RANGE_RE.sub(lambda m: _replace_space_separated_range(m, language), normalized) - normalized = _FRACTION_RE.sub(lambda m: _replace_fraction(m, language), normalized) + normalized = _NUMBER_RANGE_RE.sub( + lambda m: _replace_number_range(m, language), normalized + ) + normalized = _NUMBER_SPACE_RANGE_RE.sub( + lambda m: _replace_space_separated_range(m, language), normalized + ) + normalized = _FRACTION_RE.sub( + lambda m: _replace_fraction(m, language), normalized + ) normalized = _DECIMAL_NUMBER_RE.sub(_replace_decimal, normalized) normalized = _NUMBER_WITH_GROUP_RE.sub(_replace_grouped, normalized) normalized = _PLAIN_NUMBER_RE.sub(_replace_plain, normalized) normalized = _normalize_roman_numerals(normalized, language) return normalized + + def _normalize_dotted_acronyms(text: str) -> str: def _replace(match: re.Match[str]) -> str: value = match.group(0) @@ -1882,8 +1990,10 @@ def _normalize_dotted_acronyms(text: str) -> str: return re.sub(r"\b(?:[A-Z]\.){1,}[A-Z]\.?(?=\W|$)", _replace, text) + # ---------- Optional phoneme hint post-processing ---------- + def apply_phoneme_hints(text: str, iz_marker="‹IZ›") -> str: """ Replace markers with an orthographic sequence that @@ -1951,7 +2061,10 @@ _LLM_REGEX_TOOL = { }, }, } -_LLM_REGEX_TOOL_CHOICE = {"type": "function", "function": {"name": _LLM_REGEX_TOOL_NAME}} +_LLM_REGEX_TOOL_CHOICE = { + "type": "function", + "function": {"name": _LLM_REGEX_TOOL_NAME}, +} _LLM_ALLOWED_REGEX_FLAGS = { "IGNORECASE": re.IGNORECASE, "MULTILINE": re.MULTILINE, @@ -1974,7 +2087,9 @@ _SENTENCE_CAPTURE_RE = re.compile(r"[^.!?]+[.!?]+|[^.!?]+$", re.MULTILINE) def _split_sentences_for_llm(text: str) -> List[str]: - sentences = [segment.strip() for segment in _SENTENCE_CAPTURE_RE.findall(text or "")] + sentences = [ + segment.strip() for segment in _SENTENCE_CAPTURE_RE.findall(text or "") + ] return [segment for segment in sentences if segment] @@ -1984,7 +2099,10 @@ def _normalize_with_llm( settings: Mapping[str, Any], config: ApostropheConfig, ) -> str: - from abogen.normalization_settings import build_llm_configuration, DEFAULT_LLM_PROMPT + from abogen.normalization_settings import ( + build_llm_configuration, + DEFAULT_LLM_PROMPT, + ) from abogen.llm_client import generate_completion, LLMClientError llm_config = build_llm_configuration(settings) @@ -2001,7 +2119,7 @@ def _normalize_with_llm( newline = "" if raw_line.endswith(("\r", "\n")): stripped_newline = raw_line.rstrip("\r\n") - newline = raw_line[len(stripped_newline):] + newline = raw_line[len(stripped_newline) :] line_body = stripped_newline else: line_body = raw_line @@ -2011,7 +2129,7 @@ def _normalize_with_llm( continue leading_ws = line_body[: len(line_body) - len(line_body.lstrip())] - trailing_ws = line_body[len(line_body.rstrip()):] + trailing_ws = line_body[len(line_body.rstrip()) :] core = line_body[len(leading_ws) : len(line_body) - len(trailing_ws)] sentences = _split_sentences_for_llm(core) @@ -2136,7 +2254,11 @@ def _normalize_flag_field(raw: Any) -> List[str]: seen: set[str] = set() for value in raw_iterable: candidate = str(value or "").strip().upper() - if not candidate or candidate not in _LLM_ALLOWED_REGEX_FLAGS or candidate in seen: + if ( + not candidate + or candidate not in _LLM_ALLOWED_REGEX_FLAGS + or candidate in seen + ): continue seen.add(candidate) normalized.append(candidate) @@ -2153,7 +2275,9 @@ def _apply_single_regex_replacement(text: str, spec: Mapping[str, Any]) -> str: flag_names = spec.get("flags") if isinstance(flag_names, str): flag_iterable: Iterable[Any] = [flag_names] - elif isinstance(flag_names, Iterable) and not isinstance(flag_names, (bytes, str, Mapping)): + elif isinstance(flag_names, Iterable) and not isinstance( + flag_names, (bytes, str, Mapping) + ): flag_iterable = flag_names else: flag_iterable = [] @@ -2179,7 +2303,10 @@ def normalize_for_pipeline( ) -> str: """Normalize text for the synthesis pipeline with runtime settings.""" - from abogen.normalization_settings import build_apostrophe_config, get_runtime_settings + from abogen.normalization_settings import ( + build_apostrophe_config, + get_runtime_settings, + ) from abogen.llm_client import LLMClientError runtime_settings = settings or get_runtime_settings() @@ -2201,15 +2328,25 @@ def normalize_for_pipeline( if mode == "off": normalized = normalize_unicode_apostrophes(normalized) - if cfg.convert_numbers or cfg.convert_currency or getattr(cfg, "remove_footnotes", False): + if ( + cfg.convert_numbers + or cfg.convert_currency + or getattr(cfg, "remove_footnotes", False) + ): normalized = _normalize_grouped_numbers(normalized, cfg) normalized = _cleanup_spacing(normalized) elif mode == "llm": try: - normalized = _normalize_with_llm(normalized, settings=runtime_settings, config=cfg) + normalized = _normalize_with_llm( + normalized, settings=runtime_settings, config=cfg + ) except LLMClientError: raise - if cfg.convert_numbers or cfg.convert_currency or getattr(cfg, "remove_footnotes", False): + if ( + cfg.convert_numbers + or cfg.convert_currency + or getattr(cfg, "remove_footnotes", False) + ): normalized = _normalize_grouped_numbers(normalized, cfg) normalized = _cleanup_spacing(normalized) else: @@ -2227,6 +2364,7 @@ def normalize_for_pipeline( return normalized + # ---------- Example Usage ---------- if __name__ == "__main__": @@ -2237,4 +2375,4 @@ if __name__ == "__main__": print("Original:", sample) print("Normalized:", norm_text) for orig, cat, norm in details: - print(f"{orig:15} -> {norm:15} [{cat}]") \ No newline at end of file + print(f"{orig:15} -> {norm:15} [{cat}]") diff --git a/abogen/llm_client.py b/abogen/llm_client.py index b6c075b..2937fa3 100644 --- a/abogen/llm_client.py +++ b/abogen/llm_client.py @@ -52,8 +52,10 @@ def _build_url(base_url: str, path: str) -> str: normalized = _normalized_base_url(base_url) trimmed_path = path.lstrip("/") parsed = parse.urlparse(normalized) - if parsed.path.rstrip("/").lower().endswith("/v1") and trimmed_path.startswith("v1/"): - trimmed_path = trimmed_path[len("v1/"):] + if parsed.path.rstrip("/").lower().endswith("/v1") and trimmed_path.startswith( + "v1/" + ): + trimmed_path = trimmed_path[len("v1/") :] return parse.urljoin(normalized, trimmed_path) @@ -77,7 +79,9 @@ def _perform_request( if payload is not None: data_bytes = json.dumps(payload).encode("utf-8") request_headers = dict(headers or {}) - req = request.Request(url, data=data_bytes, headers=request_headers, method=method.upper()) + req = request.Request( + url, data=data_bytes, headers=request_headers, method=method.upper() + ) try: with request.urlopen(req, timeout=timeout) as response: body = response.read() @@ -102,7 +106,9 @@ def list_models(configuration: LLMConfiguration) -> List[Dict[str, str]]: raise LLMClientError("LLM configuration is incomplete") url = _build_url(configuration.base_url, "v1/models") headers = _build_headers(configuration.api_key) - payload = _perform_request("GET", url, headers=headers, timeout=configuration.timeout) + payload = _perform_request( + "GET", url, headers=headers, timeout=configuration.timeout + ) if not isinstance(payload, Mapping): raise LLMClientError("Unexpected response when listing models") data = payload.get("data") @@ -153,7 +159,9 @@ def generate_completion( if response_format: payload["response_format"] = dict(response_format) - response = _perform_request("POST", url, headers=headers, payload=payload, timeout=configuration.timeout) + response = _perform_request( + "POST", url, headers=headers, payload=payload, timeout=configuration.timeout + ) if not isinstance(response, Mapping): raise LLMClientError("Unexpected response from LLM") choices = response.get("choices") diff --git a/abogen/normalization_settings.py b/abogen/normalization_settings.py index fcbe58e..4242265 100644 --- a/abogen/normalization_settings.py +++ b/abogen/normalization_settings.py @@ -5,7 +5,10 @@ from dataclasses import replace from functools import lru_cache from typing import Any, Dict, Mapping, Optional -from abogen.kokoro_text_normalization import ApostropheConfig, CONTRACTION_CATEGORY_DEFAULTS +from abogen.kokoro_text_normalization import ( + ApostropheConfig, + CONTRACTION_CATEGORY_DEFAULTS, +) from abogen.llm_client import LLMConfiguration from abogen.utils import load_config @@ -149,7 +152,9 @@ def _extract_settings(source: Mapping[str, Any]) -> Dict[str, Any]: elif isinstance(default, float): extracted[key] = _coerce_float(raw_value, default) else: - extracted[key] = str(raw_value or "") if isinstance(default, str) else raw_value + extracted[key] = ( + str(raw_value or "") if isinstance(default, str) else raw_value + ) _apply_llm_migrations(extracted) return extracted @@ -177,20 +182,38 @@ def build_apostrophe_config( config.convert_numbers = bool(settings.get("normalization_numbers", True)) config.convert_currency = bool(settings.get("normalization_currency", True)) config.remove_footnotes = bool(settings.get("normalization_footnotes", True)) - config.year_pronunciation_mode = str(settings.get("normalization_numbers_year_style", "american") or "").strip().lower() + config.year_pronunciation_mode = ( + str(settings.get("normalization_numbers_year_style", "american") or "") + .strip() + .lower() + ) config.add_phoneme_hints = bool(settings.get("normalization_phoneme_hints", True)) - config.contraction_mode = "expand" if settings.get("normalization_apostrophes_contractions", True) else "keep" + config.contraction_mode = ( + "expand" + if settings.get("normalization_apostrophes_contractions", True) + else "keep" + ) config.plural_possessive_mode = ( - "collapse" if settings.get("normalization_apostrophes_plural_possessives", True) else "keep" + "collapse" + if settings.get("normalization_apostrophes_plural_possessives", True) + else "keep" ) config.sibilant_possessive_mode = ( - "mark" if settings.get("normalization_apostrophes_sibilant_possessives", True) else "keep" + "mark" + if settings.get("normalization_apostrophes_sibilant_possessives", True) + else "keep" + ) + config.decades_mode = ( + "expand" if settings.get("normalization_apostrophes_decades", True) else "keep" ) - config.decades_mode = "expand" if settings.get("normalization_apostrophes_decades", True) else "keep" config.leading_elision_mode = ( - "expand" if settings.get("normalization_apostrophes_leading_elisions", True) else "keep" + "expand" + if settings.get("normalization_apostrophes_leading_elisions", True) + else "keep" + ) + config.ambiguous_past_modal_mode = ( + "contextual" if config.contraction_mode == "expand" else "keep" ) - config.ambiguous_past_modal_mode = "contextual" if config.contraction_mode == "expand" else "keep" category_flags = dict(CONTRACTION_CATEGORY_DEFAULTS) for setting_key, category in _CONTRACTION_SETTING_MAP.items(): default_value = bool(_SETTINGS_DEFAULTS.get(setting_key, True)) @@ -205,11 +228,15 @@ def build_llm_configuration(settings: Mapping[str, Any]) -> LLMConfiguration: base_url=str(settings.get("llm_base_url") or ""), api_key=str(settings.get("llm_api_key") or ""), model=str(settings.get("llm_model") or ""), - timeout=_coerce_float(settings.get("llm_timeout"), float(_SETTINGS_DEFAULTS["llm_timeout"])), + timeout=_coerce_float( + settings.get("llm_timeout"), float(_SETTINGS_DEFAULTS["llm_timeout"]) + ), ) -def apply_overrides(base: Mapping[str, Any], overrides: Mapping[str, Any]) -> Dict[str, Any]: +def apply_overrides( + base: Mapping[str, Any], overrides: Mapping[str, Any] +) -> Dict[str, Any]: merged: Dict[str, Any] = dict(base) for key, value in overrides.items(): if key not in _SETTINGS_DEFAULTS: diff --git a/abogen/pronunciation_store.py b/abogen/pronunciation_store.py index 19e7f33..5a5e7fb 100644 --- a/abogen/pronunciation_store.py +++ b/abogen/pronunciation_store.py @@ -31,7 +31,7 @@ def _migrate_legacy_sqlite(target_json_path: Path) -> None: base_dir = Path(get_user_settings_dir()) except ModuleNotFoundError: base_dir = Path(get_internal_cache_path("pronunciations")) - + sqlite_path = base_dir / "pronunciations.db" if not sqlite_path.exists(): return @@ -39,23 +39,25 @@ def _migrate_legacy_sqlite(target_json_path: Path) -> None: try: conn = sqlite3.connect(sqlite_path) conn.row_factory = sqlite3.Row - + # Check if table exists - cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='overrides'") + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='overrides'" + ) if not cursor.fetchone(): conn.close() return cursor = conn.execute("SELECT * FROM overrides") rows = cursor.fetchall() - + data = {"version": _SCHEMA_VERSION, "overrides": {}} - + for row in rows: lang = row["language"] if lang not in data["overrides"]: data["overrides"][lang] = {} - + entry = { "id": str(row["id"]), "normalized": row["normalized"], @@ -70,16 +72,16 @@ def _migrate_legacy_sqlite(target_json_path: Path) -> None: "updated_at": row["updated_at"], } data["overrides"][lang][row["normalized"]] = entry - + conn.close() - + # Save to JSON with open(target_json_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) - + # Rename old DB sqlite_path.rename(sqlite_path.with_suffix(".db.bak")) - + except Exception: pass @@ -110,11 +112,11 @@ def load_overrides(language: str, tokens: Iterable[str]) -> Dict[str, Dict[str, normalized_tokens = {normalize_token(token) for token in tokens if token} if not normalized_tokens: return {} - + with _DB_LOCK: db = _load_db() lang_overrides = db.get("overrides", {}).get(language, {}) - + results: Dict[str, Dict[str, Any]] = {} for normalized in normalized_tokens: if normalized in lang_overrides: @@ -122,22 +124,27 @@ def load_overrides(language: str, tokens: Iterable[str]) -> Dict[str, Dict[str, return results -def search_overrides(language: str, query: str, *, limit: int = 15) -> List[Dict[str, Any]]: +def search_overrides( + language: str, query: str, *, limit: int = 15 +) -> List[Dict[str, Any]]: if not query: return [] - + query = query.lower() with _DB_LOCK: db = _load_db() lang_overrides = db.get("overrides", {}).get(language, {}) - + matches = [] for entry in lang_overrides.values(): if query in entry["normalized"] or query in entry["token"].lower(): matches.append(entry) - + # Sort by usage count desc, then updated_at desc - matches.sort(key=lambda x: (x.get("usage_count", 0), x.get("updated_at", 0)), reverse=True) + matches.sort( + key=lambda x: (x.get("usage_count", 0), x.get("updated_at", 0)), + reverse=True, + ) return matches[:limit] @@ -153,15 +160,15 @@ def save_override( normalized = normalize_token(token) if not normalized: raise ValueError("Provide a token to override") - + timestamp = time.time() with _DB_LOCK: db = _load_db() overrides = db.setdefault("overrides", {}) lang_overrides = overrides.setdefault(language, {}) - + existing = lang_overrides.get(normalized) - + if existing: entry = existing entry["token"] = token @@ -185,7 +192,7 @@ def save_override( "updated_at": timestamp, } lang_overrides[normalized] = entry - + _save_db(db) return entry @@ -194,11 +201,11 @@ def delete_override(*, language: str, token: str) -> None: normalized = normalize_token(token) if not normalized: return - + with _DB_LOCK: db = _load_db() lang_overrides = db.get("overrides", {}).get(language, {}) - + if normalized in lang_overrides: del lang_overrides[normalized] _save_db(db) @@ -208,7 +215,7 @@ def all_overrides(language: str) -> List[Dict[str, Any]]: with _DB_LOCK: db = _load_db() lang_overrides = db.get("overrides", {}).get(language, {}) - + results = list(lang_overrides.values()) results.sort(key=lambda x: x.get("updated_at", 0), reverse=True) return results @@ -218,11 +225,11 @@ def increment_usage(*, language: str, token: str, amount: int = 1) -> None: normalized = normalize_token(token) if not normalized: return - + with _DB_LOCK: db = _load_db() lang_overrides = db.get("overrides", {}).get(language, {}) - + if normalized in lang_overrides: entry = lang_overrides[normalized] entry["usage_count"] = entry.get("usage_count", 0) + amount @@ -234,11 +241,13 @@ def get_override_stats(language: str) -> Dict[str, int]: with _DB_LOCK: db = _load_db() lang_overrides = db.get("overrides", {}).get(language, {}) - + total = len(lang_overrides) - with_pronunciation = sum(1 for x in lang_overrides.values() if x.get("pronunciation")) + with_pronunciation = sum( + 1 for x in lang_overrides.values() if x.get("pronunciation") + ) with_voice = sum(1 for x in lang_overrides.values() if x.get("voice")) - + return { "total": total, "filtered": total, diff --git a/abogen/spacy_contraction_resolver.py b/abogen/spacy_contraction_resolver.py index d85ca7e..7a95704 100644 --- a/abogen/spacy_contraction_resolver.py +++ b/abogen/spacy_contraction_resolver.py @@ -49,7 +49,9 @@ def _load_spacy_model(model: str = _DEFAULT_MODEL) -> Optional[Language]: return nlp -def resolve_ambiguous_contractions(text: str, *, model: Optional[str] = None) -> Dict[Tuple[int, int], ContractionResolution]: +def resolve_ambiguous_contractions( + text: str, *, model: Optional[str] = None +) -> Dict[Tuple[int, int], ContractionResolution]: """Use spaCy to disambiguate ambiguous contractions in *text*. Returns a mapping from (start, end) spans to their resolved expansion. @@ -80,7 +82,9 @@ def resolve_ambiguous_contractions(text: str, *, model: Optional[str] = None) -> return resolutions -def _resolution(prev: Token, token: Token, expansion_word: str, category: str, lemma_hint: str) -> Optional[ContractionResolution]: +def _resolution( + prev: Token, token: Token, expansion_word: str, category: str, lemma_hint: str +) -> Optional[ContractionResolution]: if token is None or prev is None: return None @@ -186,10 +190,14 @@ def _resolve_apostrophe_d(token: Token) -> Optional[ContractionResolution]: next_lemma = "" if next_tag == "VB": - return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will") + return _resolution( + prev, token, "would", "contraction_modal_would", lemma or "will" + ) if token.tag_ == "MD" or lemma in {"will", "would", "shall"}: - return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will") + return _resolution( + prev, token, "would", "contraction_modal_would", lemma or "will" + ) if next_lemma in {"been", "gone", "had", "better"} or next_tag in {"VBN", "VBD"}: return _resolution(prev, token, "had", "contraction_aux_have", "have") @@ -255,4 +263,3 @@ def _context_prefers_had(token: Token) -> bool: if next_lemma == "better": return True return False - diff --git a/abogen/spacy_utils.py b/abogen/spacy_utils.py index 54fbcf7..46d6510 100644 --- a/abogen/spacy_utils.py +++ b/abogen/spacy_utils.py @@ -83,12 +83,12 @@ def get_spacy_model(lang_code, log_callback=None): model_name, disable=["ner", "tagger", "lemmatizer", "attribute_ruler"], ) - + # Ensure a sentence segmentation strategy is in place # The parser provides sents, but if it's missing (unlikely for core models), fallback to sentencizer if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names: nlp.add_pipe("sentencizer") - + _nlp_cache[lang_code] = nlp return nlp except OSError: @@ -105,7 +105,7 @@ def get_spacy_model(lang_code, log_callback=None): ) if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names: nlp.add_pipe("sentencizer") - + _nlp_cache[lang_code] = nlp log(f"spaCy model '{model_name}' downloaded and loaded") return nlp diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py index 40a7d66..e8d0b09 100644 --- a/abogen/speaker_analysis.py +++ b/abogen/speaker_analysis.py @@ -164,10 +164,16 @@ class SpeakerGuess: sample_excerpt: Optional[str] = None, ) -> None: self.count += 1 - if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0): + if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get( + self.confidence, 0 + ): self.confidence = confidence - excerpt = sample_excerpt if sample_excerpt is not None else _build_excerpt(text, quote) + excerpt = ( + sample_excerpt + if sample_excerpt is not None + else _build_excerpt(text, quote) + ) gender_hint = _format_gender_hint(male_votes, female_votes) if excerpt: payload = {"excerpt": excerpt, "gender_hint": gender_hint} @@ -180,9 +186,13 @@ class SpeakerGuess: self.male_votes += male_votes if female_votes: self.female_votes += female_votes - self.detected_gender = _derive_gender(self.male_votes, self.female_votes, self.detected_gender) + self.detected_gender = _derive_gender( + self.male_votes, self.female_votes, self.detected_gender + ) if self.gender in {"unknown", "male", "female"}: - self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender) + self.gender = _derive_gender( + self.male_votes, self.female_votes, self.gender + ) def as_dict(self) -> Dict[str, Any]: return { @@ -211,7 +221,10 @@ class SpeakerAnalysis: "version": self.version, "narrator": self.narrator, "assignments": dict(self.assignments), - "speakers": {speaker_id: guess.as_dict() for speaker_id, guess in self.speakers.items()}, + "speakers": { + speaker_id: guess.as_dict() + for speaker_id, guess in self.speakers.items() + }, "suppressed": list(self.suppressed), "stats": dict(self.stats), } @@ -226,7 +239,9 @@ def analyze_speakers( ) -> SpeakerAnalysis: narrator_id = "narrator" speaker_guesses: Dict[str, SpeakerGuess] = { - narrator_id: SpeakerGuess(speaker_id=narrator_id, label="Narrator", confidence="low") + narrator_id: SpeakerGuess( + speaker_id=narrator_id, label="Narrator", confidence="low" + ) } label_index: Dict[str, str] = {"Narrator": narrator_id} assignments: Dict[str, str] = {} @@ -265,21 +280,31 @@ def analyze_speakers( if record_id is None: record_id = _dedupe_slug(_slugify(label), speaker_guesses) label_index[label] = record_id - speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label) + speaker_guesses[record_id] = SpeakerGuess( + speaker_id=record_id, label=label + ) guess = speaker_guesses[record_id] assignments[chunk_id] = record_id unique_speakers.add(record_id) - if record_id != narrator_id and record_id != speaker_id and speaker_id == last_explicit: + if ( + record_id != narrator_id + and record_id != speaker_id + and speaker_id == last_explicit + ): last_explicit = record_id sample_excerpt = None if record_id != narrator_id: - sample_excerpt = _select_sample_excerpt(ordered_chunks, index, guess.label, quote, confidence) + sample_excerpt = _select_sample_excerpt( + ordered_chunks, index, guess.label, quote, confidence + ) male_votes, female_votes = _count_gender_votes(text, guess.label) - guess.register_occurrence(confidence, text, quote, male_votes, female_votes, sample_excerpt) + guess.register_occurrence( + confidence, text, quote, male_votes, female_votes, sample_excerpt + ) active_speakers = [sid for sid in speaker_guesses if sid != narrator_id] # Apply minimum occurrence threshold. @@ -302,7 +327,9 @@ def analyze_speakers( active_speakers = active_speakers[:max_speakers] narrator_guess = speaker_guesses[narrator_id] - narrator_guess.count = sum(1 for value in assignments.values() if value == narrator_id) + narrator_guess.count = sum( + 1 for value in assignments.values() if value == narrator_id + ) narrator_guess.confidence = "low" stats = { @@ -322,7 +349,9 @@ def analyze_speakers( ) -def _infer_chunk_speaker(text: str, last_explicit: Optional[str]) -> Tuple[Optional[str], str, Optional[str]]: +def _infer_chunk_speaker( + text: str, last_explicit: Optional[str] +) -> Tuple[Optional[str], str, Optional[str]]: normalized = text.strip() if not normalized: return None, "low", None @@ -376,7 +405,9 @@ def _match_name_near_quote(before: str, after: str) -> Optional[str]: if _looks_like_name(name): return name - match = re.search(rf"({_NAME_PATTERN})\s*,?\s*{_VERB_PATTERN}", leading, flags=re.IGNORECASE) + match = re.search( + rf"({_NAME_PATTERN})\s*,?\s*{_VERB_PATTERN}", leading, flags=re.IGNORECASE + ) if match: name = match.group(1) if _looks_like_name(name): @@ -466,8 +497,14 @@ def _count_gender_votes(text: str, label: Optional[str]) -> Tuple[int, int]: if windows: mapped: List[Tuple[int, int]] = [] for start, end in windows: - start_idx = min(len(search_text) - 1, int(start * len(search_text) / max(len(ascii_text), 1))) - end_idx = min(len(search_text), int(end * len(search_text) / max(len(ascii_text), 1))) + start_idx = min( + len(search_text) - 1, + int(start * len(search_text) / max(len(ascii_text), 1)), + ) + end_idx = min( + len(search_text), + int(end * len(search_text) / max(len(ascii_text), 1)), + ) mapped.append((start_idx, end_idx)) windows = mapped else: @@ -485,7 +522,9 @@ def _count_gender_votes(text: str, label: Optional[str]) -> Tuple[int, int]: except IndexError: content_start, content_end = match.span() if content_start < content_end: - quote_spans.append((content_start, content_end, search_text[content_start:content_end])) + quote_spans.append( + (content_start, content_end, search_text[content_start:content_end]) + ) normalized_label = _normalize_candidate_name(label) if label else None normalized_label_lower = normalized_label.lower() if normalized_label else None @@ -511,7 +550,11 @@ def _count_gender_votes(text: str, label: Optional[str]) -> Tuple[int, int]: name_matches = list(re.finditer(_NAME_PATTERN, tail)) if name_matches: last_name = _normalize_candidate_name(name_matches[-1].group(0)) - if normalized_label_lower and last_name and last_name.lower() == normalized_label_lower: + if ( + normalized_label_lower + and last_name + and last_name.lower() == normalized_label_lower + ): return 0.6 return 0.05 if re.search(r"[.!?]\s", prefix): @@ -607,8 +650,12 @@ def _contains_dialogue_attribution(label: str, text: str, quote: Optional[str]) if not label or not text: return False escaped_label = re.escape(label) - direct_pattern = re.compile(rf"\b{escaped_label}\b\s+(?:{_VERB_PATTERN})\b", re.IGNORECASE) - reverse_pattern = re.compile(rf"(?:{_VERB_PATTERN})\s+\b{escaped_label}\b", re.IGNORECASE) + direct_pattern = re.compile( + rf"\b{escaped_label}\b\s+(?:{_VERB_PATTERN})\b", re.IGNORECASE + ) + reverse_pattern = re.compile( + rf"(?:{_VERB_PATTERN})\s+\b{escaped_label}\b", re.IGNORECASE + ) colon_pattern = re.compile(rf"^\s*{escaped_label}\s*:\s*", re.IGNORECASE) if colon_pattern.search(text): @@ -679,7 +726,7 @@ def _format_gender_hint(male_votes: int, female_votes: int) -> str: def _normalize_candidate_name(raw: str) -> Optional[str]: if not raw: return None - cleaned = raw.strip().strip('"“”\'’.,:;!') + cleaned = raw.strip().strip("\"“”'’.,:;!") cleaned = re.sub(r"\s+", " ", cleaned).strip() if not cleaned: return None @@ -712,4 +759,4 @@ def _normalize_candidate_name(raw: str) -> Optional[str]: lowered = candidate.lower() if lowered in _PRONOUN_LABELS or lowered in _STOP_LABELS: return None - return candidate \ No newline at end of file + return candidate diff --git a/abogen/speaker_configs.py b/abogen/speaker_configs.py index 283389e..04ab198 100644 --- a/abogen/speaker_configs.py +++ b/abogen/speaker_configs.py @@ -135,7 +135,11 @@ def _sanitize_speaker(entry: Dict[str, Any]) -> Dict[str, Any]: normalized_langs.append(code.lower()) resolved_voice = entry.get("resolved_voice") or voice_formula or voice resolved_label = label or entry.get("id") or "" - slug = entry.get("id") if isinstance(entry.get("id"), str) else slugify_label(resolved_label) + slug = ( + entry.get("id") + if isinstance(entry.get("id"), str) + else slugify_label(resolved_label) + ) return { "id": slug, "label": resolved_label, diff --git a/abogen/subtitle_utils.py b/abogen/subtitle_utils.py index 73576c0..5455c55 100644 --- a/abogen/subtitle_utils.py +++ b/abogen/subtitle_utils.py @@ -37,6 +37,7 @@ def clean_subtitle_text(text): text = _CHAPTER_MARKER_PATTERN.sub("", text) return text.strip() + def calculate_text_length(text): # Use pre-compiled patterns for better performance # Ignore chapter markers and metadata patterns in a single pass @@ -48,6 +49,7 @@ def calculate_text_length(text): char_count = len(text) return char_count + def clean_text(text, *args, **kwargs): # Remove metadata tags first text = _METADATA_TAG_PATTERN.sub("", text) diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py index b6e2ee9..7d6bcf3 100644 --- a/abogen/text_extractor.py +++ b/abogen/text_extractor.py @@ -110,13 +110,19 @@ def _extract_from_string(raw: str, default_title: str) -> ExtractionResult: normalized_tags = _normalize_metadata_keys(raw_metadata) chapter_count = len(chapters) artist_value = normalized_tags.get("artist") - authors = [name.strip() for name in artist_value.split(",") if name.strip()] if artist_value else [] + authors = ( + [name.strip() for name in artist_value.split(",") if name.strip()] + if artist_value + else [] + ) metadata_source = MetadataSource( title=normalized_tags.get("title") or default_title, authors=authors, publication_year=normalized_tags.get("year"), ) - metadata = _build_metadata_payload(metadata_source, chapter_count, "text", default_title) + metadata = _build_metadata_payload( + metadata_source, chapter_count, "text", default_title + ) metadata.update(normalized_tags) if not chapters: chapters = [ExtractedChapter(title=default_title, text="")] @@ -148,9 +154,11 @@ def _split_chapters(content: str, default_title: str) -> List[ExtractedChapter]: current_title = default_title for match in matches: - segment = content[last_index:match.start()] + segment = content[last_index : match.start()] if segment.strip(): - chapters.append(ExtractedChapter(title=current_title, text=clean_text(segment))) + chapters.append( + ExtractedChapter(title=current_title, text=clean_text(segment)) + ) current_title = match.group(1).strip() or default_title last_index = match.end() @@ -271,19 +279,27 @@ def _extract_markdown(path: Path) -> ExtractionResult: raw = path.read_text(encoding=encoding, errors="replace") metadata_source, chapters = _parse_markdown(raw, path.stem) if not chapters: - chapters = [ExtractedChapter(title=metadata_source.title or path.stem, text=clean_text(raw))] - metadata = _build_metadata_payload(metadata_source, len(chapters), "markdown", path.stem) + chapters = [ + ExtractedChapter( + title=metadata_source.title or path.stem, text=clean_text(raw) + ) + ] + metadata = _build_metadata_payload( + metadata_source, len(chapters), "markdown", path.stem + ) return ExtractionResult(chapters=chapters, metadata=metadata) -def _parse_markdown(raw: str, default_title: str) -> Tuple[MetadataSource, List[ExtractedChapter]]: +def _parse_markdown( + raw: str, default_title: str +) -> Tuple[MetadataSource, List[ExtractedChapter]]: metadata = MetadataSource() text = textwrap.dedent(raw) frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL) if frontmatter_match: frontmatter = frontmatter_match.group(1) _parse_markdown_frontmatter(frontmatter, metadata) - text_body = text[frontmatter_match.end():] + text_body = text[frontmatter_match.end() :] else: text_body = text @@ -324,7 +340,11 @@ def _parse_markdown(raw: str, default_title: str) -> Tuple[MetadataSource, List[ chapters: List[ExtractedChapter] = [] for index, (header_id, start, name) in enumerate(header_positions): - end = header_positions[index + 1][1] if index + 1 < len(header_positions) else len(html) + end = ( + header_positions[index + 1][1] + if index + 1 < len(header_positions) + else len(html) + ) section_html = html[start:end] section_soup = BeautifulSoup(section_html, "html.parser") header_tag = section_soup.find(attrs={"id": header_id}) @@ -336,7 +356,14 @@ def _parse_markdown(raw: str, default_title: str) -> Tuple[MetadataSource, List[ chapters.append(ExtractedChapter(title=name.strip(), text=section_text)) if not metadata.title: - first_h1 = next((header for header in headers if header.get("level") == 1 and header.get("name")), None) + first_h1 = next( + ( + header + for header in headers + if header.get("level") == 1 and header.get("name") + ), + None, + ) if first_h1: metadata.title = str(first_h1["name"]) @@ -344,21 +371,27 @@ def _parse_markdown(raw: str, default_title: str) -> Tuple[MetadataSource, List[ def _parse_markdown_frontmatter(frontmatter: str, metadata: MetadataSource) -> None: - title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + title_match = re.search( + r"^title:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE + ) if title_match: - metadata.title = title_match.group(1).strip().strip('"\'') + metadata.title = title_match.group(1).strip().strip("\"'") - author_match = re.search(r"^author:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + author_match = re.search( + r"^author:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE + ) if author_match: - metadata.authors = [author_match.group(1).strip().strip('"\'')] + metadata.authors = [author_match.group(1).strip().strip("\"'")] - desc_match = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + desc_match = re.search( + r"^description:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE + ) if desc_match: - metadata.description = desc_match.group(1).strip().strip('"\'') + metadata.description = desc_match.group(1).strip().strip("\"'") date_match = re.search(r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) if date_match: - date_str = date_match.group(1).strip().strip('\"\'') + date_str = date_match.group(1).strip().strip("\"'") year_match = re.search(r"\b(19|20)\d{2}\b", date_str) if year_match: metadata.publication_year = year_match.group(0) @@ -390,7 +423,9 @@ class EpubExtractor: chapters = self._process_spine_fallback() if not chapters: chapters = [ExtractedChapter(title=self.path.stem, text="")] - metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem) + metadata = _build_metadata_payload( + metadata_source, len(chapters), "epub", self.path.stem + ) metadata.setdefault("chapter_count", str(len(chapters))) if metadata_source.series: series_text = str(metadata_source.series).strip() @@ -424,7 +459,9 @@ class EpubExtractor: try: author_items = self.book.get_metadata("DC", "creator") if author_items: - metadata.authors = [author[0] for author in author_items if author and author[0]] + metadata.authors = [ + author[0] for author in author_items if author and author[0] + ] except Exception as exc: logger.debug("Failed to extract EPUB author metadata: %s", exc) @@ -447,7 +484,9 @@ class EpubExtractor: if date_items: date_str = date_items[0][0] year_match = re.search(r"\b(19|20)\d{2}\b", date_str) - metadata.publication_year = year_match.group(0) if year_match else date_str + metadata.publication_year = ( + year_match.group(0) if year_match else date_str + ) except Exception as exc: logger.debug("Failed to extract EPUB publication year metadata: %s", exc) @@ -482,7 +521,16 @@ class EpubExtractor: if name in {"calibre:series", "series"} and series_name is None: series_name = candidate_text continue - if name in {"calibre:series_index", "calibre:seriesindex", "series_index", "seriesindex"} and series_index is None: + if ( + name + in { + "calibre:series_index", + "calibre:seriesindex", + "series_index", + "seriesindex", + } + and series_index is None + ): series_index = candidate_text continue @@ -533,7 +581,9 @@ class EpubExtractor: self.spine_docs = self._build_spine_docs() doc_order = {href: index for index, href in enumerate(self.spine_docs)} - doc_order_decoded = {urllib.parse.unquote(href): index for href, index in doc_order.items()} + doc_order_decoded = { + urllib.parse.unquote(href): index for href, index in doc_order.items() + } nav_targets = self._collect_nav_targets(nav_soup, nav_type) self._cache_relevant_documents(doc_order, nav_targets) @@ -544,7 +594,9 @@ class EpubExtractor: if not nav_map: raise ValueError("NCX navigation missing ") for nav_point in nav_map.find_all("navPoint", recursive=False): - self._parse_ncx_navpoint(nav_point, ordered_entries, doc_order, doc_order_decoded) + self._parse_ncx_navpoint( + nav_point, ordered_entries, doc_order, doc_order_decoded + ) else: toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"}) if toc_nav is None: @@ -558,7 +610,9 @@ class EpubExtractor: if top_ol is None: raise ValueError("TOC navigation missing
      ") for li in top_ol.find_all("li", recursive=False): - self._parse_html_nav_li(li, ordered_entries, doc_order, doc_order_decoded) + self._parse_html_nav_li( + li, ordered_entries, doc_order, doc_order_decoded + ) if not ordered_entries: raise ValueError("No navigation entries found") @@ -591,7 +645,9 @@ class EpubExtractor: text = self._html_to_text(html_content) if not text: continue - title = self._resolve_document_title(html_content, fallback=f"Untitled Chapter {index + 1}") + title = self._resolve_document_title( + html_content, fallback=f"Untitled Chapter {index + 1}" + ) chapters.append(ExtractedChapter(title=title, text=text)) return chapters @@ -605,7 +661,8 @@ class EpubExtractor: ( item for item in nav_items - if "nav" in item.get_name().lower() and item.get_name().lower().endswith((".xhtml", ".html")) + if "nav" in item.get_name().lower() + and item.get_name().lower().endswith((".xhtml", ".html")) ), None, ) @@ -627,7 +684,11 @@ class EpubExtractor: if not nav_item and nav_items: ncx_candidate = next( - (item for item in nav_items if item.get_name().lower().endswith(".ncx")), + ( + item + for item in nav_items + if item.get_name().lower().endswith(".ncx") + ), None, ) if ncx_candidate: @@ -682,7 +743,9 @@ class EpubExtractor: targets.append(href_value.split("#", 1)[0]) return targets - def _cache_relevant_documents(self, doc_order: Dict[str, int], nav_targets: List[str]) -> None: + def _cache_relevant_documents( + self, doc_order: Dict[str, int], nav_targets: List[str] + ) -> None: needed: set[str] = set(doc_order.keys()) for target in nav_targets: needed.add(target) @@ -718,7 +781,9 @@ class EpubExtractor: if src: base_href, fragment = src.split("#", 1) if "#" in src else (src, None) - doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + doc_key, doc_idx = self._find_doc_key( + base_href, doc_order, doc_order_decoded + ) if doc_key is not None and doc_idx is not None: position = self._find_position_robust(doc_key, fragment) ordered_entries.append( @@ -738,7 +803,9 @@ class EpubExtractor: ) for child_navpoint in nav_point.find_all("navPoint", recursive=False): - self._parse_ncx_navpoint(child_navpoint, ordered_entries, doc_order, doc_order_decoded) + self._parse_ncx_navpoint( + child_navpoint, ordered_entries, doc_order, doc_order_decoded + ) def _parse_html_nav_li( self, @@ -767,7 +834,9 @@ class EpubExtractor: if src: base_href, fragment = src.split("#", 1) if "#" in src else (src, None) - doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + doc_key, doc_idx = self._find_doc_key( + base_href, doc_order, doc_order_decoded + ) if doc_key is not None and doc_idx is not None: position = self._find_position_robust(doc_key, fragment) ordered_entries.append( @@ -788,7 +857,9 @@ class EpubExtractor: for child_ol in li_element.find_all("ol", recursive=False): for child_li in child_ol.find_all("li", recursive=False): - self._parse_html_nav_li(child_li, ordered_entries, doc_order, doc_order_decoded) + self._parse_html_nav_li( + child_li, ordered_entries, doc_order, doc_order_decoded + ) def _find_doc_key( self, @@ -825,7 +896,9 @@ class EpubExtractor: if pos != -1: return pos except Exception: - logger.debug("BeautifulSoup failed to locate id '%s' in %s", fragment_id, doc_href) + logger.debug( + "BeautifulSoup failed to locate id '%s' in %s", fragment_id, doc_href + ) safe_fragment_id = re.escape(fragment_id) id_name_pattern = re.compile( @@ -844,13 +917,17 @@ class EpubExtractor: tag_start = html_content.rfind("<", 0, pos) return tag_start if tag_start != -1 else pos - logger.warning("Anchor '%s' not found in %s. Defaulting to start.", fragment_id, doc_href) + logger.warning( + "Anchor '%s' not found in %s. Defaulting to start.", fragment_id, doc_href + ) return 0 def _slice_entries(self, ordered_entries: List[NavEntry]) -> List[ExtractedChapter]: chapters: List[ExtractedChapter] = [] for index, entry in enumerate(ordered_entries): - next_entry = ordered_entries[index + 1] if index + 1 < len(ordered_entries) else None + next_entry = ( + ordered_entries[index + 1] if index + 1 < len(ordered_entries) else None + ) slice_html = self._slice_entry(entry, next_entry) text = self._html_to_text(slice_html) if not text: diff --git a/abogen/tts_supertonic.py b/abogen/tts_supertonic.py index 68b7e69..ed37c81 100644 --- a/abogen/tts_supertonic.py +++ b/abogen/tts_supertonic.py @@ -47,7 +47,9 @@ def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndar return np.interp(x_new, x_old, audio).astype("float32", copy=False) -def _split_text(text: str, *, split_pattern: Optional[str], max_chunk_length: int) -> list[str]: +def _split_text( + text: str, *, split_pattern: Optional[str], max_chunk_length: int +) -> list[str]: stripped = (text or "").strip() if not stripped: return [] @@ -81,13 +83,17 @@ def _split_text(text: str, *, split_pattern: Optional[str], max_chunk_length: in return result -_UNSUPPORTED_CHARS_RE = re.compile(r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE) +_UNSUPPORTED_CHARS_RE = re.compile( + r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE +) def _parse_unsupported_characters(error: BaseException) -> list[str]: """Best-effort extraction of unsupported characters from SuperTonic errors.""" - message = " ".join(str(part) for part in getattr(error, "args", ()) if part is not None) or str(error) + message = " ".join( + str(part) for part in getattr(error, "args", ()) if part is not None + ) or str(error) match = _UNSUPPORTED_CHARS_RE.search(message) if not match: return [] @@ -127,8 +133,9 @@ def _configure_supertonic_gpu() -> None: """Patch supertonic's config to enable GPU acceleration if available.""" try: import onnxruntime as ort + available = ort.get_available_providers() - + # Use CUDA if available, skip TensorRT (requires extra libs not always present) # TensorrtExecutionProvider may be listed as available but fail at runtime # if TensorRT libraries (libnvinfer.so) are not installed @@ -136,11 +143,12 @@ def _configure_supertonic_gpu() -> None: if "CUDAExecutionProvider" in available: providers.append("CUDAExecutionProvider") providers.append("CPUExecutionProvider") - + # Patch supertonic's config and loader before TTS import # We must patch both because loader imports the value at module load time import supertonic.config as supertonic_config import supertonic.loader as supertonic_loader + supertonic_config.DEFAULT_ONNX_PROVIDERS = providers supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers logger.info("Supertonic ONNX providers configured: %s", providers) @@ -191,7 +199,9 @@ class SupertonicPipeline: speed_value = max(0.7, min(2.0, speed_value)) style = self._tts.get_voice_style(voice_name=voice_name) - chunks = _split_text(text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length) + chunks = _split_text( + text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length + ) for chunk in chunks: chunk_to_speak = chunk removed: set[str] = set() @@ -217,7 +227,9 @@ class SupertonicPipeline: raise removed.update(unsupported) - sanitized = _remove_unsupported_characters(chunk_to_speak, unsupported).strip() + sanitized = _remove_unsupported_characters( + chunk_to_speak, unsupported + ).strip() # If we didn't change anything, don't loop forever. if sanitized == chunk_to_speak.strip(): diff --git a/abogen/utils.py b/abogen/utils.py index e984157..156e9f8 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -14,6 +14,7 @@ from functools import lru_cache from dotenv import load_dotenv, find_dotenv + def _load_environment() -> None: explicit_path = os.environ.get("ABOGEN_ENV_FILE") if explicit_path: @@ -56,6 +57,7 @@ def detect_encoding(file_path): encoding = detected_encoding if detected_encoding else "utf-8" return encoding.lower() + def get_resource_path(package, resource): """ Get the path to a resource file, with fallback to local file system. @@ -146,7 +148,9 @@ def get_user_settings_dir(): if os.path.exists(legacy_dir): return ensure_directory(legacy_dir) - config_dir = user_config_dir("abogen", appauthor=False, roaming=True, ensure_exists=True) + config_dir = user_config_dir( + "abogen", appauthor=False, roaming=True, ensure_exists=True + ) return ensure_directory(config_dir) @@ -250,7 +254,9 @@ def get_user_cache_root(): def get_internal_cache_root(): - root = os.environ.get("ABOGEN_INTERNAL_CACHE_ROOT") or os.environ.get("XDG_CACHE_HOME") + root = os.environ.get("ABOGEN_INTERNAL_CACHE_ROOT") or os.environ.get( + "XDG_CACHE_HOME" + ) if root: return ensure_directory(root) home_dir = os.environ.get("HOME") or os.path.join("/tmp", "abogen-home") @@ -274,9 +280,8 @@ def get_user_cache_path(folder=None): @lru_cache(maxsize=1) def get_user_output_root(): - override = ( - os.environ.get("ABOGEN_OUTPUT_DIR") - or os.environ.get("ABOGEN_OUTPUT_ROOT") + override = os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get( + "ABOGEN_OUTPUT_ROOT" ) if override: return ensure_directory(override) @@ -290,7 +295,10 @@ def get_user_output_path(folder=None): return base -_sleep_procs: Dict[str, Optional[subprocess.Popen[str]]] = {"Darwin": None, "Linux": None} # Store sleep prevention processes +_sleep_procs: Dict[str, Optional[subprocess.Popen[str]]] = { + "Darwin": None, + "Linux": None, +} # Store sleep prevention processes def clean_text(text, *args, **kwargs): @@ -328,7 +336,9 @@ def create_process(cmd, stdin=None, text=True, capture_output=False): # Determine shell usage: use shell only for string commands use_shell = isinstance(cmd, str) if use_shell: - logger.warning("Security Warning: create_process called with string command. Prefer using a list of arguments to avoid shell injection risks.") + logger.warning( + "Security Warning: create_process called with string command. Prefer using a list of arguments to avoid shell injection risks." + ) kwargs = { "shell": use_shell, @@ -439,18 +449,18 @@ def get_gpu_acceleration(enabled): if not enabled: return "GPU available but using CPU.", False - + # Check for Apple Silicon MPS if platform.system() == "Darwin" and platform.processor() == "arm": if torch.backends.mps.is_available(): return "MPS GPU available and enabled.", True else: return "MPS GPU not available on Apple Silicon. Using CPU.", False - + # Check for CUDA if cuda_available(): return "CUDA GPU available and enabled.", True - + # Gather CUDA diagnostic info if not available try: cuda_devices = torch.cuda.device_count() diff --git a/abogen/voice_cache.py b/abogen/voice_cache.py index df1662f..fd8e4df 100644 --- a/abogen/voice_cache.py +++ b/abogen/voice_cache.py @@ -12,9 +12,11 @@ except Exception: # pragma: no cover - import fallback LocalEntryNotFoundError = None # type: ignore[assignment] if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests + class LocalEntryNotFoundError(Exception): pass + from abogen.constants import VOICES_INTERNAL _CACHE_LOCK = threading.Lock() @@ -140,4 +142,4 @@ def _ensure_single_voice_asset( pass hf_hub_download(resume_download=True, **common_kwargs) - return True \ No newline at end of file + return True diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py index dede121..608b943 100644 --- a/abogen/voice_profiles.py +++ b/abogen/voice_profiles.py @@ -110,9 +110,17 @@ def normalize_profile_entry(entry: Any) -> Dict[str, Any]: return { "provider": "supertonic", "language": language, - "voice": _normalize_supertonic_voice(entry.get("voice") or entry.get("voice_name") or entry.get("name")), - "total_steps": _coerce_supertonic_steps(entry.get("total_steps") or entry.get("supertonic_total_steps") or entry.get("quality")), - "speed": _coerce_supertonic_speed(entry.get("speed") or entry.get("supertonic_speed")), + "voice": _normalize_supertonic_voice( + entry.get("voice") or entry.get("voice_name") or entry.get("name") + ), + "total_steps": _coerce_supertonic_steps( + entry.get("total_steps") + or entry.get("supertonic_total_steps") + or entry.get("quality") + ), + "speed": _coerce_supertonic_speed( + entry.get("speed") or entry.get("supertonic_speed") + ), } voices = _normalize_voice_entries(entry.get("voices", [])) diff --git a/build_pypi.py b/build_pypi.py index 076e538..9203498 100644 --- a/build_pypi.py +++ b/build_pypi.py @@ -27,10 +27,11 @@ def main(): version = None if version: print(f"🔖 Package version: {version}") - + # Check if build module is installed, install if not # Temporarily remove script_dir from sys.path to avoid importing local build.py import sys + original_path = sys.path[:] try: sys.path = [p for p in sys.path if os.path.abspath(p) != script_dir] diff --git a/tests/__init__.py b/tests/__init__.py index c6c023a..1b2f809 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -10,7 +10,9 @@ import sys from types import ModuleType -def _soundfile_write_stub(file_obj, data, samplerate, format="WAV", **_kwargs): # pragma: no cover - stub +def _soundfile_write_stub( + file_obj, data, samplerate, format="WAV", **_kwargs +): # pragma: no cover - stub """Minimal stand-in for soundfile.write used in tests. The real library streams waveform data to disk. Our tests don't exercise diff --git a/tests/test_audiobookshelf_client.py b/tests/test_audiobookshelf_client.py index 1491829..12678e4 100644 --- a/tests/test_audiobookshelf_client.py +++ b/tests/test_audiobookshelf_client.py @@ -2,7 +2,10 @@ from __future__ import annotations import json -from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig +from abogen.integrations.audiobookshelf import ( + AudiobookshelfClient, + AudiobookshelfConfig, +) def test_upload_fields_include_series_sequence(tmp_path): diff --git a/tests/test_book_handler_regression.py b/tests/test_book_handler_regression.py index e8420e5..a2fd87d 100644 --- a/tests/test_book_handler_regression.py +++ b/tests/test_book_handler_regression.py @@ -6,7 +6,7 @@ import time from PyQt6.QtWidgets import QApplication # Ensure we can import the module -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from abogen.book_handler import HandlerDialog from ebooklib import epub @@ -14,6 +14,7 @@ from ebooklib import epub # We need a QApplication instance for QWriter/QDialog app = QApplication(sys.argv) + class TestBookHandlerRegression(unittest.TestCase): def setUp(self): @@ -48,26 +49,29 @@ class TestBookHandlerRegression(unittest.TestCase): # HandlerDialog starts processing in a background thread in __init__ # We assume headless environment, so we won't show it. # But we need to wait for the thread to finish. - + dialog = HandlerDialog(self.sample_epub_path) - + # Wait for thread to finish # The dialog emits no signal publicly, but we can check internal state or thread - + start_time = time.time() while time.time() - start_time < 5: # HandlerDialog logic: # _loader_thread.finished connect to _on_load_finished # _on_load_finished populates content_texts and content_lengths - + # We can check if content_texts is populated if dialog.content_texts: break - app.processEvents() # Process Qt events to let thread signals propagate + app.processEvents() # Process Qt events to let thread signals propagate time.sleep(0.1) - - self.assertTrue(len(dialog.content_texts) > 0, "HandlerDialog failed to process content in time") - + + self.assertTrue( + len(dialog.content_texts) > 0, + "HandlerDialog failed to process content in time", + ) + # Validate content similar to what we expect # intro.xhtml should be there found_intro = False @@ -80,5 +84,6 @@ class TestBookHandlerRegression(unittest.TestCase): # Cleanup dialog.close() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_book_parser.py b/tests/test_book_parser.py index e8172ea..cff9d44 100644 --- a/tests/test_book_parser.py +++ b/tests/test_book_parser.py @@ -6,10 +6,11 @@ import fitz # PyMuPDF from ebooklib import epub # Ensure we can import the module -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from abogen.book_parser import get_book_parser, PdfParser, EpubParser, MarkdownParser + class TestBookParser(unittest.TestCase): def setUp(self): @@ -32,18 +33,18 @@ class TestBookParser(unittest.TestCase): def _create_sample_pdf(self): doc = fitz.open() - + # Page 1 page1 = doc.new_page() page1.insert_text((50, 50), "Page 1 content") # Add pattern to be cleaned - page1.insert_text((50, 100), "[12]") - page1.insert_text((50, 200), "1") # Page number at bottom - + page1.insert_text((50, 100), "[12]") + page1.insert_text((50, 200), "1") # Page number at bottom + # Page 2 page2 = doc.new_page() page2.insert_text((50, 50), "Page 2 content") - + doc.save(self.sample_pdf_path) doc.close() @@ -65,7 +66,7 @@ class TestBookParser(unittest.TestCase): # Basic spine and nav book.spine = ["nav", c1, c2] - + # Add NCX and NAV for compatibility book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNav()) @@ -93,11 +94,11 @@ class TestBookParser(unittest.TestCase): # 1. Copy sample epub to something.pdf wrong_ext_path = os.path.join(self.test_dir, "actually_epub.pdf") shutil.copy(self.sample_epub_path, wrong_ext_path) - + # 2. Open it telling parser it IS epub parser = get_book_parser(wrong_ext_path, file_type="epub") self.assertIsInstance(parser, EpubParser) - + # Should load successfully parser.load() self.assertTrue(parser.book is not None) @@ -109,7 +110,7 @@ class TestBookParser(unittest.TestCase): self.assertIn("page_1", parser.content_texts) self.assertIn("page_2", parser.content_texts) - + text1 = parser.content_texts["page_1"] self.assertIn("Page 1 content", text1) self.assertNotIn("[12]", text1) @@ -118,7 +119,7 @@ class TestBookParser(unittest.TestCase): """Test MarkdownParser splitting logic.""" parser = get_book_parser(self.sample_md_path) parser.process_content() - + # Should have Chapter 1 and Chapter 2 keys (actual keys depend on ID generation) # Markdown extensions might slugify IDs: "chapter-1" self.assertIn("chapter-1", parser.content_texts) @@ -130,7 +131,7 @@ class TestBookParser(unittest.TestCase): """Test EpubParser processing.""" parser = get_book_parser(self.sample_epub_path) parser.process_content() - + self.assertIn("intro.xhtml", parser.content_texts) self.assertIn("chap1.xhtml", parser.content_texts) self.assertIn("Welcome to the book", parser.content_texts["intro.xhtml"]) @@ -140,7 +141,7 @@ class TestBookParser(unittest.TestCase): parser = get_book_parser(self.sample_epub_path) # Processing content triggers metadata extraction in current implementation parser.process_content() - + metadata = parser.get_metadata() self.assertEqual(metadata.get("title"), "Sample Book") self.assertEqual(metadata.get("author"), "Test Author") @@ -149,23 +150,23 @@ class TestBookParser(unittest.TestCase): """Test
        handling in EpubParser.""" parser = get_book_parser(self.sample_epub_path) parser.process_content() - + text = parser.content_texts.get("chap1.xhtml", "") self.assertIn("1) Item One", text) self.assertIn("2) Item Two", text) def test_find_position_robust_logic(self): """Unit test for _find_position_robust on EpubParser.""" - parser = EpubParser(self.sample_epub_path) # Instantiate directly - + parser = EpubParser(self.sample_epub_path) # Instantiate directly + html = '

        Start

        Heading

        End

        ' parser.doc_content["dummy.html"] = html - + # Test finding ID pos = parser._find_position_robust("dummy.html", "target") self.assertGreater(pos, 0) self.assertTrue(html[pos:].startswith('

        >", text) self.assertIn("Some text", text) @@ -206,5 +207,6 @@ class TestBookParser(unittest.TestCase): md_parser = MarkdownParser(self.sample_md_path) self.assertEqual(md_parser.file_type, "markdown") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_calibre_opds.py b/tests/test_calibre_opds.py index 82f6bf4..e19bf07 100644 --- a/tests/test_calibre_opds.py +++ b/tests/test_calibre_opds.py @@ -1,4 +1,10 @@ -from abogen.integrations.calibre_opds import CalibreOPDSClient, OPDSEntry, OPDSFeed, OPDSLink, feed_to_dict +from abogen.integrations.calibre_opds import ( + CalibreOPDSClient, + OPDSEntry, + OPDSFeed, + OPDSLink, + feed_to_dict, +) def test_calibre_opds_feed_exposes_series_metadata() -> None: @@ -158,25 +164,33 @@ This is the detailed summary text. def test_calibre_opds_relative_urls_keep_catalog_prefix() -> None: - client = CalibreOPDSClient("http://example.com/opds/") + client = CalibreOPDSClient("http://example.com/opds/") - assert client._make_url("search") == "http://example.com/opds/search" - assert client._make_url("books/sample.epub") == "http://example.com/opds/books/sample.epub" - assert client._make_url("/cover/1") == "http://example.com/cover/1" - assert client._make_url("?page=2") == "http://example.com/opds?page=2" + assert client._make_url("search") == "http://example.com/opds/search" + assert ( + client._make_url("books/sample.epub") + == "http://example.com/opds/books/sample.epub" + ) + assert client._make_url("/cover/1") == "http://example.com/cover/1" + assert client._make_url("?page=2") == "http://example.com/opds?page=2" def test_calibre_opds_base_url_without_trailing_slash() -> None: - """Ensure the client works with base URLs that don't have trailing slashes.""" - client = CalibreOPDSClient("http://example.com/api/v1/opds") + """Ensure the client works with base URLs that don't have trailing slashes.""" + client = CalibreOPDSClient("http://example.com/api/v1/opds") - # Base URL should be stored without trailing slash - assert client._base_url == "http://example.com/api/v1/opds" - # Relative paths should resolve as siblings to the base URL - assert client._make_url("catalog") == "http://example.com/api/v1/opds/catalog" - assert client._make_url("search?q=test") == "http://example.com/api/v1/opds/search?q=test" - assert client._make_url("/api/v1/opds/books") == "http://example.com/api/v1/opds/books" - assert client._make_url("?page=2") == "http://example.com/api/v1/opds?page=2" + # Base URL should be stored without trailing slash + assert client._base_url == "http://example.com/api/v1/opds" + # Relative paths should resolve as siblings to the base URL + assert client._make_url("catalog") == "http://example.com/api/v1/opds/catalog" + assert ( + client._make_url("search?q=test") + == "http://example.com/api/v1/opds/search?q=test" + ) + assert ( + client._make_url("/api/v1/opds/books") == "http://example.com/api/v1/opds/books" + ) + assert client._make_url("?page=2") == "http://example.com/api/v1/opds?page=2" def test_calibre_opds_filters_out_unsupported_formats() -> None: @@ -253,365 +267,382 @@ def test_calibre_opds_navigation_entries_without_download_are_preserved() -> Non def test_calibre_opds_search_filters_by_title_and_author() -> None: - client = CalibreOPDSClient("http://example.com/catalog") - feed = OPDSFeed( - id="catalog", - title="Catalog", - entries=[ - OPDSEntry(id="1", title="The Long Journey", authors=["Alice Smith"]), - OPDSEntry(id="2", title="Hidden Worlds", authors=["Bob Johnson"]), - OPDSEntry(id="3", title="Side Stories", authors=["Cara Nguyen"], series="Journey Tales"), - ], - ) + client = CalibreOPDSClient("http://example.com/catalog") + feed = OPDSFeed( + id="catalog", + title="Catalog", + entries=[ + OPDSEntry(id="1", title="The Long Journey", authors=["Alice Smith"]), + OPDSEntry(id="2", title="Hidden Worlds", authors=["Bob Johnson"]), + OPDSEntry( + id="3", + title="Side Stories", + authors=["Cara Nguyen"], + series="Journey Tales", + ), + ], + ) - filtered = client._filter_feed_entries(feed, "journey alice") - assert [entry.id for entry in filtered.entries] == ["1"] + filtered = client._filter_feed_entries(feed, "journey alice") + assert [entry.id for entry in filtered.entries] == ["1"] - filtered = client._filter_feed_entries(feed, "bob") - assert [entry.id for entry in filtered.entries] == ["2"] + filtered = client._filter_feed_entries(feed, "bob") + assert [entry.id for entry in filtered.entries] == ["2"] - filtered = client._filter_feed_entries(feed, "journey tales") - assert [entry.id for entry in filtered.entries] == ["3"] + filtered = client._filter_feed_entries(feed, "journey tales") + assert [entry.id for entry in filtered.entries] == ["3"] - filtered = client._filter_feed_entries(feed, "missing") - assert filtered.entries == [] + filtered = client._filter_feed_entries(feed, "missing") + assert filtered.entries == [] def test_calibre_opds_local_search_follows_next(monkeypatch) -> None: - client = CalibreOPDSClient("http://example.com/catalog") - page_one = OPDSFeed( - id="catalog", - title="Catalog", - entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])], - links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, - ) - page_two = OPDSFeed( - id="catalog", - title="Catalog", - entries=[OPDSEntry(id="2", title="The Journey Continues", authors=["Bob Johnson"])], - links={}, - ) + client = CalibreOPDSClient("http://example.com/catalog") + page_one = OPDSFeed( + id="catalog", + title="Catalog", + entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])], + links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, + ) + page_two = OPDSFeed( + id="catalog", + title="Catalog", + entries=[ + OPDSEntry(id="2", title="The Journey Continues", authors=["Bob Johnson"]) + ], + links={}, + ) - def fake_fetch(href=None, params=None): - if href == "http://example.com/catalog?page=2": - return page_two - return page_one + def fake_fetch(href=None, params=None): + if href == "http://example.com/catalog?page=2": + return page_two + return page_one - monkeypatch.setattr(client, "fetch_feed", fake_fetch) + monkeypatch.setattr(client, "fetch_feed", fake_fetch) - result = client._local_search("journey", seed_feed=page_one) - assert [entry.id for entry in result.entries] == ["2"] + result = client._local_search("journey", seed_feed=page_one) + assert [entry.id for entry in result.entries] == ["2"] def test_calibre_opds_local_search_traverses_navigation(monkeypatch) -> None: - client = CalibreOPDSClient("http://example.com/catalog") - root_feed = OPDSFeed( - id="catalog", - title="Catalog", - entries=[ - OPDSEntry( - id="nav-authors", - title="Browse Authors", - links=[ - OPDSLink( - href="http://example.com/catalog/authors", - rel="http://opds-spec.org/navigation", - type="application/atom+xml;profile=opds-catalog", - ) + client = CalibreOPDSClient("http://example.com/catalog") + root_feed = OPDSFeed( + id="catalog", + title="Catalog", + entries=[ + OPDSEntry( + id="nav-authors", + title="Browse Authors", + links=[ + OPDSLink( + href="http://example.com/catalog/authors", + rel="http://opds-spec.org/navigation", + type="application/atom+xml;profile=opds-catalog", + ) + ], + ) ], - ) - ], - links={}, - ) - authors_feed = OPDSFeed( - id="authors", - title="Authors", - entries=[ - OPDSEntry(id="book-42", title="The Count of Monte Cristo", authors=["Alexandre Dumas"]) - ], - links={}, - ) + links={}, + ) + authors_feed = OPDSFeed( + id="authors", + title="Authors", + entries=[ + OPDSEntry( + id="book-42", + title="The Count of Monte Cristo", + authors=["Alexandre Dumas"], + ) + ], + links={}, + ) - def fake_fetch(href=None, params=None): - if href == "http://example.com/catalog/authors": - return authors_feed - return root_feed + def fake_fetch(href=None, params=None): + if href == "http://example.com/catalog/authors": + return authors_feed + return root_feed - monkeypatch.setattr(client, "fetch_feed", fake_fetch) + monkeypatch.setattr(client, "fetch_feed", fake_fetch) - result = client._local_search("monte cristo", seed_feed=root_feed) - assert [entry.id for entry in result.entries] == ["book-42"] + result = client._local_search("monte cristo", seed_feed=root_feed) + assert [entry.id for entry in result.entries] == ["book-42"] def test_calibre_opds_search_falls_back_to_local_search(monkeypatch) -> None: - client = CalibreOPDSClient("http://example.com/catalog") - search_page = OPDSFeed( - id="catalog", - title="Catalog", - entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])], - links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, - ) - next_page = OPDSFeed( - id="catalog", - title="Catalog", - entries=[OPDSEntry(id="2", title="Journey in Space", authors=["Cara Nguyen"])], - links={}, - ) + client = CalibreOPDSClient("http://example.com/catalog") + search_page = OPDSFeed( + id="catalog", + title="Catalog", + entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])], + links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, + ) + next_page = OPDSFeed( + id="catalog", + title="Catalog", + entries=[OPDSEntry(id="2", title="Journey in Space", authors=["Cara Nguyen"])], + links={}, + ) - def fake_fetch(path=None, params=None): - if path == "search": - return search_page - if path == "http://example.com/catalog?page=2": - return next_page - return search_page + def fake_fetch(path=None, params=None): + if path == "search": + return search_page + if path == "http://example.com/catalog?page=2": + return next_page + return search_page - monkeypatch.setattr(client, "fetch_feed", fake_fetch) + monkeypatch.setattr(client, "fetch_feed", fake_fetch) - result = client.search("journey") - assert [entry.id for entry in result.entries] == ["2"] + result = client.search("journey") + assert [entry.id for entry in result.entries] == ["2"] def test_calibre_opds_search_collects_next_page_results(monkeypatch) -> None: - client = CalibreOPDSClient("http://example.com/catalog") - first_page = OPDSFeed( - id="catalog", - title="Catalog", - entries=[OPDSEntry(id="1", title="Ryan's Adventure")], - links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, - ) - second_page = OPDSFeed( - id="catalog", - title="Catalog", - entries=[OPDSEntry(id="2", title="Return of Ryan")], - links={}, - ) + client = CalibreOPDSClient("http://example.com/catalog") + first_page = OPDSFeed( + id="catalog", + title="Catalog", + entries=[OPDSEntry(id="1", title="Ryan's Adventure")], + links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, + ) + second_page = OPDSFeed( + id="catalog", + title="Catalog", + entries=[OPDSEntry(id="2", title="Return of Ryan")], + links={}, + ) - def fake_fetch(path=None, params=None): - if path == "search": - return first_page - if path == "http://example.com/catalog?page=2": - return second_page - if path is None and params is None: - return first_page - return first_page + def fake_fetch(path=None, params=None): + if path == "search": + return first_page + if path == "http://example.com/catalog?page=2": + return second_page + if path is None and params is None: + return first_page + return first_page - monkeypatch.setattr(client, "fetch_feed", fake_fetch) + monkeypatch.setattr(client, "fetch_feed", fake_fetch) - result = client.search("ryan") - assert [entry.id for entry in result.entries] == ["1", "2"] + result = client.search("ryan") + assert [entry.id for entry in result.entries] == ["1", "2"] def test_calibre_opds_search_supplements_with_local_navigation(monkeypatch) -> None: - client = CalibreOPDSClient("http://example.com/catalog") - search_feed = OPDSFeed( - id="catalog", - title="Catalog", - entries=[ - OPDSEntry(id="book-1", title="Ryan's First Mission"), - OPDSEntry( - id="nav-authors", - title="Browse Authors", - links=[ - OPDSLink( - href="http://example.com/catalog/authors", - rel="http://opds-spec.org/navigation", - type="application/atom+xml;profile=opds-catalog", - ) + client = CalibreOPDSClient("http://example.com/catalog") + search_feed = OPDSFeed( + id="catalog", + title="Catalog", + entries=[ + OPDSEntry(id="book-1", title="Ryan's First Mission"), + OPDSEntry( + id="nav-authors", + title="Browse Authors", + links=[ + OPDSLink( + href="http://example.com/catalog/authors", + rel="http://opds-spec.org/navigation", + type="application/atom+xml;profile=opds-catalog", + ) + ], + ), ], - ), - ], - links={}, - ) - authors_feed = OPDSFeed( - id="authors", - title="Authors", - entries=[OPDSEntry(id="book-2", title="Chronicles of Ryan")], - links={}, - ) + links={}, + ) + authors_feed = OPDSFeed( + id="authors", + title="Authors", + entries=[OPDSEntry(id="book-2", title="Chronicles of Ryan")], + links={}, + ) - def fake_fetch(path=None, params=None): - if path == "search": - return search_feed - if path == "http://example.com/catalog/authors": - return authors_feed - if path is None and params is None: - return search_feed - return search_feed + def fake_fetch(path=None, params=None): + if path == "search": + return search_feed + if path == "http://example.com/catalog/authors": + return authors_feed + if path is None and params is None: + return search_feed + return search_feed - monkeypatch.setattr(client, "fetch_feed", fake_fetch) + monkeypatch.setattr(client, "fetch_feed", fake_fetch) - result = client.search("ryan") - assert [entry.id for entry in result.entries] == ["book-1", "book-2"] + result = client.search("ryan") + assert [entry.id for entry in result.entries] == ["book-1", "book-2"] def test_calibre_opds_browse_letter_traverses_next(monkeypatch) -> None: - client = CalibreOPDSClient("http://example.com/catalog") - root_feed = OPDSFeed( - id="catalog", - title="Browse Authors", - entries=[ - OPDSEntry( - id="nav-a", - title="A", - links=[ - OPDSLink( - href="http://example.com/catalog/authors/a", - rel="http://opds-spec.org/navigation", - type="application/atom+xml;profile=opds-catalog", - ) + client = CalibreOPDSClient("http://example.com/catalog") + root_feed = OPDSFeed( + id="catalog", + title="Browse Authors", + entries=[ + OPDSEntry( + id="nav-a", + title="A", + links=[ + OPDSLink( + href="http://example.com/catalog/authors/a", + rel="http://opds-spec.org/navigation", + type="application/atom+xml;profile=opds-catalog", + ) + ], + ) ], - ) - ], - links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, - ) - page_two = OPDSFeed( - id="catalog", - title="Browse Authors", - entries=[ - OPDSEntry( - id="nav-c", - title="C", - links=[ - OPDSLink( - href="http://example.com/catalog/authors/c", - rel="http://opds-spec.org/navigation", - type="application/atom+xml;profile=opds-catalog", - ) + links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, + ) + page_two = OPDSFeed( + id="catalog", + title="Browse Authors", + entries=[ + OPDSEntry( + id="nav-c", + title="C", + links=[ + OPDSLink( + href="http://example.com/catalog/authors/c", + rel="http://opds-spec.org/navigation", + type="application/atom+xml;profile=opds-catalog", + ) + ], + ) ], - ) - ], - links={}, - ) - letter_feed = OPDSFeed( - id="authors-c", - title="Authors starting with C", - entries=[OPDSEntry(id="author-1", title="Clarke, Arthur C.")], - links={}, - ) + links={}, + ) + letter_feed = OPDSFeed( + id="authors-c", + title="Authors starting with C", + entries=[OPDSEntry(id="author-1", title="Clarke, Arthur C.")], + links={}, + ) - def fake_fetch(href=None, params=None): - if not href: - return root_feed - if href == "http://example.com/catalog?page=2": - return page_two - if href == "http://example.com/catalog/authors/c": - return letter_feed - return root_feed + def fake_fetch(href=None, params=None): + if not href: + return root_feed + if href == "http://example.com/catalog?page=2": + return page_two + if href == "http://example.com/catalog/authors/c": + return letter_feed + return root_feed - monkeypatch.setattr(client, "fetch_feed", fake_fetch) + monkeypatch.setattr(client, "fetch_feed", fake_fetch) - result = client.browse_letter("C") - assert [entry.id for entry in result.entries] == ["author-1"] + result = client.browse_letter("C") + assert [entry.id for entry in result.entries] == ["author-1"] -def test_calibre_opds_browse_letter_filters_when_missing_navigation(monkeypatch) -> None: - client = CalibreOPDSClient("http://example.com/catalog") - titles_feed = OPDSFeed( - id="catalog", - title="Browse Titles", - entries=[ - OPDSEntry(id="book-1", title="The Moon is a Harsh Mistress"), - OPDSEntry(id="book-2", title="Another Story"), - ], - links={}, - ) +def test_calibre_opds_browse_letter_filters_when_missing_navigation( + monkeypatch, +) -> None: + client = CalibreOPDSClient("http://example.com/catalog") + titles_feed = OPDSFeed( + id="catalog", + title="Browse Titles", + entries=[ + OPDSEntry(id="book-1", title="The Moon is a Harsh Mistress"), + OPDSEntry(id="book-2", title="Another Story"), + ], + links={}, + ) - def fake_fetch(href=None, params=None): - return titles_feed + def fake_fetch(href=None, params=None): + return titles_feed - monkeypatch.setattr(client, "fetch_feed", fake_fetch) + monkeypatch.setattr(client, "fetch_feed", fake_fetch) - result = client.browse_letter("M") - assert [entry.id for entry in result.entries] == ["book-1"] + result = client.browse_letter("M") + assert [entry.id for entry in result.entries] == ["book-1"] def test_calibre_opds_browse_letter_collects_paginated_entries(monkeypatch) -> None: - client = CalibreOPDSClient("http://example.com/catalog") - first_page = OPDSFeed( - id="catalog", - title="Browse Titles", - entries=[ - OPDSEntry(id="book-1", title="Ryan's First Adventure"), - OPDSEntry(id="book-2", title="Another Tale"), - ], - links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, - ) - second_page = OPDSFeed( - id="catalog", - title="Browse Titles", - entries=[OPDSEntry(id="book-3", title="Return of Ryan")], - links={}, - ) + client = CalibreOPDSClient("http://example.com/catalog") + first_page = OPDSFeed( + id="catalog", + title="Browse Titles", + entries=[ + OPDSEntry(id="book-1", title="Ryan's First Adventure"), + OPDSEntry(id="book-2", title="Another Tale"), + ], + links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")}, + ) + second_page = OPDSFeed( + id="catalog", + title="Browse Titles", + entries=[OPDSEntry(id="book-3", title="Return of Ryan")], + links={}, + ) - def fake_fetch(href=None, params=None): - if not href: - return first_page - if href == "http://example.com/catalog?page=2": - return second_page - return first_page + def fake_fetch(href=None, params=None): + if not href: + return first_page + if href == "http://example.com/catalog?page=2": + return second_page + return first_page - monkeypatch.setattr(client, "fetch_feed", fake_fetch) + monkeypatch.setattr(client, "fetch_feed", fake_fetch) - result = client.browse_letter("R") - assert [entry.id for entry in result.entries] == ["book-1", "book-3"] + result = client.browse_letter("R") + assert [entry.id for entry in result.entries] == ["book-1", "book-3"] def test_calibre_opds_browse_letter_collects_paginated_navigation(monkeypatch) -> None: - client = CalibreOPDSClient("http://example.com/catalog") - root_feed = OPDSFeed( - id="catalog", - title="Browse Authors", - entries=[ - OPDSEntry( - id="nav-a", - title="A", - links=[ - OPDSLink( - href="http://example.com/catalog/authors/a", - rel="http://opds-spec.org/navigation", - type="application/atom+xml;profile=opds-catalog", - ) + client = CalibreOPDSClient("http://example.com/catalog") + root_feed = OPDSFeed( + id="catalog", + title="Browse Authors", + entries=[ + OPDSEntry( + id="nav-a", + title="A", + links=[ + OPDSLink( + href="http://example.com/catalog/authors/a", + rel="http://opds-spec.org/navigation", + type="application/atom+xml;profile=opds-catalog", + ) + ], + ), + OPDSEntry( + id="nav-r", + title="R", + links=[ + OPDSLink( + href="http://example.com/catalog/authors/r", + rel="http://opds-spec.org/navigation", + type="application/atom+xml;profile=opds-catalog", + ) + ], + ), ], - ), - OPDSEntry( - id="nav-r", - title="R", - links=[ - OPDSLink( - href="http://example.com/catalog/authors/r", - rel="http://opds-spec.org/navigation", - type="application/atom+xml;profile=opds-catalog", - ) + links={}, + ) + letter_feed = OPDSFeed( + id="authors-r", + title="Authors — R", + entries=[ + OPDSEntry(id="author-1", title="Ryan, Alice"), ], - ), - ], - links={}, - ) - letter_feed = OPDSFeed( - id="authors-r", - title="Authors — R", - entries=[ - OPDSEntry(id="author-1", title="Ryan, Alice"), - ], - links={"next": OPDSLink(href="http://example.com/catalog/authors/r?page=2", rel="next")}, - ) - letter_page_two = OPDSFeed( - id="authors-r", - title="Authors — R", - entries=[OPDSEntry(id="author-2", title="Ryan, Bob")], - links={}, - ) + links={ + "next": OPDSLink( + href="http://example.com/catalog/authors/r?page=2", rel="next" + ) + }, + ) + letter_page_two = OPDSFeed( + id="authors-r", + title="Authors — R", + entries=[OPDSEntry(id="author-2", title="Ryan, Bob")], + links={}, + ) - def fake_fetch(href=None, params=None): - if not href: - return root_feed - if href == "http://example.com/catalog/authors/r": - return letter_feed - if href == "http://example.com/catalog/authors/r?page=2": - return letter_page_two - return root_feed + def fake_fetch(href=None, params=None): + if not href: + return root_feed + if href == "http://example.com/catalog/authors/r": + return letter_feed + if href == "http://example.com/catalog/authors/r?page=2": + return letter_page_two + return root_feed - monkeypatch.setattr(client, "fetch_feed", fake_fetch) + monkeypatch.setattr(client, "fetch_feed", fake_fetch) - result = client.browse_letter("R") - assert [entry.id for entry in result.entries] == ["author-1", "author-2"] + result = client.browse_letter("R") + assert [entry.id for entry in result.entries] == ["author-1", "author-2"] diff --git a/tests/test_chapter_overrides.py b/tests/test_chapter_overrides.py index 27f942d..7ffbe1a 100644 --- a/tests/test_chapter_overrides.py +++ b/tests/test_chapter_overrides.py @@ -43,7 +43,11 @@ def _install_dependency_stubs() -> None: setattr(numpy_stub, "float32", "float32") setattr(numpy_stub, "array", lambda data, dtype=None: data) setattr(numpy_stub, "asarray", lambda data, dtype=None: data) - setattr(numpy_stub, "concatenate", lambda seq, axis=0: sum((list(item) for item in seq), [])) + setattr( + numpy_stub, + "concatenate", + lambda seq, axis=0: sum((list(item) for item in seq), []), + ) sys.modules["numpy"] = numpy_stub if "soundfile" not in sys.modules: @@ -117,7 +121,9 @@ def test_apply_chapter_overrides_with_custom_text() -> None: {"index": 1, "enabled": False}, ] - selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides) + selected, metadata, diagnostics = _apply_chapter_overrides( + _sample_chapters(), overrides + ) assert len(selected) == 1 assert selected[0].title == "Intro" @@ -132,7 +138,9 @@ def test_apply_chapter_overrides_uses_original_content_when_text_missing() -> No {"index": 1, "enabled": True}, ] - selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides) + selected, metadata, diagnostics = _apply_chapter_overrides( + _sample_chapters(), overrides + ) assert len(selected) == 1 assert selected[0].title == "Chapter 2" @@ -152,7 +160,9 @@ def test_apply_chapter_overrides_collects_metadata_updates() -> None: } ] - selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides) + selected, metadata, diagnostics = _apply_chapter_overrides( + _sample_chapters(), overrides + ) assert len(selected) == 1 assert metadata == {"artist": "Test Author", "year": "2024"} @@ -164,7 +174,9 @@ def test_apply_chapter_overrides_reports_diagnostics_for_invalid_payload() -> No {"enabled": True, "title": "Missing"}, ] - selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides) + selected, metadata, diagnostics = _apply_chapter_overrides( + _sample_chapters(), overrides + ) assert selected == [] assert metadata == {} diff --git a/tests/test_chunk_helpers.py b/tests/test_chunk_helpers.py index b937358..ee9da79 100644 --- a/tests/test_chunk_helpers.py +++ b/tests/test_chunk_helpers.py @@ -27,7 +27,9 @@ def test_chunk_voice_spec_prefers_chunk_overrides() -> None: def test_chunk_voice_spec_falls_back_to_speaker_voice() -> None: - job = SimpleNamespace(voice="base_voice", speakers={"narrator": {"voice": "speaker_voice"}}) + job = SimpleNamespace( + voice="base_voice", speakers={"narrator": {"voice": "speaker_voice"}} + ) chunk = {"speaker_id": "narrator"} assert _chunk_voice_spec(job, chunk, "fallback") == "speaker_voice" diff --git a/tests/test_conversion_chapter_titles.py b/tests/test_conversion_chapter_titles.py index 76805c6..5fe6a4d 100644 --- a/tests/test_conversion_chapter_titles.py +++ b/tests/test_conversion_chapter_titles.py @@ -74,7 +74,9 @@ def test_format_spoken_chapter_title_adds_prefix() -> None: def test_format_spoken_chapter_title_respects_existing_prefix() -> None: - assert _format_spoken_chapter_title("Chapter 2: Story", 2, True) == "Chapter 2: Story" + assert ( + _format_spoken_chapter_title("Chapter 2: Story", 2, True) == "Chapter 2: Story" + ) def test_format_spoken_chapter_title_handles_empty_title() -> None: @@ -82,7 +84,10 @@ def test_format_spoken_chapter_title_handles_empty_title() -> None: def test_format_spoken_chapter_title_trims_delimiters() -> None: - assert _format_spoken_chapter_title("7 - Into the Wild", 7, True) == "Chapter 7. Into the Wild" + assert ( + _format_spoken_chapter_title("7 - Into the Wild", 7, True) + == "Chapter 7. Into the Wild" + ) def test_headings_equivalent_ignores_case_and_prefix() -> None: @@ -90,7 +95,9 @@ def test_headings_equivalent_ignores_case_and_prefix() -> None: def test_strip_duplicate_heading_line_removes_first_match() -> None: - text, removed = _strip_duplicate_heading_line("Chapter 3: Intro\nBody text", "Chapter 3: Intro") + text, removed = _strip_duplicate_heading_line( + "Chapter 3: Intro\nBody text", "Chapter 3: Intro" + ) assert removed is True assert text.strip() == "Body text" diff --git a/tests/test_conversion_series.py b/tests/test_conversion_series.py index fb85138..9bb8def 100644 --- a/tests/test_conversion_series.py +++ b/tests/test_conversion_series.py @@ -117,4 +117,4 @@ def test_series_number_preserves_decimal_positions() -> None: intro_text = _build_title_intro_text(metadata, "interlude.mp3") - assert "Book 2.5 of the Chronicles." in intro_text \ No newline at end of file + assert "Book 2.5 of the Chronicles." in intro_text diff --git a/tests/test_conversion_voice_resolution.py b/tests/test_conversion_voice_resolution.py index 4987c5d..e791306 100644 --- a/tests/test_conversion_voice_resolution.py +++ b/tests/test_conversion_voice_resolution.py @@ -14,14 +14,14 @@ def _sample_job(formula: str) -> Job: return cast( Job, SimpleNamespace( - voice="__custom_mix", - speakers={ - "narrator": { - "resolved_voice": formula, - } - }, - chapters=[], - chunks=[{}], + voice="__custom_mix", + speakers={ + "narrator": { + "resolved_voice": formula, + } + }, + chapters=[], + chunks=[{}], ), ) diff --git a/tests/test_date_normalization_comprehensive.py b/tests/test_date_normalization_comprehensive.py index 393feee..1bc04a0 100644 --- a/tests/test_date_normalization_comprehensive.py +++ b/tests/test_date_normalization_comprehensive.py @@ -1,15 +1,21 @@ import pytest -from abogen.kokoro_text_normalization import _normalize_grouped_numbers, ApostropheConfig +from abogen.kokoro_text_normalization import ( + _normalize_grouped_numbers, + ApostropheConfig, +) + @pytest.fixture def cfg(): return ApostropheConfig(convert_numbers=True, year_pronunciation_mode="american") + def normalize(text, config): return _normalize_grouped_numbers(text, config) + class TestDateNormalization: - + def test_standard_years(self, cfg): # 1990 -> nineteen hundred ninety assert "nineteen hundred ninety" in normalize("In 1990, the web was born.", cfg) @@ -18,8 +24,10 @@ class TestDateNormalization: # 2023 -> twenty twenty-three assert "twenty twenty-three" in normalize("It is currently 2023.", cfg) # 1905 -> nineteen hundred oh five - assert "nineteen hundred oh five" in normalize("In 1905, Einstein published.", cfg) - + assert "nineteen hundred oh five" in normalize( + "In 1905, Einstein published.", cfg + ) + def test_future_years(self, cfg): # 3400 -> thirty-four hundred assert "thirty-four hundred" in normalize("In the year 3400, we fly.", cfg) @@ -29,13 +37,13 @@ class TestDateNormalization: def test_years_with_markers(self, cfg): # 1021 BC -> ten twenty-one assert "ten twenty-one" in normalize("It happened in 1021 BC.", cfg) - # 4000 BCE -> forty hundred (or four thousand?) - # _format_year_like logic: + # 4000 BCE -> forty hundred (or four thousand?) + # _format_year_like logic: # if value % 1000 == 0: return "X thousand" - # 4000 -> four thousand. + # 4000 -> four thousand. # Let's check 4001 -> forty oh one assert "forty oh one" in normalize("Ancient times 4001 BCE.", cfg) - + def test_addresses_explicit(self, cfg): # "address" keyword present -> should NOT be year # 1925 -> one thousand nine hundred twenty-five (default num2words) @@ -46,11 +54,11 @@ class TestDateNormalization: assert "one thousand" in res or "nineteen hundred" in res res = normalize("Please send it to the address: 3400 North Blvd.", cfg) - assert "thirty-four hundred" not in res # Should not be year style - assert "three thousand" in res or "thirty-four hundred" in res - # Wait, "thirty-four hundred" IS how you say 3400 in num2words sometimes? + assert "thirty-four hundred" not in res # Should not be year style + assert "three thousand" in res or "thirty-four hundred" in res + # Wait, "thirty-four hundred" IS how you say 3400 in num2words sometimes? # num2words(3400) -> "three thousand, four hundred" usually. - # Let's verify what "thirty-four hundred" implies. + # Let's verify what "thirty-four hundred" implies. # If it's a year: "thirty-four hundred". # If it's a number: "three thousand four hundred". assert "three thousand" in res @@ -62,9 +70,9 @@ class TestDateNormalization: def test_ambiguous_numbers(self, cfg): # Just a number, no "address", no markers. Should default to year if 4 digits 1000-9999 - assert "nineteen hundred fifty" in normalize("I have 1950 apples.", cfg) + assert "nineteen hundred fifty" in normalize("I have 1950 apples.", cfg) # This is a known limitation/feature: it aggressively identifies years. - + def test_specific_user_examples(self, cfg): # 1021 assert "ten twenty-one" in normalize("1021", cfg) @@ -77,10 +85,10 @@ class TestDateNormalization: # Simulating a title or sentence from the book # "The Rise of the Robots: Technology and the Threat of a Jobless Future" # Maybe it mentions a year like 2015 (pub date) or a future date. - + # "In 2015, Martin Ford wrote..." assert "twenty fifteen" in normalize("In 2015, Martin Ford wrote...", cfg) - + # "By 2100, robots will..." assert "twenty-one hundred" in normalize("By 2100, robots will...", cfg) @@ -113,4 +121,3 @@ class TestDateNormalization: res = normalize("The addresses are 1925 and 1926.", cfg) # Expectation: should probably be numbers, not years. assert "nineteen twenty-five" not in res - diff --git a/tests/test_debug_tts_samples.py b/tests/test_debug_tts_samples.py index 6f0a088..a936fb2 100644 --- a/tests/test_debug_tts_samples.py +++ b/tests/test_debug_tts_samples.py @@ -4,7 +4,12 @@ from pathlib import Path import numpy as np import pytest -from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES, MARKER_PREFIX, MARKER_SUFFIX, iter_expected_codes +from abogen.debug_tts_samples import ( + DEBUG_TTS_SAMPLES, + MARKER_PREFIX, + MARKER_SUFFIX, + iter_expected_codes, +) from abogen.kokoro_text_normalization import HAS_NUM2WORDS, normalize_for_pipeline from abogen.normalization_settings import build_apostrophe_config from abogen.text_extractor import extract_from_path @@ -16,7 +21,9 @@ def test_debug_epub_contains_all_codes(): assert epub_path.exists() extraction = extract_from_path(epub_path) - combined = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters) + combined = extraction.combined_text or "\n\n".join( + (c.text or "") for c in extraction.chapters + ) for code in iter_expected_codes(): marker = f"{MARKER_PREFIX}{code}{MARKER_SUFFIX}" @@ -32,7 +39,9 @@ def test_debug_samples_normalize_smoke(): runtime = dict(settings) normalized = { - sample.code: normalize_for_pipeline(sample.text, config=apostrophe, settings=runtime) + sample.code: normalize_for_pipeline( + sample.text, config=apostrophe, settings=runtime + ) for sample in DEBUG_TTS_SAMPLES } @@ -69,7 +78,9 @@ def test_settings_debug_route_writes_manifest(tmp_path, monkeypatch): audio[::100] = 0.1 yield _Seg(audio) - monkeypatch.setattr(runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline()) + monkeypatch.setattr( + runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline() + ) app = create_app( { @@ -87,14 +98,18 @@ def test_settings_debug_route_writes_manifest(tmp_path, monkeypatch): assert "/settings/debug/" in location # Extract run id from /settings/debug/ - run_id = location.rsplit("/settings/debug/", 1)[1].split("?", 1)[0].split("#", 1)[0] + run_id = ( + location.rsplit("/settings/debug/", 1)[1].split("?", 1)[0].split("#", 1)[0] + ) manifest_path = tmp_path / "debug" / run_id / "manifest.json" assert manifest_path.exists() manifest = json.loads(manifest_path.read_text(encoding="utf-8")) filenames = {item["filename"] for item in manifest.get("artifacts", [])} assert "overall.wav" in filenames - assert any(name.startswith("case_") and name.endswith(".wav") for name in filenames) + assert any( + name.startswith("case_") and name.endswith(".wav") for name in filenames + ) def test_debug_samples_have_minimum_per_category(): @@ -125,7 +140,9 @@ def test_debug_runner_resolves_profile_voice_before_pipeline(tmp_path, monkeypat from abogen.webui import debug_tts_runner as runner # Stub voice setting resolution so we don't depend on the user's profile file. - monkeypatch.setattr(runner, "_resolve_voice_setting", lambda value: ("af_heart", "AM HQ Alt", None)) + monkeypatch.setattr( + runner, "_resolve_voice_setting", lambda value: ("af_heart", "AM HQ Alt", None) + ) calls = [] @@ -139,7 +156,9 @@ def test_debug_runner_resolves_profile_voice_before_pipeline(tmp_path, monkeypat audio = np.zeros(int(0.05 * runner.SAMPLE_RATE), dtype="float32") yield _Seg(audio) - monkeypatch.setattr(runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline()) + monkeypatch.setattr( + runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline() + ) settings = { "language": "en", @@ -152,4 +171,6 @@ def test_debug_runner_resolves_profile_voice_before_pipeline(tmp_path, monkeypat assert manifest.get("run_id") assert calls # Must not pass through the profile:* string. - assert all(isinstance(v, str) and not v.lower().startswith("profile:") for v in calls) + assert all( + isinstance(v, str) and not v.lower().startswith("profile:") for v in calls + ) diff --git a/tests/test_epub_content_slicing.py b/tests/test_epub_content_slicing.py index 16c616b..829e233 100644 --- a/tests/test_epub_content_slicing.py +++ b/tests/test_epub_content_slicing.py @@ -5,10 +5,11 @@ import sys from ebooklib import epub # Ensure import path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from abogen.book_parser import get_book_parser + class TestEpubContentSlicing(unittest.TestCase): """ Tests for the complex content slicing logic in _execute_nav_parsing_logic. @@ -34,7 +35,7 @@ class TestEpubContentSlicing(unittest.TestCase): book = epub.EpubBook() book.set_identifier("slice123") book.set_title("Slicing Test Book") - + # Create a single content file with two sections content_html = """ @@ -50,7 +51,7 @@ class TestEpubContentSlicing(unittest.TestCase): c1 = epub.EpubHtml(title="Full Content", file_name="content.xhtml", lang="en") c1.content = content_html book.add_item(c1) - + # Create Nav that points to anchors in the SAME file # We use EpubHtml for Nav to control content exactly without ebooklib interference nav_html = """ @@ -64,55 +65,56 @@ class TestEpubContentSlicing(unittest.TestCase): nav = epub.EpubHtml(title="Nav", file_name="nav.xhtml") nav.content = nav_html book.add_item(nav) - + book.spine = [nav, c1] - + epub.write_epub(self.epub_path, book) - + # OPF Patching to valid crash import zipfile + patched = False - with zipfile.ZipFile(self.epub_path, 'r') as zin: - opf_content = zin.read('EPUB/content.opf').decode('utf-8') + with zipfile.ZipFile(self.epub_path, "r") as zin: + opf_content = zin.read("EPUB/content.opf").decode("utf-8") if 'toc="ncx"' in opf_content: - opf_content = opf_content.replace('toc="ncx"', '') + opf_content = opf_content.replace('toc="ncx"', "") patched = True - + if patched: TEMP_EPUB = self.epub_path + ".temp" - with zipfile.ZipFile(TEMP_EPUB, 'w') as zout: + with zipfile.ZipFile(TEMP_EPUB, "w") as zout: for item in zin.infolist(): - if item.filename == 'EPUB/content.opf': + if item.filename == "EPUB/content.opf": zout.writestr(item, opf_content) else: zout.writestr(item, zin.read(item.filename)) - + if patched: shutil.move(TEMP_EPUB, self.epub_path) - + # Parse parser = get_book_parser(self.epub_path) parser.process_content() chapters = parser.get_chapters() - + # Filter Nav/Intro chapters = [c for c in chapters if "Chapter" in c[1]] - + self.assertEqual(len(chapters), 2) self.assertEqual(chapters[0][1], "Chapter 1") self.assertEqual(chapters[1][1], "Chapter 2") - + # Check content of Chapter 1 # It should contain "Text for chapter 1" but NOT "Text for chapter 2" # The parser logic slices from start_pos to next_pos text1 = parser.content_texts[chapters[0][0]] self.assertIn("Text for chapter 1", text1) self.assertNotIn("Text for chapter 2", text1) - + # Check content of Chapter 2 text2 = parser.content_texts[chapters[1][0]] self.assertIn("Text for chapter 2", text2) - + def test_list_renumbering(self): """ Test that ordered lists are re-numbered when slicing. @@ -121,7 +123,7 @@ class TestEpubContentSlicing(unittest.TestCase): book = epub.EpubBook() book.set_identifier("list123") book.set_title("List Test Book") - + content_html = """ @@ -141,7 +143,7 @@ class TestEpubContentSlicing(unittest.TestCase): c1 = epub.EpubHtml(title="Content", file_name="content.xhtml", lang="en") c1.content = content_html book.add_item(c1) - + nav_html = """ """ self._create_epub_with_custom_nav(nav_html) - + parser = get_book_parser(self.epub_path) parser.process_content() chapters = parser.get_chapters() - + # Filter out "Nav" or "Introduction" prefix content found from the Nav file itself chapters = [c for c in chapters if "Chapter" in c[1] or "Section" in c[1]] - + self.assertEqual(len(chapters), 2) self.assertEqual(chapters[0][1], "Chapter 1") self.assertEqual(chapters[1][1], "Chapter 2") @@ -116,11 +117,11 @@ class TestEpubHtmlNavParsing(unittest.TestCase): """ # Note: In this test setup, chap2 is serving as "Section 1.1" effectively self._create_epub_with_custom_nav(nav_html) - + parser = get_book_parser(self.epub_path) parser.process_content() chapters = parser.get_chapters() - + ids = [c[1] for c in chapters] self.assertIn("Chapter 1", ids) self.assertIn("Section 1.1", ids) @@ -143,25 +144,26 @@ class TestEpubHtmlNavParsing(unittest.TestCase): """ self._create_epub_with_custom_nav(nav_html) - + parser = get_book_parser(self.epub_path) parser.process_content() - + chapters = parser.get_chapters() chapter_titles = [c[1] for c in chapters] - + self.assertIn("Chapter 1", chapter_titles) - self.assertNotIn("Part I", chapter_titles) - + self.assertNotIn("Part I", chapter_titles) + # Check internal structure # Find the node named "Part I" in the processed structure - root_node = next(node for node in parser.processed_nav_structure if node['title'] == "Part I") - - self.assertEqual(root_node['title'], "Part I") - self.assertFalse(root_node['has_content']) - self.assertEqual(len(root_node['children']), 1) - self.assertEqual(root_node['children'][0]['title'], "Chapter 1") + root_node = next( + node for node in parser.processed_nav_structure if node["title"] == "Part I" + ) + self.assertEqual(root_node["title"], "Part I") + self.assertFalse(root_node["has_content"]) + self.assertEqual(len(root_node["children"]), 1) + self.assertEqual(root_node["children"][0]["title"], "Chapter 1") def test_identify_nav_item(self): """Test the _identify_nav_item method specifically.""" @@ -175,10 +177,11 @@ class TestEpubHtmlNavParsing(unittest.TestCase): # But here we can call load directly if needed, or rely on normal flow up until navigation parser.load() nav_item, nav_type = parser._identify_nav_item() - + self.assertEqual(nav_type, "html") self.assertIsNotNone(nav_item) self.assertTrue("nav.xhtml" in nav_item.get_name()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_epub_missing_file_error_handling.py b/tests/test_epub_missing_file_error_handling.py index 8005092..18c69ca 100644 --- a/tests/test_epub_missing_file_error_handling.py +++ b/tests/test_epub_missing_file_error_handling.py @@ -7,10 +7,11 @@ import logging from ebooklib import epub # Ensure import path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from abogen.book_parser import get_book_parser + class TestEpubMissingFileErrorHandling(unittest.TestCase): """ Tests for robust error handling and recovery in the book parser. @@ -22,8 +23,8 @@ class TestEpubMissingFileErrorHandling(unittest.TestCase): shutil.rmtree(self.test_dir) os.makedirs(self.test_dir) self.broken_epub_path = os.path.join(self.test_dir, "missing_file.epub") - - # Suppress logging during tests to keep output clean, + + # Suppress logging during tests to keep output clean, # or capture it if we want to assert on warnings. # For now, we just let it be or set level to ERROR. logging.getLogger().setLevel(logging.ERROR) @@ -39,27 +40,27 @@ class TestEpubMissingFileErrorHandling(unittest.TestCase): book = epub.EpubBook() book.set_identifier("broken123") book.set_title("Broken Book") - + # 1. Add a valid chapter c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en") c1.content = "

        Chapter 1

        Survivable content.

        " book.add_item(c1) - + # 2. Add a 'ghost' chapter that we will delete later c2 = epub.EpubHtml(title="Ghost Chapter", file_name="ghost.xhtml", lang="en") c2.content = "

        Ghost

        I will disappear.

        " book.add_item(c2) - + book.spine = ["nav", c1, c2] book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNav()) - + temp_path = os.path.join(self.test_dir, "temp.epub") epub.write_epub(temp_path, book) - + # 3. Physically remove 'ghost.xhtml' from the ZIP - with zipfile.ZipFile(temp_path, 'r') as zin: - with zipfile.ZipFile(self.broken_epub_path, 'w') as zout: + with zipfile.ZipFile(temp_path, "r") as zin: + with zipfile.ZipFile(self.broken_epub_path, "w") as zout: for item in zin.infolist(): # Copy everything EXCEPT the ghost file # Note: ebooklib might put files in OEPS/ or EPUB/ folders depending on version, @@ -73,14 +74,14 @@ class TestEpubMissingFileErrorHandling(unittest.TestCase): Should log a warning instead of raising KeyError. """ self._create_broken_epub() - + try: parser = get_book_parser(self.broken_epub_path) parser.process_content() - + # 1. Ensure process didn't crash self.assertTrue(True, "Parser should not crash on missing file") - + # 2. Ensure valid content was extracted # Identify the ID for chap1.xhtml (usually file path based) # Since IDs can vary, we check if ANY content contains our known string @@ -90,11 +91,12 @@ class TestEpubMissingFileErrorHandling(unittest.TestCase): chap1_found = True break self.assertTrue(chap1_found, "The valid chapter should still be processed") - + except KeyError: self.fail("Parser raised KeyError instead of handling the missing file!") except Exception as e: self.fail(f"Parser raised unexpected exception: {e}") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_epub_ncx_parsing.py b/tests/test_epub_ncx_parsing.py index bf24710..313d785 100644 --- a/tests/test_epub_ncx_parsing.py +++ b/tests/test_epub_ncx_parsing.py @@ -5,13 +5,14 @@ import sys from ebooklib import epub # Ensure we can import the module -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from abogen.book_parser import get_book_parser, EpubParser + class TestEpubNcxParsing(unittest.TestCase): """ - Focused tests for NCX navigation scenarios, ensuring legacy/compatibility + Focused tests for NCX navigation scenarios, ensuring legacy/compatibility modes work when HTML5 Navigation is missing. """ @@ -50,9 +51,9 @@ class TestEpubNcxParsing(unittest.TestCase): # Add default NCX and generic spine book.add_item(epub.EpubNcx()) # IMPORTANT: Do NOT add EpubNav() here, that's what we are testing! - + book.spine = ["nav"] + epub_chapters - + epub.write_epub(self.ncx_only_epub_path, book) def test_ncx_only_parsing(self): @@ -63,28 +64,28 @@ class TestEpubNcxParsing(unittest.TestCase): # 1. Setup Data chapters_data = [ ("Chapter 1", "This is the first chapter."), - ("Chapter 2", "This is the second chapter.") + ("Chapter 2", "This is the second chapter."), ] self._create_ncx_only_epub(chapters_data) # 2. Run Parser parser = get_book_parser(self.ncx_only_epub_path) parser.process_content() - + # 3. Verify Breakdown # We expect detailed breakdown based on NCX chapters = parser.get_chapters() - + # Should find exactly 2 chapters based on the Toc self.assertEqual(len(chapters), 2, "Should have 2 chapters extracted from NCX") - + # Check Titles and Sequence self.assertEqual(chapters[0][1], "Chapter 1") self.assertEqual(chapters[1][1], "Chapter 2") - + # Verify content was extracted # Note: 'src' in chapters usually points to file_name if no fragments - id_1 = chapters[0][0] + id_1 = chapters[0][0] self.assertIn("This is the first chapter", parser.content_texts[id_1]) def test_nested_ncx_parsing(self): @@ -94,7 +95,7 @@ class TestEpubNcxParsing(unittest.TestCase): book = epub.EpubBook() book.set_identifier("nested_ncx") book.set_title("Nested NCX") - + # Create one big file with sections c1 = epub.EpubHtml(title="Main Chapter", file_name="main.xhtml", lang="en") c1.content = """ @@ -108,33 +109,32 @@ class TestEpubNcxParsing(unittest.TestCase): # Manually construct nested TOC because ebooklib's default helpers are simple # EbookLib automatically builds NCX from book.toc # Nested tuple structure: (Section, (Subsection, Sub-subsection)) - + # We need to link to Fragments for this to really test nested NCX pointing to same file # EbookLib Link object: epub.Link(href, title, uid) - + link_root = epub.Link("main.xhtml#intro", "Introduction", "intro") link_sect = epub.Link("main.xhtml#sect1", "Section 1", "sect1") - + # Structure: Intro -> Section 1 (as child) - book.toc = ( - (link_root, (link_sect, )), - ) - + book.toc = ((link_root, (link_sect,)),) + book.add_item(epub.EpubNcx()) book.spine = ["nav", c1] - + epub.write_epub(self.ncx_only_epub_path, book) - + # Parse parser = get_book_parser(self.ncx_only_epub_path) parser.process_content() - + chapters = parser.get_chapters() - + # Depending on how the parser flattens, we should see both entries titles = [node[1] for node in chapters] self.assertIn("Introduction", titles) self.assertIn("Section 1", titles) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_epub_standard_nav.py b/tests/test_epub_standard_nav.py index d916f1e..b4aaf39 100644 --- a/tests/test_epub_standard_nav.py +++ b/tests/test_epub_standard_nav.py @@ -7,10 +7,11 @@ import ebooklib from unittest.mock import MagicMock # Ensure import path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from abogen.book_parser import get_book_parser + class TestEpubStandardNav(unittest.TestCase): """ Tests for the standard ITEM_NAVIGATION discovery in _identify_nav_item. @@ -33,38 +34,39 @@ class TestEpubStandardNav(unittest.TestCase): book = epub.EpubBook() book.set_identifier("stdnav123") book.set_title("Standard Nav Test") - + c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en") c1.content = "

        Chapter 1

        Text 1

        " book.add_item(c1) - + # Use Standard EpubNav nav = epub.EpubNav() book.add_item(nav) book.spine = [nav, c1] - + epub.write_epub(self.epub_path, book) - + # "Zip Surgery" Patch: # ebooklib unconditionally adds `toc="ncx"` to the spine, even for EPUB 3 files that purely use HTML Nav. # This creates a dangling reference to a non-existent "ncx" item, causing ebooklib to crash on read. # We manually remove this attribute to ensure the test EPUB is valid and readable. # TODO - find real world examples of EPUB 3 files that use HTML Nav import zipfile + patched = False - with zipfile.ZipFile(self.epub_path, 'r') as zin: - opf_content = zin.read('EPUB/content.opf').decode('utf-8') + with zipfile.ZipFile(self.epub_path, "r") as zin: + opf_content = zin.read("EPUB/content.opf").decode("utf-8") if 'toc="ncx"' in opf_content: - opf_content = opf_content.replace('toc="ncx"', '') + opf_content = opf_content.replace('toc="ncx"', "") patched = True TEMP_EPUB = self.epub_path + ".temp" - with zipfile.ZipFile(TEMP_EPUB, 'w') as zout: + with zipfile.ZipFile(TEMP_EPUB, "w") as zout: for item in zin.infolist(): - if item.filename == 'EPUB/content.opf': + if item.filename == "EPUB/content.opf": zout.writestr(item, opf_content) else: zout.writestr(item, zin.read(item.filename)) - + if patched: shutil.move(TEMP_EPUB, self.epub_path) @@ -78,18 +80,18 @@ class TestEpubStandardNav(unittest.TestCase): This exercises the first branch of _identify_nav_item. """ parser = self._create_and_load_epub() - + # Inject an item that mocks the ITEM_NAVIGATION type behavior # (This simulates a library/parser that correctly types the item as 4) mock_nav = MagicMock() mock_nav.get_name.return_value = "nav.xhtml" mock_nav.get_type.return_value = ebooklib.ITEM_NAVIGATION - + # We append this mock to the book items to ensure get_items_of_type(ITEM_NAVIGATION) finds it parser.book.items.append(mock_nav) - + nav_item, nav_type = parser._identify_nav_item() - + self.assertEqual(nav_type, "html") self.assertEqual(nav_item.get_name(), "nav.xhtml") # Verify we are getting the object we expect (implied by success) @@ -100,31 +102,32 @@ class TestEpubStandardNav(unittest.TestCase): This is the standard EPUB 3 behavior and exercises the fallback branch. """ parser = self._create_and_load_epub() - + # Locate the generic 'nav' item loaded by ebooklib original_nav = parser.book.get_item_with_id("nav") self.assertIsNotNone(original_nav) - + # "Fix" the object to match what we expect from a correct EPUB 3 read: # It should have properties=['nav']. # We use a real EpubNav object to ensure structural correctness. proper_nav = epub.EpubNav(uid=original_nav.id, file_name=original_nav.file_name) proper_nav.content = original_nav.content - proper_nav.properties = ['nav'] - + proper_nav.properties = ["nav"] + # Swap it into the book items list try: idx = parser.book.items.index(original_nav) parser.book.items[idx] = proper_nav except ValueError: self.fail("Could not find original nav item to swap") - + nav_item, nav_type = parser._identify_nav_item() - + self.assertEqual(nav_type, "html") self.assertEqual(nav_item.get_name(), "nav.xhtml") # Check that we actually found the one with properties - self.assertEqual(getattr(nav_item, 'properties', []), ['nav']) + self.assertEqual(getattr(nav_item, "properties", []), ["nav"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_output_paths.py b/tests/test_output_paths.py index a1d14a8..77e8992 100644 --- a/tests/test_output_paths.py +++ b/tests/test_output_paths.py @@ -30,16 +30,22 @@ def _sample_job(tmp_path: Path) -> Job: ) -def test_prepare_project_layout_uses_timestamped_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_prepare_project_layout_uses_timestamped_folder( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: job = _sample_job(tmp_path) monkeypatch.setattr( "abogen.webui.conversion_runner._output_timestamp_token", lambda: "20250101-120000", ) - project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, tmp_path) + project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout( + job, tmp_path + ) - assert project_root.name.startswith("20250101-120000_Sample_Title"), project_root.name + assert project_root.name.startswith( + "20250101-120000_Sample_Title" + ), project_root.name assert audio_dir == project_root assert subtitle_dir == project_root assert metadata_dir is None @@ -48,7 +54,9 @@ def test_prepare_project_layout_uses_timestamped_folder(monkeypatch: pytest.Monk assert output_path == project_root / "Sample_Title.mp3" -def test_prepare_project_layout_creates_project_subdirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_prepare_project_layout_creates_project_subdirs( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: job = _sample_job(tmp_path) job.save_as_project = True monkeypatch.setattr( @@ -56,7 +64,9 @@ def test_prepare_project_layout_creates_project_subdirs(monkeypatch: pytest.Monk lambda: "20250101-120500", ) - project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, tmp_path) + project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout( + job, tmp_path + ) assert audio_dir == project_root / "audio" assert subtitle_dir == project_root / "subtitles" diff --git a/tests/test_prepare_form.py b/tests/test_prepare_form.py index 4ea05eb..9456d0f 100644 --- a/tests/test_prepare_form.py +++ b/tests/test_prepare_form.py @@ -102,7 +102,9 @@ def test_resolve_voice_setting_handles_profile_reference(): } } - voice, profile_name, language = resolve_voice_setting("profile:Blend", profiles=profiles) + voice, profile_name, language = resolve_voice_setting( + "profile:Blend", profiles=profiles + ) assert voice == "af_nova*0.5+am_liam*0.5" assert profile_name == "Blend" @@ -112,9 +114,11 @@ def test_resolve_voice_setting_handles_profile_reference(): def test_apply_prepare_form_updates_closing_outro_flag(): pending = _make_pending_job() pending.read_closing_outro = True - form = MultiDict({ - "read_closing_outro": "false", - }) + form = MultiDict( + { + "read_closing_outro": "false", + } + ) apply_prepare_form(pending, form) diff --git a/tests/test_preview_applies_manual_overrides.py b/tests/test_preview_applies_manual_overrides.py index 76998c4..0f08512 100644 --- a/tests/test_preview_applies_manual_overrides.py +++ b/tests/test_preview_applies_manual_overrides.py @@ -13,7 +13,9 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch): def normalize_for_pipeline(text): return text - monkeypatch.setitem(__import__("sys").modules, "abogen.kokoro_text_normalization", _Norm) + monkeypatch.setitem( + __import__("sys").modules, "abogen.kokoro_text_normalization", _Norm + ) # And stub the kokoro pipeline path so generate_preview_audio won't proceed. # We'll instead validate by calling the override logic through generate_preview_audio @@ -28,7 +30,11 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch): captured["text"] = text return iter(()) - monkeypatch.setitem(__import__("sys").modules, "abogen.tts_supertonic", type("M", (), {"SupertonicPipeline": DummyPipeline})) + monkeypatch.setitem( + __import__("sys").modules, + "abogen.tts_supertonic", + type("M", (), {"SupertonicPipeline": DummyPipeline}), + ) try: preview.generate_preview_audio( diff --git a/tests/test_regression_fixes.py b/tests/test_regression_fixes.py index 7bf0396..babc2f8 100644 --- a/tests/test_regression_fixes.py +++ b/tests/test_regression_fixes.py @@ -1,16 +1,21 @@ import pytest from unittest.mock import patch -from abogen.kokoro_text_normalization import normalize_for_pipeline, DEFAULT_APOSTROPHE_CONFIG +from abogen.kokoro_text_normalization import ( + normalize_for_pipeline, + DEFAULT_APOSTROPHE_CONFIG, +) from abogen.normalization_settings import build_apostrophe_config, _SETTINGS_DEFAULTS + def normalize(text, overrides=None): settings = dict(_SETTINGS_DEFAULTS) if overrides: settings.update(overrides) - + config = build_apostrophe_config(settings=settings, base=DEFAULT_APOSTROPHE_CONFIG) return normalize_for_pipeline(text, config=config, settings=settings) + def test_year_pronunciation(): # 1925 -> Nineteen Hundred Twenty Five normalized = normalize("1925") @@ -24,6 +29,7 @@ def test_year_pronunciation(): assert "twenty twenty" in normalized.lower() assert "five" in normalized.lower() + def test_currency_pronunciation(): # $1.00 -> One dollar (no zero cents) normalized = normalize("$1.00") @@ -37,6 +43,7 @@ def test_currency_pronunciation(): assert "one dollar" in normalized.lower() assert "five cents" in normalized.lower() + def test_url_pronunciation(): # https://www.amazon.com -> amazon dot com normalized = normalize("https://www.amazon.com") @@ -50,6 +57,7 @@ def test_url_pronunciation(): print(f"www.google.com -> {normalized}") assert "google dot com" in normalized.lower() + def test_roman_numerals_world_war(): # World War I -> World War One normalized = normalize("World War I") @@ -61,6 +69,7 @@ def test_roman_numerals_world_war(): print(f"World War II -> {normalized}") assert "world war two" in normalized.lower() + def test_footnote_removal(): # Bob is awesome1. -> Bob is awesome. normalized = normalize("Bob is awesome1.") @@ -74,8 +83,10 @@ def test_footnote_removal(): assert "citation needed." in normalized.lower() assert "[1]" not in normalized + def test_manual_override_normalization(): from abogen.entity_analysis import normalize_manual_override_token + assert normalize_manual_override_token("The") == "the" assert normalize_manual_override_token(" A ") == "a" assert normalize_manual_override_token("word") == "word" diff --git a/tests/test_service.py b/tests/test_service.py index 07c61f1..afa56c5 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -2,7 +2,13 @@ from __future__ import annotations import io import time -from abogen.webui.service import Job, JobStatus, build_service, _JOB_LOGGER, build_audiobookshelf_metadata +from abogen.webui.service import ( + Job, + JobStatus, + build_service, + _JOB_LOGGER, + build_audiobookshelf_metadata, +) def test_service_processes_job(tmp_path): @@ -47,7 +53,11 @@ def test_service_processes_job(tmp_path): ) deadline = time.time() + 5 - while time.time() < deadline and job.status not in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: + while time.time() < deadline and job.status not in { + JobStatus.COMPLETED, + JobStatus.FAILED, + JobStatus.CANCELLED, + }: time.sleep(0.05) service.shutdown() @@ -287,4 +297,4 @@ def test_audiobookshelf_metadata_allows_decimal_sequence(tmp_path): metadata = build_audiobookshelf_metadata(job) - assert metadata["seriesSequence"] == "4.5" \ No newline at end of file + assert metadata["seriesSequence"] == "4.5" diff --git a/tests/test_speaker_analysis.py b/tests/test_speaker_analysis.py index 88885a5..2b46085 100644 --- a/tests/test_speaker_analysis.py +++ b/tests/test_speaker_analysis.py @@ -24,9 +24,12 @@ def _chunk(text: str, idx: int) -> dict: def test_analyze_speakers_infers_gender_from_pronouns(): chunks = [ - _chunk("\"Greetings,\" said John. He adjusted his hat as he smiled.", 0), - _chunk("\"Hello,\" said Mary. She straightened her dress as she introduced herself.", 1), - _chunk("\"Nice to meet you,\" said Alex.", 2), + _chunk('"Greetings," said John. He adjusted his hat as he smiled.', 0), + _chunk( + '"Hello," said Mary. She straightened her dress as she introduced herself.', + 1, + ), + _chunk('"Nice to meet you," said Alex.', 2), ] analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0) @@ -63,8 +66,8 @@ def test_analyze_speakers_ignores_leading_stopwords(): def test_analyze_speakers_applies_threshold_suppression(): chunks = [ - _chunk("\"Hello there,\" said Narrator.", 0), - _chunk("\"It is lying,\" said Green.", 1), + _chunk('"Hello there," said Narrator.', 0), + _chunk('"It is lying," said Green.', 1), ] analysis = analyze_speakers(_chapters(), chunks, threshold=3, max_speakers=0) @@ -78,7 +81,7 @@ def test_analyze_speakers_applies_threshold_suppression(): def test_sample_excerpt_includes_context_paragraphs(): chunks = [ _chunk("The hallway was quiet as footsteps approached.", 0), - _chunk('\"Open the door,\" said John as he reached for the handle.', 1), + _chunk('"Open the door," said John as he reached for the handle.', 1), _chunk("Mary watched him closely, unsure of his intent.", 2), ] @@ -89,5 +92,5 @@ def test_sample_excerpt_includes_context_paragraphs(): assert john.sample_quotes, "Expected John to have at least one sample quote" excerpt = john.sample_quotes[0]["excerpt"] assert "The hallway was quiet" in excerpt - assert "\"Open the door,\" said John" in excerpt + assert '"Open the door," said John' in excerpt assert "Mary watched him closely" in excerpt diff --git a/tests/test_text_extractor.py b/tests/test_text_extractor.py index f63b67f..cf7db58 100644 --- a/tests/test_text_extractor.py +++ b/tests/test_text_extractor.py @@ -6,7 +6,9 @@ from abogen.text_extractor import extract_from_path from abogen.utils import calculate_text_length -ASSET = Path("test_assets/alexandre-dumas_the-count-of-monte-cristo_chapman-and-hall.epub") +ASSET = Path( + "test_assets/alexandre-dumas_the-count-of-monte-cristo_chapman-and-hall.epub" +) def test_epub_character_counts_align_with_calculated_total(): @@ -37,8 +39,12 @@ def test_epub_series_metadata_extracted_from_opf_meta(tmp_path): book.add_author("Example Author") # Calibre-style series metadata - book.add_metadata("OPF", "meta", "", {"name": "calibre:series", "content": "Example Saga"}) - book.add_metadata("OPF", "meta", "", {"name": "calibre:series_index", "content": "2"}) + book.add_metadata( + "OPF", "meta", "", {"name": "calibre:series", "content": "Example Saga"} + ) + book.add_metadata( + "OPF", "meta", "", {"name": "calibre:series_index", "content": "2"} + ) chapter = epub.EpubHtml(title="Chapter 1", file_name="chap_01.xhtml", lang="en") chapter.content = "

        Chapter 1

        Hello

        " diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py index 156fccf..4680017 100644 --- a/tests/test_text_normalization.py +++ b/tests/test_text_normalization.py @@ -16,14 +16,22 @@ from abogen.normalization_settings import ( from abogen.spacy_contraction_resolver import resolve_ambiguous_contractions -SPACY_RESOLVER_AVAILABLE = bool(resolve_ambiguous_contractions("It's been a long time.")) +SPACY_RESOLVER_AVAILABLE = bool( + resolve_ambiguous_contractions("It's been a long time.") +) -def _normalize_text(text: str, *, normalization_overrides: dict[str, object] | None = None) -> str: +def _normalize_text( + text: str, *, normalization_overrides: dict[str, object] | None = None +) -> str: runtime_settings = get_runtime_settings() if normalization_overrides: - runtime_settings = apply_normalization_overrides(runtime_settings, normalization_overrides) - config = build_apostrophe_config(settings=runtime_settings, base=DEFAULT_APOSTROPHE_CONFIG) + runtime_settings = apply_normalization_overrides( + runtime_settings, normalization_overrides + ) + config = build_apostrophe_config( + settings=runtime_settings, base=DEFAULT_APOSTROPHE_CONFIG + ) return normalize_for_pipeline(text, config=config, settings=runtime_settings) @@ -202,7 +210,9 @@ def test_contractions_can_be_kept_when_override_disabled() -> None: def test_sibilant_possessives_remain_when_marking_disabled() -> None: normalized = _normalize_text( "The boss's chair wobbled.", - normalization_overrides={"normalization_apostrophes_sibilant_possessives": False}, + normalization_overrides={ + "normalization_apostrophes_sibilant_possessives": False + }, ) assert "boss's" in normalized assert "boss iz" not in normalized.lower() @@ -276,10 +286,13 @@ def test_spacy_disambiguates_she_would() -> None: @pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable") def test_sample_sentence_handles_complex_contractions() -> None: - sample = "I've heard the captain'll arrive by dusk, but they'd said the same yesterday." + sample = ( + "I've heard the captain'll arrive by dusk, but they'd said the same yesterday." + ) normalized = _normalize_text(sample) assert ( - "I have heard the captain will arrive by dusk, but they had said the same yesterday." == normalized + "I have heard the captain will arrive by dusk, but they had said the same yesterday." + == normalized ) @@ -316,9 +329,12 @@ def mock_settings(): "normalization_footnotes": True, "normalization_numbers_year_style": "american", } - with patch("tests.test_text_normalization.get_runtime_settings", return_value=defaults): + with patch( + "tests.test_text_normalization.get_runtime_settings", return_value=defaults + ): yield + def test_currency_magnitude(): cases = [ ("$2 million", "two million dollars"), @@ -331,13 +347,15 @@ def test_currency_magnitude(): ("$2.50", "two dollars, fifty cents"), ("$100", "one hundred dollars"), ] - + settings = { "normalization_numbers": True, "normalization_currency": True, - "normalization_apostrophe_mode": "spacy" + "normalization_apostrophe_mode": "spacy", } - + for input_text, expected in cases: normalized = _normalize_text(input_text, normalization_overrides=settings) - assert expected.lower() in normalized.lower(), f"Failed for {input_text}: got '{normalized}'" + assert ( + expected.lower() in normalized.lower() + ), f"Failed for {input_text}: got '{normalized}'" diff --git a/tests/test_voice_cache.py b/tests/test_voice_cache.py index b0aa2ca..c428b3a 100644 --- a/tests/test_voice_cache.py +++ b/tests/test_voice_cache.py @@ -4,7 +4,11 @@ from typing import cast import pytest from abogen.constants import VOICES_INTERNAL -from abogen.voice_cache import LocalEntryNotFoundError, _CACHED_VOICES, ensure_voice_assets +from abogen.voice_cache import ( + LocalEntryNotFoundError, + _CACHED_VOICES, + ensure_voice_assets, +) from abogen.webui.conversion_runner import _collect_required_voice_ids from abogen.webui.service import Job