aboutsummaryrefslogtreecommitdiff
path: root/app/logging_kit.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/logging_kit.py')
-rw-r--r--app/logging_kit.py131
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