diff --git a/README.md b/README.md index dc50b55..25463e7 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.2.0 +log_setup @ git+ssh://git@git.rethinkstudios.io/rethink-public/log_setup.git@v0.3.0 ``` No dependencies — stdlib only. @@ -89,7 +89,8 @@ logging.getLogger("bot.core").info("ready", extra={"monitor": "heartbeat"}) setup_logging( name="run", # base -> run.log (the live file at cwd) log_dir="logs", # rotated/compressed copies live here (created if absent) - level="INFO", # root level (str name or logging constant) + level="INFO", # root level everything inherits (str name or logging constant) + module_levels=None, # {logger_name: level} per-logger overrides (exact name match) rotate="daily", # "daily" | "size" | "on_start" | None backup_count=14, # rotated files to keep (older auto-deleted) max_bytes=10_000_000, # only for rotate="size" @@ -102,6 +103,47 @@ setup_logging( ) -> logging.Logger # returns the configured root logger ``` +## Quieting noisy dependencies (`module_levels`) + +`level` is the **root default** — every logger inherits it. `module_levels` is an +optional `{logger_name: level}` map of **per-logger overrides** applied at setup, the +standard "turn down the chatty dependency while my own code stays at INFO" case: + +```python +setup_logging( + name="run", + level="INFO", # our code logs at INFO + module_levels={ + "motor": "WARNING", # quiet the driver + "pymongo": "WARNING", + "aiohttp": "WARNING", # also quiets aiohttp.client / aiohttp.access (hierarchy) + }, +) +``` + +- **Exact-name match — names are NOT discovered.** It calls + `logging.getLogger(name).setLevel(level)` for exactly the name you give. There's no + smart find of noisy modules; you name the loggers. A typo (`"moter"`) silently + configures a logger nothing uses — no error, no effect. Get the names right. +- **Hierarchy applies** (the one "smart" part, and it's just stdlib): naming a **parent** + quiets its whole subtree. `"aiohttp"` also quiets `aiohttp.client`, `aiohttp.access`, + etc. — the way to catch sub-loggers without listing each. +- **str or int** per entry (`"WARNING"` or `logging.WARNING`) — same normalization as the + root `level`. +- **Never crashes:** a bad level for one entry is **skipped with a warning**; the other + entries and the rest of setup still apply. Consistent with the never-crash-over-logging + rule. +- `None`/`{}` (default) → no overrides; existing callers are unaffected. + +Common noisy library logger names: `motor`, `pymongo`, `aiohttp` (parent quiets +`aiohttp.client`/`aiohttp.access`), `discord` / `discord.*`, `asyncio`, `urllib3`. Check +a lib's *actual* logger name — some log under a name different from their package. + +This already works without the lib (`logging.getLogger("motor").setLevel(WARNING)` after +setup does the same via stdlib hierarchy). The param's value is ergonomic: it keeps the +overrides in the **one** `setup_logging` call at the entry point instead of scattering +`setLevel` calls afterward — which is the whole point of `log_setup`. + ## Async-friendly (`queue=True`) For async-heavy apps, `queue=True` routes records through a stdlib `QueueHandler` to a diff --git a/pyproject.toml b/pyproject.toml index 0acaa09..411437d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "log_setup" -version = "0.2.0" +version = "0.3.0" 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 048c2e5..c8eb6d6 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.2.0" +__version__ = "0.3.0" diff --git a/src/log_setup/setup.py b/src/log_setup/setup.py index d0bf432..db0ab2b 100644 --- a/src/log_setup/setup.py +++ b/src/log_setup/setup.py @@ -13,7 +13,7 @@ import logging import logging.handlers import os import queue -from typing import Optional, Union +from typing import Dict, Optional, Union from .formats import build_formatter from .rotation import attach_rolling, prune, rotate_on_start @@ -36,6 +36,41 @@ def _level_value(level: Union[int, str]) -> int: return resolved if isinstance(resolved, int) else logging.INFO +def _strict_level_value(level: Union[int, str]) -> Optional[int]: + """coerce a level name or int to a logging level int, or None if invalid + + unlike `_level_value` (which falls back to INFO for the root `level`), this reports + an invalid value as None so the per-module path can skip + warn rather than silently + apply INFO to a logger the caller named with a typo'd level + """ + if isinstance(level, bool): + return None + if isinstance(level, int): + return level + if not isinstance(level, str): + return None + resolved = logging.getLevelName(level.upper()) + return resolved if isinstance(resolved, int) else None + + +def _apply_module_levels(module_levels: Optional[Dict[str, Union[int, str]]]) -> None: + """set per-logger level overrides by exact logger name, never crashing + + each name->level entry calls `logging.getLogger(name).setLevel()`. names are + matched exactly (no discovery); stdlib hierarchy still applies, so a parent name + quiets its whole subtree. a bad level for one entry is skipped with a warning so the + other entries and the rest of setup still proceed. + """ + if not module_levels: + return + for mod_name, raw_level in module_levels.items(): + value = _strict_level_value(raw_level) + if value is None: + log.warning("log_setup: invalid level %r for logger %r; skipping", raw_level, mod_name) + continue + logging.getLogger(mod_name).setLevel(value) + + def _clear_owned(root: logging.Logger) -> None: """remove only the handlers this lib previously added; leave app handlers alone""" global _listener @@ -84,6 +119,7 @@ def setup_logging( name: str = "run", log_dir: str = "logs", level: Union[int, str] = "INFO", + module_levels: Optional[Dict[str, Union[int, str]]] = None, rotate: Optional[str] = "daily", backup_count: int = 14, max_bytes: int = 10_000_000, @@ -97,6 +133,15 @@ def setup_logging( """configure the root logger for the whole process and return it `name` -> .log live file at cwd; rolled/compressed copies go to `log_dir`. + `level` is the root default every logger inherits. `module_levels` is an optional + map of exact logger name -> level applied after the root is set, the ergonomic way + to quiet noisy dependencies (e.g. {"motor": "WARNING", "aiohttp": "WARNING"}) from + the one setup call instead of scattering `getLogger(...).setLevel(...)` afterwards — + it's stdlib hierarchy under the hood, not new capability. names match EXACTLY (no + discovery: a typo'd name silently configures an unused logger), but stdlib hierarchy + applies, so naming a parent ("aiohttp") quiets its whole subtree (aiohttp.client, + aiohttp.access, ...). each entry accepts a str or int level; a bad value for one + entry is skipped with a warning and never aborts the others or the setup. `rotate` is "daily" (default), "size", "on_start", or None. `console=True` adds a stdout handler (off by default — the file is the output). `queue=True` routes records through a background QueueListener so file I/O never blocks the caller (the listener @@ -113,6 +158,7 @@ def setup_logging( root = logging.getLogger() root.setLevel(_level_value(level)) + _apply_module_levels(module_levels) _clear_owned(root) formatter = build_formatter(output, fmt, datefmt)