fix: prune/retier no longer treat a foreign same-stem file as a rolled log

prune() and retier() matched any file starting with "<stem>." (minus the live
<stem>.log), with no check that the name actually has this lib's own rolled
shape (<stem>.<stamp>[.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 <dev@disqualifier.me>
This commit is contained in:
2026-07-06 00:14:20 -04:00
parent a6edb965d0
commit 3454f73b6a
+30 -2
View File
@@ -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.<t>.log /
run.<t>.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
(`<stem>.<stamp>[.N].log[.gz]`, see `_is_rolled_name`) - a foreign file that only
shares the `<stem>.` prefix (e.g. a project's own `<stem>.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
`<stem>.<Y-m-d_H-M-S>[.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 `<stem>.` prefix
(e.g. a project's own `proj.audit.log` sitting beside its `proj.<stamp>.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
(`<stem>.<stamp>[.N].log[.gz]`, see `_is_rolled_name`) - a foreign file that only
shares the `<stem>.` prefix (e.g. a project's own `<stem>.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