diff options
| author | historia <historiavg@proton.me> | 2026-08-28 03:16:14 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-28 03:16:14 -0400 |
| commit | d487996281f71ccd3034dd708fa099057e528b42 (patch) | |
| tree | 989d9f40f5c2dbb03e1cf570f2c463f43cf5ac76 /app/logging_kit.py | |
| parent | 5c3db8f500ff206f3a675d8f4184cb0d61f94804 (diff) | |
| download | tts-audiobook-generator-d487996281f71ccd3034dd708fa099057e528b42.tar.gz | |
feat: unified logging with logging_kit.py
Diffstat (limited to 'app/logging_kit.py')
| -rw-r--r-- | app/logging_kit.py | 131 |
1 files changed, 131 insertions, 0 deletions
diff --git a/app/logging_kit.py b/app/logging_kit.py new file mode 100644 index 0000000..c16b7ed --- /dev/null +++ b/app/logging_kit.py @@ -0,0 +1,131 @@ +"""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: + ``<prefix>_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): + ``<name>_YYYYmmdd_HHMMSS.log`` (e.g. audiocpp_build_..., audiocpp_start_...) + +The one exception is ``<name>-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 time +from datetime import datetime +from pathlib import Path + +# The single log directory (app/logs, already gitignored). +LOG_DIR = Path(__file__).resolve().parent / "logs" + +# 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 ``<prefix>_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 ``<name>_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). + + ``<name>-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 |
