From 76d3efad96dfd9d1681fc64c491f45529cbd9c25 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Fri, 16 Oct 2026 03:57:59 -0400 Subject: [PATCH] fix: daily roll spares cwd, failed roll keeps logging, queue formats at call T1-2: TimedRotatingFileHandler(backupCount=N) on py3.10/3.11 runs the stdlib's own retention over the live file's dir (cwd) and deleted unrelated files. pass backupCount=0; make_rotator's prune already owns retention. T1-3: an OSError from move/gzip/prune escaped into doRollover, rolloverAt never advanced and every later record was dropped. the rotator now writes one line to stderr and returns, and the stdlib reopens the live file. T2-6: queue=True deferred %-formatting to the listener thread, so a mutable arg logged its mutated value. prepare() merges msg+args on the calling thread; exc_info stays for the listener's formatter. Signed-off-by: disqualifier --- src/log_setup/rotation.py | 12 ++++++++++++ src/log_setup/setup.py | 11 +++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/log_setup/rotation.py b/src/log_setup/rotation.py index 904784a..9f0088e 100644 --- a/src/log_setup/rotation.py +++ b/src/log_setup/rotation.py @@ -16,6 +16,7 @@ import gzip import os import re import shutil +import sys import time from typing import Callable, Optional, Tuple @@ -150,10 +151,21 @@ def make_rotator( tiered mode (when `keep_uncompressed`/`keep_compressed` are given): land the rolled file PLAIN and re-tier `log_dir` - newest `keep_uncompressed` stay uncompressed, next `keep_compressed` gzipped, rest deleted. `compress`/`backup_count` are ignored. + + a failed roll (OSError from move/gzip/prune, e.g. `log_dir` deleted) is reported on + stderr and swallowed: raising into the stdlib doRollover would leave the handler + retrying the roll and dropping every later record. the stdlib then reopens the live + file and logging continues there. stderr, not logging - this runs inside emit. """ tiered = keep_uncompressed is not None or keep_compressed is not None def rotator(source: str, dest: str) -> None: + try: + _roll(source, dest) + except OSError as error: + sys.stderr.write(f"log_setup: rotation of {source!r} failed, still logging to it: {error}\n") + + def _roll(source: str, dest: str) -> None: if not os.path.exists(source): return if tiered: diff --git a/src/log_setup/setup.py b/src/log_setup/setup.py index f65cbfa..7defbfd 100644 --- a/src/log_setup/setup.py +++ b/src/log_setup/setup.py @@ -38,10 +38,15 @@ class _PreservingQueueHandler(logging.handlers.QueueHandler): listener's real formatter ever sees the record. that behavior exists to keep a record picklable across a multiprocessing.Queue; this lib only ever uses an in-process queue.Queue, so there is nothing to pickle and nothing to strip - - the listener's handler formats the untouched record exactly once. + the listener's handler formats the record exactly once, exc_info intact. + + the message IS merged with its args here, on the calling thread: deferring that to + the listener would log a mutable arg with whatever value it holds by then. """ def prepare(self, record: logging.LogRecord) -> logging.LogRecord: + record.msg = record.getMessage() + record.args = None return record @@ -168,8 +173,10 @@ def _file_handler( tiered=tiered, rotate_mode=rotate, ) elif rotate == "daily": + # backupCount=0: on py3.10/3.11 the stdlib's own retention scans the live file's + # dir (cwd) and deletes unrelated files; make_rotator's prune owns retention handler = logging.handlers.TimedRotatingFileHandler( - live_path, when="midnight", backupCount=backup_count, encoding="utf-8", + live_path, when="midnight", backupCount=0, encoding="utf-8", ) attach_rolling( handler, log_dir, compress, prune_stem=history_stem, backup_count=backup_count,