diff --git a/README.md b/README.md index efc4e67..73c6c79 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ and emit; their records flow into the handlers `log_setup` wired. ## Install ``` -log_setup @ git+ssh://git@git.rethinkstudios.io/rethink-public/log_setup.git@v0.3.1 +log_setup @ git+ssh://git@git.rethinkstudios.io/rethink-public/log_setup.git@v0.3.2 ``` No dependencies — stdlib only. diff --git a/pyproject.toml b/pyproject.toml index 498a259..c2ed493 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "log_setup" -version = "0.3.1" +version = "0.3.2" description = "stdlib app-entry-point logging setup: live run.log, rotation, gzip, retention, consistent format" requires-python = ">=3.10" dependencies = [] diff --git a/src/log_setup/__init__.py b/src/log_setup/__init__.py index 8633ee5..699b84c 100644 --- a/src/log_setup/__init__.py +++ b/src/log_setup/__init__.py @@ -19,4 +19,4 @@ from .setup import setup_logging __all__ = ["setup_logging"] -__version__ = "0.3.1" +__version__ = "0.3.2" diff --git a/src/log_setup/rotation.py b/src/log_setup/rotation.py index 0997699..4073d2c 100644 --- a/src/log_setup/rotation.py +++ b/src/log_setup/rotation.py @@ -13,6 +13,20 @@ import time from typing import Callable, Optional, Tuple +def _move(source: str, dest: str) -> None: + """rename source to dest, falling back to copy+unlink across filesystems + + os.replace is atomic but raises OSError(EXDEV) when source and dest are on + different filesystems — exactly the container bind-mount / separate-logs-volume + case this lib targets. fall back to shutil.move (copy+unlink) so the roll still + lands instead of failing every rotation via the handler's silent handleError. + """ + try: + os.replace(source, dest) + except OSError: + shutil.move(source, dest) + + def make_namer(log_dir: str, compress: bool) -> Callable[[str], str]: """namer: redirect a rolled filename into log_dir, adding .gz when compressing @@ -46,7 +60,7 @@ def make_rotator( shutil.copyfileobj(src, dst) os.remove(source) else: - os.replace(source, dest) + _move(source, dest) if log_dir is not None and prune_stem is not None: prune(log_dir, prune_stem, backup_count) return rotator @@ -62,14 +76,21 @@ def rotate_on_start(live_path: str, log_dir: str, compress: bool, clock=time.loc return stem = os.path.splitext(os.path.basename(live_path))[0] stamp = time.strftime("%Y-%m-%d_%H-%M-%S", clock()) - dest = os.path.join(log_dir, f"{stem}.{stamp}.log") + suffix = ".log.gz" if compress else ".log" + # the stamp is 1-second resolution; two starts in the same second would collide + # and the second clobber the first. disambiguate with a numeric counter so a rapid + # crash-restart loop doesn't lose the earlier rolled file + dest = os.path.join(log_dir, f"{stem}.{stamp}{suffix}") + counter = 1 + while os.path.exists(dest): + dest = os.path.join(log_dir, f"{stem}.{stamp}.{counter}{suffix}") + counter += 1 if compress: - dest += ".gz" with open(live_path, "rb") as src, gzip.open(dest, "wb") as dst: shutil.copyfileobj(src, dst) os.remove(live_path) else: - os.replace(live_path, dest) + _move(live_path, dest) def prune(log_dir: str, stem: str, backup_count: int) -> None: diff --git a/src/log_setup/setup.py b/src/log_setup/setup.py index db0ab2b..4b6d6c4 100644 --- a/src/log_setup/setup.py +++ b/src/log_setup/setup.py @@ -12,7 +12,7 @@ import atexit import logging import logging.handlers import os -import queue +import queue as _queue from typing import Dict, Optional, Union from .formats import build_formatter @@ -26,6 +26,10 @@ _listener = None def _level_value(level: Union[int, str]) -> int: """coerce a level name or int to a logging level int (defaults to INFO)""" + if isinstance(level, bool): + # bool is an int subclass (True==1, below DEBUG) but is never a real level — + # reject it consistently with the per-module path rather than set level 1 + return logging.INFO if isinstance(level, int): return level if not isinstance(level, str): @@ -83,7 +87,9 @@ def _clear_owned(root: logging.Logger) -> None: try: handler.close() except Exception: - pass + # a handler failing to close must not abort re-setup, but log it + # rather than swallow silently (consistent with the lib's warn pattern) + log.warning("log_setup: failed to close handler %r during re-setup", handler, exc_info=True) def _tag(handler: logging.Handler) -> logging.Handler: @@ -186,7 +192,7 @@ def setup_logging( handlers.append(sh) if queue: - record_queue: "queue.Queue" = _make_queue() + record_queue: "_queue.Queue" = _make_queue() qh = _tag(logging.handlers.QueueHandler(record_queue)) root.addHandler(qh) _listener = logging.handlers.QueueListener(record_queue, *handlers, respect_handler_level=True) @@ -202,9 +208,9 @@ def setup_logging( return root -def _make_queue() -> "queue.Queue": +def _make_queue() -> "_queue.Queue": """unbounded in-memory queue for the QueueHandler -> QueueListener path""" - return queue.Queue(-1) + return _queue.Queue(-1) def _stop_listener() -> None: