From f340b976db79b987dfb33121b93c35fd21cb22b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Thu, 20 Aug 2026 20:52:08 +0300 Subject: [PATCH] feat: added colorful logging using rich, improved startup times by lazy-loading spacy, some other fixes --- .gitignore | 3 + abogen/entity_analysis.py | 22 +++- abogen/heteronym_overrides.py | 22 +++- abogen/pyqt/book_handler.py | 6 +- abogen/pyqt/gui.py | 10 +- abogen/pyqt/main.py | 163 +++++++++++++++------------ abogen/spacy_contraction_resolver.py | 11 +- abogen/utils.py | 131 ++++++++++++++++++++- abogen/webui/app.py | 108 +++++++++++------- abogen/webui/debug_tts_runner.py | 15 ++- abogen/webui/service.py | 6 +- pyproject.toml | 1 + tests/test_pending_job_metadata.py | 9 ++ 13 files changed, 359 insertions(+), 148 deletions(-) diff --git a/.gitignore b/.gitignore index 4e3a03b..1a2b6f1 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ test_assets/ dev_notes/ .claude/ .coverage + +# CodeGraph index (local, machine-specific) +.codegraph/ diff --git a/abogen/entity_analysis.py b/abogen/entity_analysis.py index 4c0b561..c123162 100644 --- a/abogen/entity_analysis.py +++ b/abogen/entity_analysis.py @@ -9,15 +9,26 @@ from collections import Counter from dataclasses import dataclass, field from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple -try: # pragma: no cover - fallback when spaCy not available during tests - import spacy # type: ignore[import-not-found] -except Exception: # pragma: no cover - spaCy optional during runtime bootstrap - spacy = None - _Language = Any # type: ignore[misc,assignment] Doc = Any # type: ignore[misc,assignment] Span = Any # type: ignore[misc,assignment] +_SPACY: Any = None +_SPACY_LOADED = False + + +def _get_spacy() -> Any: + """Import spaCy lazily (it pulls in torch/thinc, ~2s at startup).""" + global _SPACY, _SPACY_LOADED + if not _SPACY_LOADED: + _SPACY_LOADED = True + try: # pragma: no cover - fallback when spaCy not available during tests + import spacy # type: ignore[import-not-found] + except Exception: # pragma: no cover - spaCy optional during runtime bootstrap + spacy = None + _SPACY = spacy + return _SPACY + _TITLE_PREFIXES = ( "mr", @@ -167,6 +178,7 @@ def _resolve_model_name(language: str) -> str: def _load_model(language: str) -> Any: + spacy = _get_spacy() if spacy is None: raise EntityModelError( "spaCy is not available. Install spaCy to enable entity extraction." diff --git a/abogen/heteronym_overrides.py b/abogen/heteronym_overrides.py index 08cbd07..b0d4b01 100644 --- a/abogen/heteronym_overrides.py +++ b/abogen/heteronym_overrides.py @@ -5,10 +5,21 @@ import re from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple -try: # pragma: no cover - optional dependency - import spacy # type: ignore -except Exception: # pragma: no cover - spaCy may be unavailable in minimal environments - spacy = None +_SPACY: Any = None +_SPACY_LOADED = False + + +def _get_spacy() -> Any: + """Import spaCy lazily (it pulls in torch/thinc, ~2s at startup).""" + global _SPACY, _SPACY_LOADED + if not _SPACY_LOADED: + _SPACY_LOADED = True + try: # pragma: no cover - optional dependency + import spacy # type: ignore + except Exception: # pragma: no cover - spaCy may be unavailable in minimal environments + spacy = None + _SPACY = spacy + return _SPACY @dataclass(frozen=True) @@ -184,6 +195,7 @@ def _build_replacement_sentence( def _load_spacy(language: str) -> Any: + spacy = _get_spacy() if spacy is None: return None @@ -221,7 +233,7 @@ def extract_heteronym_overrides( if not lang.startswith("en"): return [] - if spacy is None: + if _get_spacy() is None: return [] nlp = _load_spacy(lang) diff --git a/abogen/pyqt/book_handler.py b/abogen/pyqt/book_handler.py index a3efe3c..0963587 100644 --- a/abogen/pyqt/book_handler.py +++ b/abogen/pyqt/book_handler.py @@ -45,9 +45,9 @@ import urllib.parse import textwrap # Setup logging -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) +from abogen.utils import setup_console_logging + +setup_console_logging() _HTML_TAG_PATTERN = re.compile(r"<[^>]+>") _LEADING_DASH_PATTERN = re.compile(r"^\s*[-–—]\s*") diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py index 892d203..6f33d2d 100644 --- a/abogen/pyqt/gui.py +++ b/abogen/pyqt/gui.py @@ -988,7 +988,12 @@ class abogen(QWidget): self.queued_items = [] self.current_queue_index = 0 - self.initUI() + from abogen.utils import timed_log + import logging + _startup_log = logging.getLogger("abogen.startup") + + with timed_log("GUI initUI (widget building)", logger=_startup_log): + self.initUI() self.speed_slider.setValue(int(self.config.get("speed", _d["speed"]) * 100)) self.update_speed_label() # Set initial selection: prefer profile, else voice @@ -1003,7 +1008,8 @@ class abogen(QWidget): if self.selected_profile_name: from abogen.voice_profiles import load_profiles - entry = load_profiles().get(self.selected_profile_name, {}) + with timed_log("voice profile load", logger=_startup_log): + entry = load_profiles().get(self.selected_profile_name, {}) if isinstance(entry, dict): self.mixed_voice_state = entry.get("voices", []) self.selected_lang = entry.get("language") diff --git a/abogen/pyqt/main.py b/abogen/pyqt/main.py index 437068a..e7d135a 100644 --- a/abogen/pyqt/main.py +++ b/abogen/pyqt/main.py @@ -1,3 +1,4 @@ +import logging import os import sys import platform @@ -6,103 +7,113 @@ import platform from abogen import shutdown # noqa: F401 shutdown.register_shutdown() +from abogen.utils import get_resource_path, setup_console_logging, timed_log # noqa: E402 + +_log = logging.getLogger("abogen.startup") +setup_console_logging() + # Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6 if platform.system() == "Windows": - import ctypes - from importlib.util import find_spec + with timed_log("PyTorch DLLs (Windows)", logger=_log): + import ctypes + from importlib.util import find_spec - try: - if ( - (spec := find_spec("torch")) - and spec.origin - and os.path.exists( - dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll") - ) - ): - ctypes.CDLL(os.path.normpath(dll_path)) - except Exception: - pass + try: + if ( + (spec := find_spec("torch")) + and spec.origin + and os.path.exists( + dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll") + ) + ): + ctypes.CDLL(os.path.normpath(dll_path)) + except Exception: + pass # Qt platform plugin detection (fixes #59) -try: - from PyQt6.QtCore import QLibraryInfo +with timed_log("Qt platform plugin detection", logger=_log): + try: + from PyQt6.QtCore import QLibraryInfo - # Get the path to the plugins directory - plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath) + # Get the path to the plugins directory + plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath) - # Normalize path to use the OS-native separators and absolute path - platform_dir = os.path.normpath(os.path.join(plugins, "platforms")) + # Normalize path to use the OS-native separators and absolute path + platform_dir = os.path.normpath(os.path.join(plugins, "platforms")) - # Ensure we work with an absolute path for clarity - platform_dir = os.path.abspath(platform_dir) + # Ensure we work with an absolute path for clarity + platform_dir = os.path.abspath(platform_dir) - if os.path.isdir(platform_dir): - os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir - print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir) - else: - print("PyQt6 platform plugins not found at", platform_dir) -except ImportError: - print("PyQt6 not installed.") + if os.path.isdir(platform_dir): + os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir + _log.info("QT_QPA_PLATFORM_PLUGIN_PATH set to: %s", platform_dir) + else: + _log.warning("PyQt6 platform plugins not found at %s", platform_dir) + except ImportError: + _log.warning("PyQt6 not installed.") -from abogen.utils import get_resource_path - # Pre-load "libxcb-cursor" on Linux (fixes #101) if platform.system() == "Linux": - arch = platform.machine().lower() - lib_filename = {"x86_64": "libxcb-cursor-amd64.so.0", "amd64": "libxcb-cursor-amd64.so.0", "aarch64": "libxcb-cursor-arm64.so.0", "arm64": "libxcb-cursor-arm64.so.0"}.get(arch) - if lib_filename: - import ctypes - try: - # Try to load the system libxcb-cursor.so.0 first - ctypes.CDLL('libxcb-cursor.so.0', mode=ctypes.RTLD_GLOBAL) - except OSError: - # System lib not available, load the bundled version - lib_path = get_resource_path('abogen.libs', lib_filename) - if lib_path: - try: - ctypes.CDLL(lib_path, mode=ctypes.RTLD_GLOBAL) - except OSError: - # If it fails (e.g. wrong glibc version on very old systems), - # we simply ignore it and hope the system has the library. - pass + with timed_log("libxcb-cursor preload (Linux)", logger=_log): + arch = platform.machine().lower() + lib_filename = {"x86_64": "libxcb-cursor-amd64.so.0", "amd64": "libxcb-cursor-amd64.so.0", "aarch64": "libxcb-cursor-arm64.so.0", "arm64": "libxcb-cursor-arm64.so.0"}.get(arch) + if lib_filename: + import ctypes + try: + # Try to load the system libxcb-cursor.so.0 first + ctypes.CDLL('libxcb-cursor.so.0', mode=ctypes.RTLD_GLOBAL) + except OSError: + # System lib not available, load the bundled version + lib_path = get_resource_path('abogen.libs', lib_filename) + if lib_path: + try: + ctypes.CDLL(lib_path, mode=ctypes.RTLD_GLOBAL) + except OSError: + # If it fails (e.g. wrong glibc version on very old systems), + # we simply ignore it and hope the system has the library. + pass # Set application ID for Windows taskbar icon if platform.system() == "Windows": - try: - from abogen.constants import PROGRAM_NAME, VERSION - import ctypes + with timed_log("Windows AppUserModelID", logger=_log): + try: + from abogen.constants import PROGRAM_NAME, VERSION + import ctypes - app_id = f"{PROGRAM_NAME}.{VERSION}" - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) - except Exception as e: - print("Warning: failed to set AppUserModelID:", e) + app_id = f"{PROGRAM_NAME}.{VERSION}" + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) + except Exception as e: + _log.warning("Failed to set AppUserModelID: %s", e) -from PyQt6.QtWidgets import QApplication -from PyQt6.QtGui import QIcon -from PyQt6.QtCore import ( - QLibraryInfo, - qInstallMessageHandler, - QtMsgType, -) +with timed_log("PyQt6 imports", logger=_log): + from PyQt6.QtWidgets import QApplication + from PyQt6.QtGui import QIcon + from PyQt6.QtCore import ( + QLibraryInfo, + qInstallMessageHandler, + QtMsgType, + ) # Add the directory to Python path sys.path.insert(0, os.path.join(os.path.dirname(__file__))) # Set Hugging Face Hub environment variables -os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry -os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds) -os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds) -os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning -from abogen.utils import load_config -if load_config().get("disable_kokoro_internet", False): - print("INFO: Kokoro's internet access is disabled.") - os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access +with timed_log("config load + HF env setup", logger=_log): + os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry + os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds) + os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds) + os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning + from abogen.utils import load_config + if load_config().get("disable_kokoro_internet", False): + _log.info("Kokoro's internet access is disabled.") + os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access -from abogen.pyqt.gui import abogen -from abogen.constants import PROGRAM_NAME, VERSION +with timed_log("GUI module import (abogen.pyqt.gui)", logger=_log): + from abogen.pyqt.gui import abogen + from abogen.constants import PROGRAM_NAME, VERSION # Set environment variables for AMD ROCm os.environ["MIOPEN_FIND_MODE"] = "FAST" @@ -150,7 +161,8 @@ if platform.system() == "Linux": def main(): """Main entry point for console usage.""" - app = QApplication(sys.argv) + with timed_log("QApplication creation", logger=_log): + app = QApplication(sys.argv) # Set application icon using get_resource_path from utils icon_path = get_resource_path("abogen.assets", "icon.ico") @@ -164,8 +176,11 @@ def main(): except AttributeError: pass - ex = abogen() - ex.show() + with timed_log("main window construction", logger=_log): + ex = abogen() + with timed_log("window show", logger=_log): + ex.show() + _log.info("App startup complete. Showing window.") sys.exit(app.exec()) diff --git a/abogen/spacy_contraction_resolver.py b/abogen/spacy_contraction_resolver.py index 7a95704..c21a64b 100644 --- a/abogen/spacy_contraction_resolver.py +++ b/abogen/spacy_contraction_resolver.py @@ -6,10 +6,9 @@ from dataclasses import dataclass from functools import lru_cache from typing import Any, Dict, Optional, Tuple -try: # pragma: no cover - optional dependency - import spacy -except Exception: # pragma: no cover - spaCy unavailable at runtime - spacy = None +# spaCy is intentionally NOT imported at module level: importing it pulls in +# thinc -> torch, which costs seconds of startup time. It is imported lazily +# inside _load_spacy_model below. # Lazy spaCy type hints to avoid a hard dependency at import time. Language = Any # type: ignore[assignment] @@ -37,7 +36,9 @@ _DEFAULT_MODEL = os.environ.get("ABOGEN_SPACY_MODEL", "en_core_web_sm") @lru_cache(maxsize=1) def _load_spacy_model(model: str = _DEFAULT_MODEL) -> Optional[Language]: - if spacy is None: + try: # pragma: no cover - optional dependency + import spacy + except Exception: # pragma: no cover - spaCy unavailable at runtime logger.debug("spaCy is not installed; skipping contraction disambiguation") return None diff --git a/abogen/utils.py b/abogen/utils.py index b1e71c6..87db257 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -6,7 +6,9 @@ import re import shutil import subprocess import sys +import time import warnings +from contextlib import contextmanager from threading import Thread from typing import Dict, Optional @@ -29,6 +31,125 @@ _load_environment() warnings.filterwarnings("ignore") +# --- Console log colorization via rich (mirrors AutoSubSync's approach) --- + +try: # rich is a declared dependency, but degrade gracefully if unavailable + from rich.console import Console + from rich.highlighter import NullHighlighter + from rich.logging import RichHandler + + _RICH_AVAILABLE = True +except Exception: # pragma: no cover - fallback to plain logging + Console = None + NullHighlighter = None + RichHandler = None + _RICH_AVAILABLE = False + + +def _console_supports_color() -> bool: + if os.environ.get("NO_COLOR"): + return False + try: + return bool(sys.stderr.isatty()) + except Exception: + return False + + +_RICH_CONSOLE = None +if Console is not None: + try: + _RICH_CONSOLE = Console(stderr=True, no_color=not _console_supports_color()) + except Exception: # pragma: no cover - defensive + _RICH_CONSOLE = None + + +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") + +if RichHandler is not None: + + class RichConsoleHandler(RichHandler): + """RichHandler with default settings, except raw ANSI escapes are + stripped from messages first (werkzeug colorizes its own log lines + when attached to a TTY; without this they render as literal "[36m" + fragments).""" + + def emit(self, record): + # Werkzeug logs its dev-server banner at INFO but hardcodes a + # "WARNING: " prefix into the message text. Promote the record so + # the level tag matches the content. + try: + message = _ANSI_ESCAPE_RE.sub("", record.getMessage()) + except Exception: # pragma: no cover - defensive + message = "" + if record.levelno < logging.WARNING and message.startswith("WARNING: "): + record.levelno = logging.WARNING + record.levelname = "WARNING" + super().emit(record) + + def render_message(self, record, message): + message = _ANSI_ESCAPE_RE.sub("", message) + if message.startswith("WARNING: "): + message = message[len("WARNING: ") :] + return super().render_message(record, message) + +else: # pragma: no cover - rich unavailable fallback + RichConsoleHandler = None # type: ignore[assignment, misc] + + +def console_handler(show_level=True): + """Build a colored console handler. Rich's RichHandler when available + (no timestamps, colored level tags), plain StreamHandler otherwise.""" + if _RICH_CONSOLE is not None and RichConsoleHandler is not None: + return RichConsoleHandler( + console=_RICH_CONSOLE, + show_path=False, + show_time=False, + rich_tracebacks=True, + ) + handler = logging.StreamHandler(sys.stderr) + prefix = "%(levelname)s - " if show_level else "" + handler.setFormatter(logging.Formatter(f"{prefix}%(message)s")) + return handler + + +def setup_console_logging(level=logging.INFO): + """Configure the root logger once with a colored console handler.""" + root = logging.getLogger() + if not root.handlers: + root.addHandler(console_handler()) + root.setLevel(level) + + +@contextmanager +def timed_log(label, logger=None, level=logging.INFO): + """Context manager that logs the wall-clock time a block of code takes. + + Used to surface which load/startup steps are slow. The elapsed time is + colorized: green < 1s, yellow 1-5s, red > 5s. + """ + log = logger or logging.getLogger(__name__) + start = time.perf_counter() + try: + yield + finally: + elapsed = time.perf_counter() - start + if _RICH_AVAILABLE and _RICH_CONSOLE is not None and not _RICH_CONSOLE.no_color: + if elapsed >= 5.0: + color = "red" + elif elapsed >= 1.0: + color = "yellow" + else: + color = "green" + log.log( + level, + "Loaded %s in %s", + f"[cyan]{label}[/cyan]", + f"[{color}]{elapsed:.2f}s[/{color}]", + extra={"markup": True, "highlighter": NullHighlighter()}, + ) + else: + log.log(level, "Loaded %s in %.2fs", label, elapsed) + def detect_encoding(file_path): try: @@ -527,9 +648,13 @@ class LoadPipelineThread(Thread): try: from abogen.domain.pipeline_factory import create_pipeline_for_job - backend = create_pipeline_for_job( - "kokoro", language=self.lang_code, use_gpu=self.use_gpu - ) + with timed_log( + f"TTS pipeline (lang={self.lang_code}, gpu={self.use_gpu})", + logger=logging.getLogger("abogen.startup"), + ): + backend = create_pipeline_for_job( + "kokoro", language=self.lang_code, use_gpu=self.use_gpu + ) self.callback(backend, None) except Exception as e: self.callback(None, str(e)) diff --git a/abogen/webui/app.py b/abogen/webui/app.py index 7754519..dec6a1e 100644 --- a/abogen/webui/app.py +++ b/abogen/webui/app.py @@ -9,11 +9,19 @@ from flask import Flask from abogen import shutdown # noqa: F401 shutdown.register_shutdown() -from abogen.utils import get_user_cache_path, get_user_output_path, get_user_settings_dir +from abogen.utils import ( + get_user_cache_path, + get_user_output_path, + get_user_settings_dir, + setup_console_logging, + timed_log, +) from .conversion_runner import run_conversion_job from .service import build_service +_logger = logging.getLogger("abogen.startup") + class _SuppressSuccessfulAccessFilter(logging.Filter): """Filter out successful (HTTP 200) werkzeug access logs.""" @@ -79,53 +87,57 @@ def _get_secret_key() -> str: def create_app(config: Optional[dict[str, Any]] = None) -> Flask: - uploads_dir, outputs_dir = _default_dirs() + with timed_log("default directories", logger=_logger): + uploads_dir, outputs_dir = _default_dirs() - app = Flask( - __name__, - static_folder="static", - template_folder="templates", - ) - base_config = { - "SECRET_KEY": _get_secret_key(), - "UPLOAD_FOLDER": str(uploads_dir), - "OUTPUT_FOLDER": str(outputs_dir), - "MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads - # Large books can submit four form fields per chapter. Werkzeug's - # defaults reject those requests before the wizard route can process - # them, even though the encoded payload is much smaller than the upload - # limit above. - "MAX_FORM_MEMORY_SIZE": 10 * 1024 * 1024, - "MAX_FORM_PARTS": 10_000, - } - if config: - base_config.update(config) - app.config.update(base_config) + with timed_log("Flask app creation + config", logger=_logger): + app = Flask( + __name__, + static_folder="static", + template_folder="templates", + ) + base_config = { + "SECRET_KEY": _get_secret_key(), + "UPLOAD_FOLDER": str(uploads_dir), + "OUTPUT_FOLDER": str(outputs_dir), + "MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads + # Large books can submit four form fields per chapter. Werkzeug's + # defaults reject those requests before the wizard route can process + # them, even though the encoded payload is much smaller than the upload + # limit above. + "MAX_FORM_MEMORY_SIZE": 10 * 1024 * 1024, + "MAX_FORM_PARTS": 10_000, + } + if config: + base_config.update(config) + app.config.update(base_config) - service = build_service( - runner=run_conversion_job, - output_root=Path(app.config["OUTPUT_FOLDER"]), - uploads_root=Path(app.config["UPLOAD_FOLDER"]), - ) + with timed_log("conversion service (incl. queue state load)", logger=_logger): + service = build_service( + runner=run_conversion_job, + output_root=Path(app.config["OUTPUT_FOLDER"]), + uploads_root=Path(app.config["UPLOAD_FOLDER"]), + ) app.extensions["conversion_service"] = service - from abogen.webui.routes import ( - main_bp, - jobs_bp, - settings_bp, - voices_bp, - entities_bp, - books_bp, - api_bp, - ) + with timed_log("blueprint registration", logger=_logger): + from abogen.webui.routes import ( + main_bp, + jobs_bp, + settings_bp, + voices_bp, + entities_bp, + books_bp, + api_bp, + ) - app.register_blueprint(main_bp) - app.register_blueprint(jobs_bp, url_prefix="/jobs") - app.register_blueprint(settings_bp, url_prefix="/settings") - app.register_blueprint(voices_bp, url_prefix="/voices") - app.register_blueprint(entities_bp, url_prefix="/overrides") - app.register_blueprint(books_bp, url_prefix="/find-books") - app.register_blueprint(api_bp, url_prefix="/api") + app.register_blueprint(main_bp) + app.register_blueprint(jobs_bp, url_prefix="/jobs") + app.register_blueprint(settings_bp, url_prefix="/settings") + app.register_blueprint(voices_bp, url_prefix="/voices") + app.register_blueprint(entities_bp, url_prefix="/overrides") + app.register_blueprint(books_bp, url_prefix="/find-books") + app.register_blueprint(api_bp, url_prefix="/api") global _access_log_filter_attached if not _access_log_filter_attached: @@ -137,6 +149,16 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask: def main() -> None: + setup_console_logging() + # Route Flask's dev-server banner through our logger instead of click.echo. + import flask.cli as flask_cli + + def _show_server_banner(debug, app_import_path): + _logger.info(" * Serving Flask app %r", app_import_path) + _logger.info(" * Debug mode: %s", "on" if debug else "off") + + flask_cli.show_server_banner = _show_server_banner + app = create_app() host = os.environ.get("ABOGEN_HOST", "0.0.0.0") port = int(os.environ.get("ABOGEN_PORT", "8808")) diff --git a/abogen/webui/debug_tts_runner.py b/abogen/webui/debug_tts_runner.py index e1e1afe..3cee0b7 100644 --- a/abogen/webui/debug_tts_runner.py +++ b/abogen/webui/debug_tts_runner.py @@ -47,10 +47,17 @@ def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str def _load_pipeline(language: Language, use_gpu: bool) -> Any: - device = "cpu" - if use_gpu: - device = _select_device() - return create_pipeline("kokoro", language=language, device=device) + import logging + from abogen.utils import timed_log + + with timed_log( + f"TTS pipeline (lang={language}, gpu={use_gpu})", + logger=logging.getLogger("abogen.startup"), + ): + device = "cpu" + if use_gpu: + device = _select_device() + return create_pipeline("kokoro", language=language, device=device) def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]: diff --git a/abogen/webui/service.py b/abogen/webui/service.py index 087017b..92f3c8d 100644 --- a/abogen/webui/service.py +++ b/abogen/webui/service.py @@ -17,7 +17,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping from abogen.domain.metadata_helpers import normalize_metadata_map from abogen.domain.enums import Language -from abogen.utils import get_internal_cache_path, get_user_settings_dir +from abogen.utils import console_handler, get_internal_cache_path, get_user_settings_dir @@ -32,9 +32,7 @@ STATE_VERSION = 8 _JOB_LOGGER = logging.getLogger("abogen.jobs") if not _JOB_LOGGER.handlers: - handler = logging.StreamHandler(sys.stdout) - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")) - _JOB_LOGGER.addHandler(handler) + _JOB_LOGGER.addHandler(console_handler()) _JOB_LOGGER.propagate = False _JOB_LOGGER.setLevel(logging.DEBUG) diff --git a/pyproject.toml b/pyproject.toml index 884cd98..a24e88f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "num2words>=0.5.13", "httpx>=0.27.0", "PyQt6>=6.5.0", + "rich>=13.0.0", ] classifiers = [ diff --git a/tests/test_pending_job_metadata.py b/tests/test_pending_job_metadata.py index 8d84fd9..7b72ca3 100644 --- a/tests/test_pending_job_metadata.py +++ b/tests/test_pending_job_metadata.py @@ -4,6 +4,10 @@ from pathlib import Path from types import SimpleNamespace +_real_routes = sys.modules.get("abogen.webui.routes") +# Import routes.utils.form without executing routes/__init__.py (which imports +# every blueprint). Use a temporary namespace package, then restore the real +# module so later tests can still `from abogen.webui.routes import ...`. routes_package = types.ModuleType("abogen.webui.routes") routes_package.__path__ = [ str(Path(__file__).parents[1] / "abogen" / "webui" / "routes") @@ -15,6 +19,11 @@ from abogen.webui.routes.utils.form import ( # noqa: E402 load_settings, ) +if _real_routes is not None: + sys.modules["abogen.webui.routes"] = _real_routes +else: + del sys.modules["abogen.webui.routes"] + def test_user_metadata_overrides_extraction_fallback(tmp_path: Path) -> None: extraction = SimpleNamespace(