From 1be2f45a0c233877636c21ac11240f3c798b355a Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 16 Oct 2026 22:22:47 +0200 Subject: [PATCH] Improved logger --- .gitea/workflows/build-local.yaml | 2 +- pyproject.toml | 2 +- simple_logger_handler/__init__.py | 9 ++- simple_logger_handler/logger_handler.py | 98 +++++++++++++++++++++---- 4 files changed, 91 insertions(+), 20 deletions(-) diff --git a/.gitea/workflows/build-local.yaml b/.gitea/workflows/build-local.yaml index 6cd4ab6..67b7230 100644 --- a/.gitea/workflows/build-local.yaml +++ b/.gitea/workflows/build-local.yaml @@ -1,4 +1,4 @@ -name: Build and Publish Package +name: Build and Publish Package via local runner on: workflow_dispatch: diff --git a/pyproject.toml b/pyproject.toml index 1e5452a..25aa22f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "simple-logger-handler" -version = "0.1.0" +version = "0.2.0" description = "Simple reusable logger setup." authors = [{ name = "Florian Gänsejunge" }] dependencies = ["python-json-logger"] diff --git a/simple_logger_handler/__init__.py b/simple_logger_handler/__init__.py index 484e95e..93cfbd8 100644 --- a/simple_logger_handler/__init__.py +++ b/simple_logger_handler/__init__.py @@ -1,3 +1,8 @@ -from .logger_handler import setup_logger, LOG_LEVEL +from .logger_handler import ( + LOG_LEVEL, + adopt_uvicorn_loggers, + configure_logging, + setup_logger, +) -__all__ = ["setup_logger"] +__all__ = ["setup_logger", "configure_logging", "adopt_uvicorn_loggers", "LOG_LEVEL"] \ No newline at end of file diff --git a/simple_logger_handler/logger_handler.py b/simple_logger_handler/logger_handler.py index cc04065..7c7ad5b 100644 --- a/simple_logger_handler/logger_handler.py +++ b/simple_logger_handler/logger_handler.py @@ -1,20 +1,86 @@ -import os import logging -from pythonjsonlogger import jsonlogger +import os +import threading + +from pythonjsonlogger.json import JsonFormatter + + +_VALID_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} +_FORMAT = "%(asctime)s %(name)s %(levelname)s %(message)s" +_NOISY_LOGGERS = ("urllib3", "httpx", "httpcore", "asyncio", "multipart") +_UVICORN_LOGGERS = ("uvicorn", "uvicorn.error", "uvicorn.access") + +_lock = threading.Lock() +_configured = False + + +def _resolve_level() -> tuple[str, bool]: + """Return (level_name, was_valid). Falls back to INFO if invalid.""" + raw = os.getenv("LOG_LEVEL", "INFO").upper() + return (raw, True) if raw in _VALID_LEVELS else ("INFO", False) + + +# Kept for backwards compatibility with `from simple_logger_handler import LOG_LEVEL` +LOG_LEVEL, _ = _resolve_level() + + +def _build_formatter() -> logging.Formatter: + if os.getenv("LOG_FORMAT", "json").lower() == "text": + return logging.Formatter(_FORMAT) # readable output for local development + return JsonFormatter(_FORMAT) + + +def configure_logging(*, force: bool = False) -> None: + """Configure the root logger once. Safe to call repeatedly and from threads. + + Environment variables: + LOG_LEVEL DEBUG | INFO | WARNING | ERROR | CRITICAL (default INFO) + LOG_FORMAT json | text (default json) + """ + global _configured + with _lock: + if _configured and not force: + return + + level, valid = _resolve_level() + root = logging.getLogger() + + # Remove only handlers we added earlier, so we never clobber + # handlers installed by pytest, Sentry, etc. + for h in list(root.handlers): + if getattr(h, "_shared_handler", False): + root.removeHandler(h) + + handler = logging.StreamHandler() + handler.setFormatter(_build_formatter()) + handler._shared_handler = True # marker for idempotency + root.addHandler(handler) + root.setLevel(level) + + for name in _NOISY_LOGGERS: + logging.getLogger(name).setLevel(max(logging.WARNING, root.level)) + + _configured = True + + if not valid: + root.warning("Invalid LOG_LEVEL %r, falling back to INFO", + os.getenv("LOG_LEVEL")) + + +def adopt_uvicorn_loggers() -> None: + """Route uvicorn's logs through the root handler (same format as everything else). + + Uvicorn installs its own handlers at startup, so call this from your app's + lifespan, which runs after uvicorn has configured itself. + """ + for name in _UVICORN_LOGGERS: + lg = logging.getLogger(name) + lg.handlers.clear() + lg.setLevel(logging.NOTSET) # inherit from root + lg.propagate = True -LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() -if LOG_LEVEL not in {"ERROR", "DEBUG", "INFO", "WARNING", "CRITICAL"}: - LOG_LEVEL = "INFO" def setup_logger(name: str) -> logging.Logger: - logger = logging.getLogger(name) - if not logger.handlers: - handler = logging.StreamHandler() - formatter = jsonlogger.JsonFormatter( - "%(asctime)s %(name)s %(levelname)s %(message)s" - ) - handler.setFormatter(formatter) - logger.addHandler(handler) - logger.setLevel(getattr(logging, LOG_LEVEL)) - logger.debug(f"Logger {name} initialized with level {LOG_LEVEL}") - return logger + """Return a named logger, ensuring logging is configured.""" + configure_logging() + return logging.getLogger(name) \ No newline at end of file