"""Ownership of app/logs: naming, handles, and retention. Every log file this app writes follows one of two conventions, and all of them are created through this module so the policy lives in one place: * Streams — continuous app activity, appended to across runs: ``_YYYYMMDD.log`` (e.g. audiobook_20260828.log, tui_20260828.log) * Artifacts — one discrete operation, kept as its own file and referenced by path afterwards (failure flashes, post-TUI notices): ``_YYYYmmdd_HHMMSS.log`` (e.g. audiocpp_build_..., audiocpp_start_...) The one exception is ``-server.log``: managed-server processes (backends.servers) spawn with their stdout/stderr attached to that file and outlive this app's runs, so it stays a per-process append file — and prune_logs never deletes it. Everything here is stdlib-only and best-effort: logging must never break the app, so an unwritable directory or a failed write degrades to a no-op. """ import logging import time import traceback from datetime import datetime from pathlib import Path # The single log directory (app/logs, already gitignored). LOG_DIR = Path(__file__).resolve().parent / "logs" # Reserved logger for file-only traceback dumps: records emitted on this # name reach the dated log file through the root handlers, while the # converter's console filter (setup_logging's _console_log_filter) drops # them, so a handled failure can keep its full traceback out of the # console without losing it from the logs. TRACEBACK_LOGGER = "app.traceback" def log_traceback() -> None: """Log the active exception's traceback to the log file only. Call from inside an ``except`` block: the record reaches the file handler but not the console, where the caller shows a single friendly message instead (tracebacks are for the logs, or for real crashes). """ logging.getLogger(TRACEBACK_LOGGER).error(traceback.format_exc()) # Stream/artifact files older than this are deleted by prune_logs (called # once per app start). Server logs and pid files are never touched. RETENTION_DAYS = 30 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 try: directory.mkdir(parents=True, exist_ok=True) return (directory / f"{prefix}_{datetime.now():%Y%m%d}.log" ).open("a", encoding="utf-8") except OSError: return None def run_artifact(name: str, log_dir: Path = None): """Create ``_YYYYmmdd_HHMMSS.log``; return ``(path, handle)``. The path is always returned so callers can point the user at it even when HANDLE is None (the directory/file could not be created). """ directory = log_dir if log_dir is not None else LOG_DIR path = directory / f"{name}_{datetime.now():%Y%m%d_%H%M%S}.log" try: directory.mkdir(parents=True, exist_ok=True) return path, path.open("w", encoding="utf-8") except OSError: return path, None def write_line(handle, text: str) -> None: """Append one line to HANDLE (None-safe), flushed; never raises.""" if handle is None: return try: handle.write(f"{text}\n") handle.flush() except (OSError, ValueError): pass class TeeWriter: """A file-like that mirrors writes to a log file and an inner stream. Used with ``contextlib.redirect_stdout`` to tee plain-console output into a persistent log without losing the original consumer (e.g. the task view's line writer). Either side may be None, and write errors are swallowed, so logging never breaks the caller. """ def __init__(self, logf=None, inner=None): self._logf = logf self._inner = inner def write(self, text) -> int: if not text: return 0 for stream in (self._logf, self._inner): if stream is not None: try: stream.write(text) except (OSError, ValueError): pass return len(text) def flush(self) -> None: for stream in (self._logf, self._inner): if stream is not None: try: stream.flush() except (OSError, ValueError, AttributeError): pass def isatty(self) -> bool: return False def prune_logs(days: int = RETENTION_DAYS, log_dir: Path = None) -> None: """Delete stream/artifact log files older than DAYS (by mtime). ``-server.log`` files are skipped — a managed server may still hold its log open, and its lifetime is not tied to this app's runs. Non-log files (pid files, anything else) are never touched. """ directory = log_dir if log_dir is not None else LOG_DIR try: entries = list(directory.iterdir()) except OSError: return cutoff = time.time() - days * 86400 for entry in entries: if not entry.name.endswith(".log") \ or entry.name.endswith("-server.log"): continue try: if entry.stat().st_mtime < cutoff: entry.unlink() except OSError: pass