Compare commits
4
Commits
d3f109db15
...
b4fd31dddc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4fd31dddc | ||
|
|
5b27df65e7 | ||
|
|
5e53a7cd6c | ||
|
|
76d3efad96 |
No files matched your search
@@ -13,12 +13,12 @@ and emit; their records flow into the handlers `log_setup` wired.
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```
|
```
|
||||||
log_setup @ git+ssh://git@git.rethinkstudios.io/rethink-public/log_setup.git@v1.0.0
|
log_setup @ git+ssh://git@git.rethinkstudios.io/rethink-public/log_setup.git@v1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
No dependencies — stdlib only.
|
No dependencies — stdlib only.
|
||||||
|
|
||||||
Drop the `@v1.0.0` suffix from the line above to install the latest unpinned.
|
Drop the `@v1.1.0` suffix from the line above to install the latest unpinned.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -85,6 +85,12 @@ setup_logging(name="latest", rotate="daily")
|
|||||||
- Retention (tier counts / `backup_count`) is unchanged — it's just keyed to the project
|
- Retention (tier counts / `backup_count`) is unchanged — it's just keyed to the project
|
||||||
stem now.
|
stem now.
|
||||||
|
|
||||||
|
**One process per live file, one `history_name` per service.** Rotation and retention
|
||||||
|
assume a single writer: two processes writing the same live file both try to roll it, and
|
||||||
|
two services sharing a `log_dir` with the same history stem prune each other's history
|
||||||
|
(each counts the other's rolls against its own `backup_count`). Give each process its own
|
||||||
|
live file, and give services that share a cwd or `log_dir` distinct `history_name`s.
|
||||||
|
|
||||||
## Tiered retention (`keep_uncompressed` / `keep_compressed`)
|
## Tiered retention (`keep_uncompressed` / `keep_compressed`)
|
||||||
|
|
||||||
The default is a flat `backup_count`: every rolled file is gzipped on roll and the oldest
|
The default is a flat `backup_count`: every rolled file is gzipped on roll and the oldest
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "log_setup"
|
name = "log_setup"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
description = "stdlib app-entry-point logging setup: live run.log, rotation, gzip, retention, consistent format"
|
description = "stdlib app-entry-point logging setup: live run.log, rotation, gzip, retention, consistent format"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
+21
-52
@@ -16,6 +16,7 @@ import gzip
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Callable, Optional, Tuple
|
from typing import Callable, Optional, Tuple
|
||||||
|
|
||||||
@@ -34,22 +35,6 @@ def _move(source: str, dest: str) -> None:
|
|||||||
shutil.move(source, dest)
|
shutil.move(source, dest)
|
||||||
|
|
||||||
|
|
||||||
def _free_dest(dest: str) -> str:
|
|
||||||
"""return `dest`, or a `.N`-suffixed variant if it (or its .gz twin) already exists
|
|
||||||
|
|
||||||
used by the tiered rotator so a second same-stamp roll doesn't clobber the earlier
|
|
||||||
file; checks both the plain and .gz forms of each candidate.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(dest) and not os.path.exists(dest + ".gz"):
|
|
||||||
return dest
|
|
||||||
counter = 1
|
|
||||||
while True:
|
|
||||||
candidate = f"{dest}.{counter}"
|
|
||||||
if not os.path.exists(candidate) and not os.path.exists(candidate + ".gz"):
|
|
||||||
return candidate
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
|
|
||||||
def _gzip_file(source: str, dest: str) -> None:
|
def _gzip_file(source: str, dest: str) -> None:
|
||||||
"""atomically gzip source into dest, then remove source - or leave source untouched
|
"""atomically gzip source into dest, then remove source - or leave source untouched
|
||||||
|
|
||||||
@@ -150,17 +135,27 @@ def make_rotator(
|
|||||||
tiered mode (when `keep_uncompressed`/`keep_compressed` are given): land the rolled
|
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
|
file PLAIN and re-tier `log_dir` - newest `keep_uncompressed` stay uncompressed, next
|
||||||
`keep_compressed` gzipped, rest deleted. `compress`/`backup_count` are ignored.
|
`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
|
tiered = keep_uncompressed is not None or keep_compressed is not None
|
||||||
|
|
||||||
def rotator(source: str, dest: str) -> 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):
|
if not os.path.exists(source):
|
||||||
return
|
return
|
||||||
if tiered:
|
if tiered:
|
||||||
# strip the namer's .gz suffix so the roll lands plain and retier decides
|
# the tiered namer (plain=True) already minted a free, plain .log name;
|
||||||
# its tier; disambiguate a same-interval collision via _free_dest
|
# retier decides its tier
|
||||||
plain_dest = _free_dest(dest[:-3] if dest.endswith(".gz") else dest)
|
_move(source, dest)
|
||||||
_move(source, plain_dest)
|
|
||||||
if log_dir is not None and prune_stem is not None:
|
if log_dir is not None and prune_stem is not None:
|
||||||
retier(log_dir, prune_stem, keep_uncompressed or 0, keep_compressed or 0, live_stem)
|
retier(log_dir, prune_stem, keep_uncompressed or 0, keep_compressed or 0, live_stem)
|
||||||
return
|
return
|
||||||
@@ -202,21 +197,8 @@ def rotate_on_start(
|
|||||||
tiered = keep_uncompressed is not None or keep_compressed is not None
|
tiered = keep_uncompressed is not None or keep_compressed is not None
|
||||||
live_stem = os.path.splitext(os.path.basename(live_path))[0]
|
live_stem = os.path.splitext(os.path.basename(live_path))[0]
|
||||||
stem = os.path.basename(history_stem) if history_stem else live_stem
|
stem = os.path.basename(history_stem) if history_stem else live_stem
|
||||||
stamp = time.strftime("%Y-%m-%d_%H-%M-%S", clock())
|
# same name + same-second collision counter as the size/daily rolls
|
||||||
suffix = ".log.gz" if (compress and not tiered) else ".log"
|
dest = make_history_namer(stem, log_dir, compress, plain=tiered, clock=clock)(live_path)
|
||||||
|
|
||||||
# a 1-second stamp collision (rapid crash-restart) is disambiguated with a
|
|
||||||
# counter; check BOTH .log and .log.gz since a tiered same-stamp roll may
|
|
||||||
# already be compressed
|
|
||||||
def _taken(path: str) -> bool:
|
|
||||||
base = path[:-3] if path.endswith(".gz") else path
|
|
||||||
return os.path.exists(base) or os.path.exists(base + ".gz")
|
|
||||||
|
|
||||||
dest = os.path.join(log_dir, f"{stem}.{stamp}{suffix}")
|
|
||||||
counter = 1
|
|
||||||
while _taken(dest):
|
|
||||||
dest = os.path.join(log_dir, f"{stem}.{stamp}.{counter}{suffix}")
|
|
||||||
counter += 1
|
|
||||||
if compress and not tiered:
|
if compress and not tiered:
|
||||||
_gzip_file(live_path, dest)
|
_gzip_file(live_path, dest)
|
||||||
else:
|
else:
|
||||||
@@ -356,26 +338,13 @@ def prune(log_dir: str, stem: str, backup_count: int, live_stem: Optional[str] =
|
|||||||
(`<stem>.<stamp>[.N].log[.gz]`, see `_is_rolled_name`) - a foreign file that only
|
(`<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
|
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.
|
the same log_dir) is left alone rather than counted and deleted as a roll.
|
||||||
|
|
||||||
|
this is `retier` with no compressed tier (so a crash-left plain twin of a .gz is
|
||||||
|
dropped first, not counted); `backup_count <= 0` leaves history alone.
|
||||||
"""
|
"""
|
||||||
if backup_count <= 0:
|
if backup_count <= 0:
|
||||||
return
|
return
|
||||||
stem = os.path.basename(stem)
|
retier(log_dir, stem, backup_count, 0, live_stem)
|
||||||
live_stem = os.path.basename(live_stem) if live_stem else None
|
|
||||||
try:
|
|
||||||
entries = [
|
|
||||||
os.path.join(log_dir, name)
|
|
||||||
for name in os.listdir(log_dir)
|
|
||||||
if name != f"{stem}.log" and _is_rolled_name(stem, name, live_stem)
|
|
||||||
]
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
files = [(p, _safe_mtime(p), _roll_counter(p)) for p in entries if os.path.isfile(p)]
|
|
||||||
files.sort(key=lambda t: (t[1], t[2]), reverse=True)
|
|
||||||
for path, _, _ in files[backup_count:]:
|
|
||||||
try:
|
|
||||||
os.remove(path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_mtime(path: str) -> float:
|
def _safe_mtime(path: str) -> float:
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ class _PreservingQueueHandler(logging.handlers.QueueHandler):
|
|||||||
listener's real formatter ever sees the record. that behavior exists to keep a
|
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
|
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 -
|
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:
|
def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
|
||||||
|
record.msg = record.getMessage()
|
||||||
|
record.args = None
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
@@ -168,8 +173,10 @@ def _file_handler(
|
|||||||
tiered=tiered, rotate_mode=rotate,
|
tiered=tiered, rotate_mode=rotate,
|
||||||
)
|
)
|
||||||
elif rotate == "daily":
|
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(
|
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(
|
attach_rolling(
|
||||||
handler, log_dir, compress, prune_stem=history_stem, backup_count=backup_count,
|
handler, log_dir, compress, prune_stem=history_stem, backup_count=backup_count,
|
||||||
|
|||||||
Reference in new issue
Block a user