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 gzip
import os import os
import re
import shutil import shutil
import time import time
from typing import Callable, Optional, Tuple 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 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 / 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. 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) stem = os.path.basename(stem)
try: try:
names = [ names = [
name for name in os.listdir(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: except OSError:
return return
@@ -305,6 +311,23 @@ def retier(log_dir: str, stem: str, keep_uncompressed: int, keep_compressed: int
pass 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: def _roll_counter(path: str) -> int:
"""parse the same-second disambiguation counter out of a rolled filename """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. 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 "sub/run") still matches the basenamed rolled files in log_dir - else nothing
matches and old files pile up forever. 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: if backup_count <= 0:
return return
@@ -339,7 +367,7 @@ def prune(log_dir: str, stem: str, backup_count: int) -> None:
entries = [ entries = [
os.path.join(log_dir, name) os.path.join(log_dir, name)
for name in os.listdir(log_dir) 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: except OSError:
return return