From 93f106aac2d6411c80a911adac62cd12f80e58be Mon Sep 17 00:00:00 2001 From: historia Date: Sun, 30 Aug 2026 18:49:32 -0400 Subject: feat: output log path when audiobook generation fails --- app/converter/converter.py | 3 +-- app/logging_kit.py | 18 ++++++++++++++---- app/tests/test_audiobook_cli.py | 34 ++++++++++++++++++++++++++++++++-- app/tests/test_runview.py | 29 +++++++++++++++++++++++++---- app/ui/hub.py | 3 +-- app/ui/runview.py | 7 ++++++- 6 files changed, 79 insertions(+), 15 deletions(-) (limited to 'app') diff --git a/app/converter/converter.py b/app/converter/converter.py index a9ea7eb..bd5e477 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -8,7 +8,6 @@ import sys import threading import time from collections import Counter -from datetime import datetime from pathlib import Path from typing import Callable, Dict, List, Optional, Tuple @@ -89,7 +88,7 @@ def setup_logging(debug: bool = False, console: bool = True) -> None: """ LOGS_FOLDER.mkdir(parents=True, exist_ok=True) file_handler = logging.FileHandler( - LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log", + logging_kit.stream_path("audiobook", LOGS_FOLDER), encoding="utf-8", ) file_handler.setLevel(logging.DEBUG if debug else logging.INFO) diff --git a/app/logging_kit.py b/app/logging_kit.py index 0fe39c2..8950e6a 100644 --- a/app/logging_kit.py +++ b/app/logging_kit.py @@ -50,17 +50,27 @@ def log_traceback() -> None: RETENTION_DAYS = 30 +def stream_path(prefix: str, log_dir: Path = None) -> Path: + """The ``_YYYYMMDD.log`` stream path, without opening it. + + The one place the stream naming convention is spelled out; callers + point users at the file (e.g. a failure's full details) without + creating or opening it. + """ + directory = log_dir if log_dir is not None else LOG_DIR + return directory / f"{prefix}_{datetime.now():%Y%m%d}.log" + + def day_stream(prefix: str, log_dir: Path = None): """Open today's ``_YYYYMMDD.log`` stream for appending. Returns the open text handle (write through write_line so lines are flushed), or None when the directory/file cannot be opened. """ - directory = log_dir if log_dir is not None else LOG_DIR + path = stream_path(prefix, log_dir) try: - directory.mkdir(parents=True, exist_ok=True) - return (directory / f"{prefix}_{datetime.now():%Y%m%d}.log" - ).open("a", encoding="utf-8") + path.parent.mkdir(parents=True, exist_ok=True) + return path.open("a", encoding="utf-8") except OSError: return None diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py index f13cf9e..a3f0b90 100644 --- a/app/tests/test_audiobook_cli.py +++ b/app/tests/test_audiobook_cli.py @@ -26,6 +26,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) import audiobook # noqa: E402 +import logging_kit # noqa: E402 from converter import config # noqa: E402 from converter import converter as converter_mod # noqa: E402 from converter.converter import AudiobookConverter # noqa: E402 @@ -565,6 +566,10 @@ class FatalErrorReportingTests(unittest.TestCase): (log_path,) = self.log_dir.glob("audiobook_*.log") return log_path.read_text(encoding="utf-8") + def _log_path(self) -> str: + """The dated log file's full path, as the console reports it.""" + return str(logging_kit.stream_path("audiobook", self.log_dir)) + def test_expected_failure_shows_one_friendly_line(self): message = ("The audio.cpp model 'Qwen3-TTS-12Hz-1.7B-Base-GGUF' " "(family 'qwen3_tts') has no built-in speakers " @@ -573,6 +578,8 @@ class FatalErrorReportingTests(unittest.TestCase): self.assertEqual(code, 1) self.assertEqual(out.count("[FATAL]"), 1) self.assertIn(f"[FATAL] Fatal error: {message}", out) + self.assertIn(f"[INFO] Full details in the log file: " + f"{self._log_path()}", out) self.assertNotIn("Traceback (most recent call last)", out) self.assertNotIn("Traceback (most recent call last)", err) log_text = self._log_text() @@ -581,11 +588,14 @@ class FatalErrorReportingTests(unittest.TestCase): def test_expected_failure_reports_error_event(self): events = [] - code, _, _ = self._convert(ValueError("bad input"), - progress=events.append) + code, out, _ = self._convert(ValueError("bad input"), + progress=events.append) self.assertEqual(code, 1) self.assertEqual(events, [{"kind": "error", "message": "bad input"}]) + # The TUI run view owns the console and points failures at the log + # itself; no console pointer on this path. + self.assertNotIn("Full details in the log file", out) def test_unexpected_crash_also_shows_traceback(self): code, out, err = self._convert(TypeError("boom")) @@ -594,6 +604,26 @@ class FatalErrorReportingTests(unittest.TestCase): self.assertIn("Traceback (most recent call last)", err) self.assertIn("TypeError: boom", err) self.assertIn("Traceback (most recent call last)", self._log_text()) + self.assertIn(f"[INFO] Full details in the log file: " + f"{self._log_path()}", out) + + def test_failed_run_prints_the_log_path(self): + # A run that fails without raising (a book aborted the rest) ends + # with the log file path too. + preflight = MagicMock( + return_value=([self.book], [(self.book, "dune")])) + fake_class = MagicMock() + fake_class.preflight_overwrites = preflight + fake_class.return_value.run.return_value = False + out, err = io.StringIO(), io.StringIO() + with patch.object(audiobook, "setup_directories"), \ + contextlib.redirect_stdout(out), \ + contextlib.redirect_stderr(err), \ + patch.object(audiobook, "AudiobookConverter", fake_class): + code = audiobook.convert(backend="audiocpp") + self.assertEqual(code, 1) + self.assertIn(f"[INFO] Full details in the log file: " + f"{self._log_path()}", out.getvalue()) if __name__ == "__main__": diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index 3f77ed0..5c0dc38 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -333,13 +333,15 @@ class RunLoopTests(_FakeTui, unittest.TestCase): def test_stop_and_exit_stops_server_and_quits_without_keys(self): # Toggle on: the moment the run ends the server is stopped and the - # view reports quit — no key press and no prompt anywhere. + # view reports quit — no key press and no prompt anywhere. A + # successful run's summary does not point at the log file. with patch.object(runview.servers, "alive", return_value=True), \ patch.object(runview.servers, "stop") as mk_stop, \ patch.object(runview.common, "record_post_tui_notice") as mk_notice: view, screen = self.make_view([], autostart_spec="SPEC", - stop_and_exit=True) + stop_and_exit=True, + log_path="/tmp/runs/a.log") view.started_server = True view._queue.put({"kind": "book", "index": 1, "total": 1, "name": "book.txt"}) @@ -355,6 +357,7 @@ class RunLoopTests(_FakeTui, unittest.TestCase): self.assertIn("[OK] book.txt: book_test_michael.mp3", text) self.assertIn("1 of 1 book(s) generated successfully", text) self.assertIn("Elapsed time:", text) + self.assertNotIn("Full details in the log file", text) def test_stop_and_exit_leaves_external_servers_alone(self): # A server this run did not start is never stopped; the TUI still @@ -374,13 +377,15 @@ class RunLoopTests(_FakeTui, unittest.TestCase): def test_stop_and_exit_failure_summary_lists_the_error(self): # A failed ending also auto-exits; the failing book's detail line - # lands in the post-TUI summary. + # lands in the post-TUI summary, which ends with the converter's + # log file path where the full details live. with patch.object(runview.servers, "alive", return_value=True), \ patch.object(runview.servers, "stop"), \ patch.object(runview.common, "record_post_tui_notice") as mk_notice: view, screen = self.make_view([], autostart_spec="SPEC", - stop_and_exit=True) + stop_and_exit=True, + log_path="/tmp/runs/a.log") view.started_server = True view._queue.put({"kind": "book_failed", "name": "bad.txt", "error": "chunk 3 failed", @@ -392,6 +397,22 @@ class RunLoopTests(_FakeTui, unittest.TestCase): self.assertIn("[FAIL] bad.txt: bad_x.mp3", text) self.assertIn("chunk 3 failed", text) self.assertIn("0 of 1 book(s) generated successfully", text) + self.assertIn("Full details in the log file: /tmp/runs/a.log", text) + + def test_stop_and_exit_empty_failure_summary_lists_the_log(self): + # A run that errors before any book result (worker crash) still + # points the post-TUI summary at the log file. + with patch.object(runview.servers, "stop"), \ + patch.object(runview.common, + "record_post_tui_notice") as mk_notice: + view, screen = self.make_view([], stop_and_exit=True, + log_path="/tmp/runs/a.log") + view._queue.put({"kind": "error", "message": "boom"}) + view.run() + self.assertIn("No books were converted", + mk_notice.call_args[0][0]) + self.assertIn("Full details in the log file: /tmp/runs/a.log", + mk_notice.call_args[0][0]) class WorkerTests(_FakeTui, unittest.TestCase): diff --git a/app/ui/hub.py b/app/ui/hub.py index ef41d0b..fa05685 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -26,7 +26,6 @@ import json import shutil import sys import urllib.parse -from datetime import datetime from pathlib import Path from typing import Callable, Optional, Tuple @@ -1803,7 +1802,7 @@ def _prepare_run_config(backend: str, kwargs: dict info = get(backend) if info is not None: label = info.label - log_path = str(LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log") + log_path = str(logging_kit.stream_path("audiobook", LOGS_FOLDER)) # The run view points failures at this file, so make sure it exists # from the moment a run starts — even when the run dies before the # converter's setup_logging creates it. diff --git a/app/ui/runview.py b/app/ui/runview.py index b3a1ab0..19a8f64 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -479,7 +479,8 @@ class RunView(ScreenView): Output directory, one line per book with its generated file names and OK/FAIL status (plus the failure detail), a success count, and - the total elapsed time. + the total elapsed time. A failed run ends with the converter's log + file path, where the details behind the [FAIL] lines live. """ from converter.converter import AUDIOBOOKS_FOLDER lines = ["Audiobook generation finished", @@ -501,6 +502,10 @@ class RunView(ScreenView): finished = self.finished_at or self._now() elapsed = finished - (started if started is not None else finished) lines.append(f"Elapsed time: {_format_elapsed(elapsed)}") + if (self.phase == "error" or ok_count < total) \ + and self.config.log_path: + lines.append(f"Full details in the log file: " + f"{self.config.log_path}") return "\n".join(lines) _server_stopped_confirmed = False -- cgit v1.2.3