From 3454f73b6a91bd9c0e74f4932fb7531224ef6532 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 6 Jul 2026 00:14:20 -0400 Subject: [PATCH] fix: prune/retier no longer treat a foreign same-stem file as a rolled log prune() and retier() matched any file starting with "." (minus the live .log), with no check that the name actually has this lib's own rolled shape (.[.N].log[.gz]). Since v0.5.0 the default history_stem is the cwd basename, so a project dropping its own same-stem file into log_dir (e.g. proj.audit.log, proj.stats.json) got silently deleted by prune once it aged past backup_count, or folded into retier's tier accounting (gzipped or deleted as if it were a real roll). Candidates are now filtered through _is_rolled_name before being treated as a roll. Signed-off-by: disqualifier --- src/log_setup/rotation.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/log_setup/rotation.py b/src/log_setup/rotation.py index 87c6903..4967256 100644 --- a/src/log_setup/rotation.py +++ b/src/log_setup/rotation.py @@ -15,6 +15,7 @@ plain copy. import gzip import os +import re import shutil import time from typing import Callable, Optional, Tuple @@ -252,12 +253,17 @@ def retier(log_dir: str, stem: str, keep_uncompressed: int, keep_compressed: int ordering is by mtime, then by the roll counter parsed from the name, so a same-second burst (tied mtimes, counter-disambiguated stamps like run..log / run..1.log) still tiers newest-first rather than falling back to listdir order. + + candidates are also checked against this lib's own rolled-file shape + (`.[.N].log[.gz]`, see `_is_rolled_name`) - a foreign file that only + shares the `.` prefix (e.g. a project's own `.audit.log` dropped into + the same log_dir) is left alone rather than tiered/gzipped/deleted. """ stem = os.path.basename(stem) try: names = [ name for name in os.listdir(log_dir) - if name.startswith(f"{stem}.") and name != f"{stem}.log" + if name != f"{stem}.log" and _is_rolled_name(stem, name) ] except OSError: return @@ -305,6 +311,23 @@ def retier(log_dir: str, stem: str, keep_uncompressed: int, keep_compressed: int pass +def _rolled_name_pattern(stem: str) -> "re.Pattern": + """compiled regex matching this lib's own rolled-file shape for stem + + `.[.N].log[.gz]` - the uniform namer output from + make_history_namer/rotate_on_start (see attach_rolling). used to filter + prune/retier candidates so a foreign file merely sharing the `.` prefix + (e.g. a project's own `proj.audit.log` sitting beside its `proj..log` + rolls) is never mistaken for a roll and pruned/retiered/deleted. + """ + return re.compile(rf"^{re.escape(stem)}\.\d{{4}}-\d{{2}}-\d{{2}}_\d{{2}}-\d{{2}}-\d{{2}}(?:\.\d+)?\.log(?:\.gz)?$") + + +def _is_rolled_name(stem: str, name: str) -> bool: + """return whether name has this lib's own rolled-log shape for stem""" + return _rolled_name_pattern(stem).match(name) is not None + + def _roll_counter(path: str) -> int: """parse the same-second disambiguation counter out of a rolled filename @@ -331,6 +354,11 @@ def prune(log_dir: str, stem: str, backup_count: int) -> None: FOOTGUN: `stem` is reduced to its basename so a `name` containing a directory (e.g. "sub/run") still matches the basenamed rolled files in log_dir - else nothing matches and old files pile up forever. + + candidates are also checked against this lib's own rolled-file shape + (`.[.N].log[.gz]`, see `_is_rolled_name`) - a foreign file that only + shares the `.` prefix (e.g. a project's own `.audit.log` dropped into + the same log_dir) is left alone rather than counted and deleted as a roll. """ if backup_count <= 0: return @@ -339,7 +367,7 @@ def prune(log_dir: str, stem: str, backup_count: int) -> None: entries = [ os.path.join(log_dir, name) for name in os.listdir(log_dir) - if name.startswith(f"{stem}.") and name != f"{stem}.log" + if name != f"{stem}.log" and _is_rolled_name(stem, name) ] except OSError: return