From 4179d3e23229741585b4d87dcec5d4915c1d0122 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 16 Jul 2026 09:24:41 +0800 Subject: [PATCH] feat(logging): archive historical logs daily into Archive/ At startup, previously produced logs are relocated into an Archive/ subfolder next to the active log: the project sync.log gets a -YYYY-MM-DD suffix when archived, and NSSM's nssm_*.log captures are moved as-is. The log root then only shows the current day's sync.log. Idempotent handler setup is preserved. Co-Authored-By: WorkBuddy --- src/sync/logging_setup.py | 88 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/src/sync/logging_setup.py b/src/sync/logging_setup.py index 3186857..e7e1c93 100644 --- a/src/sync/logging_setup.py +++ b/src/sync/logging_setup.py @@ -2,10 +2,89 @@ Configures the root logger with a RotatingFileHandler (10 MB x 5, UTF-8) plus a console StreamHandler. The level and log path come from ``cfg.logging``. + +Log files are managed per-day: at startup any previously produced log +(including the project's own ``sync.log`` and NSSM's ``nssm_*.log`` captures) +is relocated into an ``Archive/`` subfolder next to the active log. Where a log +lacks a timestamp, a ``-YYYY-MM-DD`` suffix is added (derived from its first +log line, falling back to mtime) so historical files carry a date. The log +root therefore only ever shows the current day's ``sync.log``. """ +import datetime import logging import logging.handlers import os +import re +import shutil + + +_LOG_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})") + + +def _first_line_date(path: str) -> str | None: + """Best-effort extraction of the first log line's YYYY-MM-DD date.""" + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + m = _LOG_DATE_RE.match(line) + if m: + return m.group(1) + except OSError: + pass + return None + + +def _archive_completed_logs(log_dir: str, active_name: str): + """Move already-produced logs out of *log_dir* into *log_dir*/Archive. + + The active log (*active_name*) is left in place only when it belongs to the + current day; an older ``sync.log`` is archived (with a ``-YYYY-MM-DD`` + suffix) so a fresh one can be opened. NSSM's own ``nssm_*.log`` captures are + timestamped already and are moved as-is. Moves are best-effort: files locked + by another process (e.g. NSSM's live handles) are skipped. + """ + archive_dir = os.path.join(log_dir, "Archive") + os.makedirs(archive_dir, exist_ok=True) + today = datetime.date.today() + + for name in os.listdir(log_dir): + src = os.path.join(log_dir, name) + if not os.path.isfile(src): + continue + if name == "Archive": + continue + if not (name.endswith(".log") or ".log." in name): + continue + # Keep NSSM's live, currently-open handles in place. + if name in ("nssm_stderr.log", "nssm_stdout.log"): + continue + + if name == active_name: + # Only archive the active log if it is from a previous day. + log_date = _first_line_date(src) + log_date = ( + datetime.date.fromisoformat(log_date) + if log_date + else datetime.date.fromtimestamp(os.path.getmtime(src)) + ) + if log_date >= today: + continue # today's log: keep appending + new_name = f"sync-{log_date.strftime('%Y-%m-%d')}.log" + else: + new_name = name + + dst = os.path.join(archive_dir, new_name) + if os.path.exists(dst): + stem, ext = os.path.splitext(new_name) + i = 2 + while os.path.exists(os.path.join(archive_dir, f"{stem}({i}){ext}")): + i += 1 + dst = os.path.join(archive_dir, f"{stem}({i}){ext}") + try: + shutil.move(src, dst) + except OSError: + # Locked by another process (e.g. NSSM holding the file open). + pass def setup_logging(cfg_dict: dict | None): @@ -13,11 +92,16 @@ def setup_logging(cfg_dict: dict | None): ``cfg_dict`` is ``SyncConfig.logging`` (a dict or None). ``level`` is a logging-level name string (default ``"INFO"``); ``path`` is the log file - path (default ``"sync.log"``). The parent directory is created if missing. + path (default ``"sync.log"``). The parent directory is created if missing, + and any previously produced logs are archived before the new handler opens. """ level = getattr(logging, (cfg_dict or {}).get("level", "INFO")) if cfg_dict else logging.INFO path = (cfg_dict or {}).get("path", "sync.log") - os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + log_dir = os.path.dirname(path) or "." + os.makedirs(log_dir, exist_ok=True) + + # Archive everything from prior runs so the root only shows today's log. + _archive_completed_logs(log_dir, os.path.basename(path)) # Idempotent: drop any handlers already attached to the root logger before # re-adding. setup_logging can be called from more than one entry point