From 2759eef7ef38188c7efdbc013b0c47543f97f3c4 Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 11 Aug 2026 00:04:17 +0800 Subject: [PATCH 1/3] feat!: v2 - lazy init, catch decorator, Slack alerts, rotation The v1 package could not be used without wrapping it. Every consumer had to build its own lazy holder, because `Logger()` created directories and four files while being constructed and `@log.error` had to work as a decorator at import time. It also read no environment variables, had no way to log an error *message* (the name belonged to the decorator), and printed a random uuid4 fragment that told a single-process program nothing. - Lazy: constructing a Logger touches nothing; handlers are built on the first emit and files open on demand, so an unused level leaves no empty file. - `from loggerhelper import log` plus `configure(log_dir=...)`; settings resolve argument > environment > default, with an injectable env mapping for tests. - `error()`, `warning()`, `debug()`, `exception()` are ordinary message methods now. `warn()` and `@error` remain as deprecated shims. - `@log.catch` logs one record carrying the traceback, names the definition and the line that actually raised, works on `async def`, and re-raises by default. - Levels are documented by blast radius, which is what makes the decorator's ERROR default correct without deciding per function. - The uuid field becomes `module.function:line` of the call site. - Rotation by size and by day, retention cleanup, and a fix for the date being frozen at construction; DEBUG finally gets a file. - Optional Slack alerts behind a bounded queue and a background thread, with repeat collapsing so an error loop cannot flood the channel. - Colour is applied to fields rather than by str.replace over the rendered line, and is switched off when the stream is not a terminal. - Package renamed to lowercase to match the build config, hatchling replaces poetry-core, implementation moved under `_internal/`. - First tests (121), pre-commit, and CI across Python 3.9/3.12/3.13. BREAKING CHANGE: `@log.catch` re-raises where `@logs.error` swallowed, and log files are no longer pre-created empty. Both old spellings still work. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 73 +++ .gitignore | 8 +- .pre-commit-config.yaml | 28 + .python-version | 1 + README.md | 200 ++++++- main.py | 32 +- pyproject.toml | 47 +- src/LoggerHelper/__init__.py | 1 - src/LoggerHelper/format.py | 46 -- src/LoggerHelper/handler.py | 33 -- src/LoggerHelper/logger.py | 68 --- src/loggerhelper/__init__.py | 38 ++ src/loggerhelper/_internal/__init__.py | 8 + src/loggerhelper/_internal/config.py | 221 ++++++++ src/loggerhelper/_internal/decorators.py | 97 ++++ src/loggerhelper/_internal/format.py | 170 ++++++ src/loggerhelper/_internal/handler.py | 149 +++++ .../_internal/notifiers/__init__.py | 7 + src/loggerhelper/_internal/notifiers/slack.py | 267 +++++++++ src/loggerhelper/logger.py | 292 ++++++++++ src/loggerhelper/py.typed | 0 tests/_internal/test_config.py | 143 +++++ tests/_internal/test_decorators.py | 188 ++++++ tests/_internal/test_format.py | 112 ++++ tests/_internal/test_handler.py | 141 +++++ tests/_internal/test_slack.py | 251 ++++++++ tests/conftest.py | 58 ++ tests/test_compat.py | 57 ++ tests/test_logger.py | 189 +++++++ tests/test_public_api.py | 33 ++ uv.lock | 534 ++++++++++++++++++ 31 files changed, 3298 insertions(+), 194 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version delete mode 100644 src/LoggerHelper/__init__.py delete mode 100644 src/LoggerHelper/format.py delete mode 100644 src/LoggerHelper/handler.py delete mode 100644 src/LoggerHelper/logger.py create mode 100644 src/loggerhelper/__init__.py create mode 100644 src/loggerhelper/_internal/__init__.py create mode 100644 src/loggerhelper/_internal/config.py create mode 100644 src/loggerhelper/_internal/decorators.py create mode 100644 src/loggerhelper/_internal/format.py create mode 100644 src/loggerhelper/_internal/handler.py create mode 100644 src/loggerhelper/_internal/notifiers/__init__.py create mode 100644 src/loggerhelper/_internal/notifiers/slack.py create mode 100644 src/loggerhelper/logger.py create mode 100644 src/loggerhelper/py.typed create mode 100644 tests/_internal/test_config.py create mode 100644 tests/_internal/test_decorators.py create mode 100644 tests/_internal/test_format.py create mode 100644 tests/_internal/test_handler.py create mode 100644 tests/_internal/test_slack.py create mode 100644 tests/conftest.py create mode 100644 tests/test_compat.py create mode 100644 tests/test_logger.py create mode 100644 tests/test_public_api.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b0de74 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +permissions: + contents: read + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: pre-commit/action@v3.0.1 + + bandit: + name: Bandit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # Pinned to .pre-commit-config.yaml so local and CI agree. + # -lll/-iii = high severity, high confidence. This is a library other + # projects install, so findings fail the build rather than being noted. + - name: Run Bandit + run: | + pip install bandit==1.7.8 + bandit -r src -lll -iii + + test: + name: Test (${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # requires-python is ">=3.9"; the oldest and newest supported versions + # are the ones that actually catch a broken promise. + python-version: ["3.9", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Sync dependencies + run: uv sync --all-extras --dev --python ${{ matrix.python-version }} + - name: Run tests + run: uv run --python ${{ matrix.python-version }} pytest tests/ + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + # The package directory name only has to match the build config on a + # case-sensitive filesystem, so this check has to run on Linux. + - name: Build the wheel + run: uv build + - name: Import it from a clean environment + run: uv run --no-project --with dist/*.whl python -c "import loggerhelper; print(loggerhelper.__version__)" diff --git a/.gitignore b/.gitignore index 1ca22fb..ba3cb2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ dist/ -__pycache__ \ No newline at end of file +__pycache__/ +.venv/ +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +log/ +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e37a604 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + # Developed on Windows, built on Linux; keep the line endings from + # showing up as a whole-file diff. + - id: mixed-line-ending + args: [--fix=lf] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.4 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + # Version pinned to match the CI job, so local and CI agree on findings. + - repo: https://github.com/PyCQA/bandit + rev: 1.7.8 + hooks: + - id: bandit + args: ["-lll"] + exclude: ^tests/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md index 19eb13a..ac533ba 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,197 @@ # LoggerHelper -A lightweight yet expandable logging library with: +A small logging package with no dependencies: -- Colored console logging -- Per-level log files (INFO.log, WARNING.log, ERROR.log, CRITICAL.log) -- Customizable log directory -- Decorator-based error handling -- Fully extendable (Telegram, Slack, Email handlers can be added easily) +- Coloured console output, plain text in files +- One file per level under a dated directory, rotated by size and by day +- A decorator that logs an escaping exception and names the definition it escaped from +- Optional Slack alerts, attached only when a webhook is configured -## Build +## Install ```bash -pip list | grep build # check if build is installed -python -m build +pip install loggerhelper ``` -## Requirements +Requires Python 3.9 or newer. -- Python >= 3.7 +## Usage -## Installation +```python +from loggerhelper import log -pip install loggerhelper +log.info("System started") +log.warning("retry 2 succeeded, carrying on") +log.error("could not reach the queue") +log.critical("configuration is missing, shutting down") +log.info("took %s ms", elapsed) # lazy formatting, as in `logging` -## Usage +try: + parse(row) +except ValueError: + log.exception("bad row, skipping") # attaches the active traceback +``` + +That is the whole setup. Nothing is created on disk until the first line is +written, so importing this costs nothing. + +To choose where the files go: + +```python +from loggerhelper import configure + +configure(log_dir="./logs") +``` + +Everything else has a default. Call `configure()` before the first log line; +calling it later is allowed and says so in the log. + +### The error decorator + +```python +from loggerhelper import log + +@log.catch +def handle(request): + ... +``` + +An exception escaping `handle` is logged at `ERROR` with its traceback, and then +re-raised. The message names the definition and the line that actually raised: + +``` +[2026-08-11 09:14:02]-ERROR-(service.pool.acquire:87)=> ConnectionRefusedError in service.pool.AdapterPool.acquire (pool.py:87): [Errno 111] refused +``` + +Options: + +```python +@log.catch(level="CRITICAL") # start-up paths, where failure ends the process +@log.catch(reraise=False, default=[]) # swallow and return a fallback instead +@log.catch(exclude=(KeyError,)) # let these through without logging +``` + +`async def` works too. Decorating never builds the logger, so this is safe at +import time. + +### Choosing a level + +Pick by **blast radius** -- what the failure costs -- not by how important the +function is. That is what makes the decorator's default correct without you +having to decide per function. + +| Level | The event | Example | +| ---------- | ------------------------------------------------------- | --------------------------------------------- | +| `DEBUG` | Detail only useful while working on the code | the SQL that was built, a cache hit | +| `INFO` | A normal lifecycle event | service started, connection established | +| `WARNING` | Degraded, but **this operation still succeeded** | a retry worked, a fallback was used | +| `ERROR` | **This operation failed**; the service is still running | an exception escaped a request handler | +| `CRITICAL` | **The process or service cannot continue** | required config missing, connection pool dead | + +The line that follows from this: an unhandled exception escaping a function +means that operation failed, so `@log.catch` defaults to `ERROR`. Reserve +`level="CRITICAL"` for start-up and other paths where the failure ends the +process. `WARNING` is for things that recovered -- if it did not recover, it is +an `ERROR`. + +### Slack alerts + +Set a webhook and alerts start; set nothing and the handler is never attached. + +```bash +export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." +export SLACK_LOG_LEVEL="WARNING" # optional, this is the default +export SLACK_ENV="prod" # optional tag shown on the alert +``` + +Sending happens on a background thread behind a bounded queue, so logging never +blocks on the network and a Slack outage cannot slow the application down. +Repeats of the same failure (same level, same origin, same exception type) are +collapsed into a count rather than flooding the channel, and a failing webhook +is reported to stderr once and then ignored. + +### Settings + +Explicit argument wins, then the environment variable, then the default. + +| Variable | Default | Meaning | +| -------------------- | -------------------- | ------------------------------------------------------------------- | +| `LOG_DIR` | `/log` | Where the dated directories go | +| `LOG_LEVEL` | `INFO` | Threshold | +| `LOG_NAME` | unset | Tag on every line; separates one logger from another | +| `LOG_MAX_BYTES` | `52428800` (50 MB) | Per-file cap before rotation; `0` disables | +| `LOG_BACKUP_COUNT` | `5` | Rotated copies kept | +| `LOG_RETENTION_DAYS` | `30` | Dated directories older than this are deleted; `0` keeps everything | +| `LOG_COLOR` | `auto` | `auto` / `always` / `never` | +| `SLACK_WEBHOOK_URL` | unset | Enables Slack when set. Must be `https://` | +| `SLACK_LOG_LEVEL` | `WARNING` | Threshold for alerts | +| `SLACK_ENV` | unset | Deployment tag on alerts | + +`configure(load_dotenv=True)` applies the nearest `.env` first. It is off by +default: a library should not edit the environment behind the application's back. + +Multiple loggers writing to different places: ```python from loggerhelper import Logger -# Auto-detects project root and saves logs to ./log or PROJECT_ROOT/log -logs = Logger() +audit = Logger(log_dir="./audit", name="audit") +``` + +Instances are keyed by `name`, which is what stops the same line being written +twice when a module is imported more than once. + +### On disk + +``` +log/ +└── 2026-08-11/ + ├── DEBUG.log + ├── INFO.log + ├── WARNING.log + ├── ERROR.log + └── CRITICAL.log +``` -# Or specify custom directory -# logs = Logger(log_dir="./logs", level="INFO") +Only levels that were actually used get a file. The date is decided when a +record is written, so a process that outlives midnight rolls into the new +directory. -logs.info("System started") -logs.warn("Be careful") -logs.critical("Unexpected shutdown") +## Upgrading from 1.x -@logs.error -def test(): - raise Exception("example") +| 1.x | 2.x | Note | +| ----------------------- | ------------------------------ | ---------------------------------------------------------- | +| `logs = Logger()` | `from loggerhelper import log` | Or keep constructing your own; both work | +| `logs.warn(msg)` | `log.warning(msg)` | `warn` still works, with a `DeprecationWarning` | +| `@logs.error` | `@log.catch` | `@logs.error` still works, with a `DeprecationWarning` | +| `logs.error("message")` | `log.error("message")` | This *raised* in 1.x -- the name belonged to the decorator | -test() +Three behaviour changes to know about: + +- **`@log.catch` re-raises.** `@logs.error` swallowed the exception and returned + `None`, which is indistinguishable from a real result. The deprecated + `@logs.error` keeps the old behaviour; `@log.catch(reraise=False)` is the + explicit way to ask for it. +- **The `(a3f1)` field is gone.** It was four hex characters of `uuid4`, constant + for the life of the process and carrying nothing a single-process program could + use. It is now `module.function:line` of the call site. +- **Log files are created on demand.** 1.x created all four files, empty, while + the `Logger` was being constructed. A level that is never used now has no file + at all. Code that asserted on an empty file needs to allow for its absence: + + ```python + assert not path.exists() or not path.read_text() # nothing logged at this level + ``` + +## Development + +```bash +uv sync --dev +uv run pre-commit install +uv run pytest ``` -**Console will show colored output, and log files will be created by level.** \ No newline at end of file +## License + +MIT diff --git a/main.py b/main.py index b801a6d..c7a6e91 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,27 @@ -from src.LoggerHelper import Logger +"""Manual smoke test: run `uv run python main.py` and look at ./log.""" -logs = Logger("./log", "INFO") +from loggerhelper import configure, log -logs.info("info") -logs.warn("test") -logs.critical("test") +configure(log_dir="./log", level="DEBUG") -@logs.error -def test(): - raise Exception("test") +log.debug("cache hit for key=42") +log.info("System started") +log.warning("retry 2 succeeded, carrying on") +log.critical("configuration is missing, shutting down") -test() \ No newline at end of file + +@log.catch(reraise=False) +def broken(): + return 1 / 0 + + +broken() + + +class Pool: + @log.catch(reraise=False) + def acquire(self): + raise ConnectionError("no route to host") + + +Pool().acquire() diff --git a/pyproject.toml b/pyproject.toml index d09da79..7560c1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,47 @@ [project] name = "loggerhelper" -version = "1.0.0" -description = "Colorful logger with split log files and extensible architecture" +version = "2.0.0" +description = "Colorful logger with per-level log files, error decorator and optional Slack alerts" authors = [ - {name = "Marcus",email = "marcus.lin@stockfeel.com.tw"} + {name = "Marcus", email = "marcus.lin@stockfeel.com.tw"} ] license = {text = "MIT"} readme = "README.md" -requires-python = ">=3.7" -dependencies = [ +requires-python = ">=3.9" +dependencies = [] + +[project.urls] +Homepage = "https://github.com/Marcus/LoggerPackage" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/loggerhelper"] + +[dependency-groups] +dev = [ + "pytest>=8.2", + "pytest-asyncio>=0.23", + "pre-commit>=3.7", + "ruff>=0.4", ] -[tool.poetry] -packages = [{ include = "loggerhelper", from = "src" }] +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = ["src"] +testpaths = ["tests"] +filterwarnings = ["error::DeprecationWarning"] +[tool.ruff] +line-length = 100 +target-version = "py39" -[build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.ruff.lint.isort] +# The package is installed into the dev venv, so without this ruff sorts it as +# third-party and the import order flips depending on whether it is installed. +known-first-party = ["loggerhelper"] diff --git a/src/LoggerHelper/__init__.py b/src/LoggerHelper/__init__.py deleted file mode 100644 index dd0a8a7..0000000 --- a/src/LoggerHelper/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .logger import Logger \ No newline at end of file diff --git a/src/LoggerHelper/format.py b/src/LoggerHelper/format.py deleted file mode 100644 index b1c14d2..0000000 --- a/src/LoggerHelper/format.py +++ /dev/null @@ -1,46 +0,0 @@ -import logging - -class Color: - RESET = "\033[0m" - BOLD = "\033[1m" - - GREY = "\033[90m" - GREEN = "\033[32m" - YELLOW = "\033[33m" - RED = "\033[31m" - CRITICAL_RED = "\033[91m" - -LEVEL_COLORS = { - "DEBUG": Color.GREY, - "INFO": Color.GREEN, - "WARNING": Color.YELLOW, - "ERROR": Color.RED, - "CRITICAL": Color.CRITICAL_RED, -} - -class ColorFormatter(logging.Formatter): - """Console formatter — Adds color but does NOT modify record attributes.""" - - def format(self, record): - original = super().format(record) - - color = LEVEL_COLORS.get(record.levelname, "") - - colored = original.replace( - f"-{record.levelname}-", - f"-{color}{record.levelname}{Color.RESET}-" - ) - - timestamp = self.formatTime(record, self.datefmt) - colored = colored.replace( - f"[{timestamp}]", - f"[{color}{timestamp}{Color.RESET}]" - ) - - return colored - -class PlainFormatter(logging.Formatter): - """File formatter — No colors.""" - pass - - \ No newline at end of file diff --git a/src/LoggerHelper/handler.py b/src/LoggerHelper/handler.py deleted file mode 100644 index 194366c..0000000 --- a/src/LoggerHelper/handler.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -import logging -from datetime import datetime -from .format import ColorFormatter, PlainFormatter - -def get_file_handler(level: str, log_dir: str, base_format: str): - """File handler — 1 file per level, no colors.""" - current_day = datetime.now().strftime("%Y-%m-%d") - save_path = os.path.join(log_dir, current_day) - - os.makedirs(save_path, exist_ok=True) - - file_path = os.path.join(save_path, f"{level}.log") - handler = logging.FileHandler(file_path, encoding="utf-8") - - target_level = logging._nameToLevel[level] - handler.setLevel(target_level) - handler.addFilter(lambda record: record.levelno == target_level) - - formatter = PlainFormatter(base_format, "%Y-%m-%d %H:%M:%S") - handler.setFormatter(formatter) - - return handler - -def get_console_handler(base_format: str, level="INFO"): - """Console handler — Adds colors.""" - formatter = ColorFormatter(base_format, "%Y-%m-%d %H:%M:%S") - - handler = logging.StreamHandler() - handler.setLevel(level) - handler.setFormatter(formatter) - - return handler diff --git a/src/LoggerHelper/logger.py b/src/LoggerHelper/logger.py deleted file mode 100644 index 23ec46b..0000000 --- a/src/LoggerHelper/logger.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -import uuid -import functools -from pathlib import Path -from traceback import format_exc - -from .handler import get_console_handler, get_file_handler - - -class Logger: - - def __init__(self, log_dir: str = None, level="INFO"): - """ - log_dir: custom log directory for users. If None, auto-detected from project root. - level: log level ("INFO", "WARNING", "ERROR", "CRITICAL") - """ - - if log_dir is None: - log_dir = self._detect_log_dir() - - self.log_dir = log_dir - self._logger = logging.getLogger(f"loggerhelper-{uuid.uuid4().hex[:6]}") - self._logger.setLevel(level) - - process_guid = uuid.uuid4().hex[:4] - - base_format = "[%(asctime)s]-%(levelname)s-(" + process_guid + ")=> %(message)s" - - # Console (colored) - self._logger.addHandler(get_console_handler(base_format, level)) - - # File handlers (no color) - for lvl in ("INFO", "WARNING", "ERROR", "CRITICAL"): - self._logger.addHandler( - get_file_handler(lvl, log_dir, base_format) - ) - - def _detect_log_dir(self) -> str: - """Auto-detect project root and return 'log' directory path.""" - current_path = Path.cwd() - for parent in [current_path] + list(current_path.parents): - if (parent / ".venv").exists() or (parent / "pyproject.toml").exists() or (parent / ".git").exists(): - return str(parent / "log") - return "./log" - - # ------- Logging functions ------- - - def info(self, message): - self._logger.info(message) - - def warn(self, message): - self._logger.warning(message) - - def critical(self, message): - self._logger.critical(message) - - # Error decorator - def error(self, func): - """Decorator that auto logs errors with full traceback.""" - @functools.wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except Exception as e: - self._logger.error(f"Error in {func.__name__}: {str(e)}") - self._logger.error(format_exc()) - return None # Don't re-raise to avoid duplicate traceback - return wrapper diff --git a/src/loggerhelper/__init__.py b/src/loggerhelper/__init__.py new file mode 100644 index 0000000..ed4b7c8 --- /dev/null +++ b/src/loggerhelper/__init__.py @@ -0,0 +1,38 @@ +""" +LoggerHelper -- coloured console output, per-level log files, and an error +decorator that names the definition that failed. + +Import the shared logger and write:: + + from loggerhelper import log + + log.info("System started") + log.error("could not reach the queue") + + @log.catch + def handle(request): ... + +Nothing is created until the first line is logged, so importing this costs +nothing and ``@log.catch`` is safe to apply at import time. Settings come from +the environment (``LOG_DIR``, ``LOG_LEVEL``, ``SLACK_WEBHOOK_URL`` and friends) +or from ``configure()``:: + + from loggerhelper import configure + configure(log_dir="./logs") + +Everything else lives in ``loggerhelper._internal`` and may change between +releases; the names below are the ones that will not. +""" + +from ._internal.config import ConfigError, LoggerConfig +from .logger import Logger, configure, log + +__all__ = [ + "ConfigError", + "Logger", + "LoggerConfig", + "configure", + "log", +] + +__version__ = "2.0.0" diff --git a/src/loggerhelper/_internal/__init__.py b/src/loggerhelper/_internal/__init__.py new file mode 100644 index 0000000..0b11335 --- /dev/null +++ b/src/loggerhelper/_internal/__init__.py @@ -0,0 +1,8 @@ +""" +Implementation detail. Nothing here is part of the public API. + +Consumers import from ``loggerhelper`` (or ``loggerhelper.logger``); the layout +inside this package may change in any release. Modules here may import each +other, but must never import ``loggerhelper.logger`` -- the dependency runs one +way, outward. +""" diff --git a/src/loggerhelper/_internal/config.py b/src/loggerhelper/_internal/config.py new file mode 100644 index 0000000..01c4f78 --- /dev/null +++ b/src/loggerhelper/_internal/config.py @@ -0,0 +1,221 @@ +""" +Where a logger's settings come from. + +Precedence is explicit argument, then environment variable, then the default +defined here -- so a consumer who only cares about the directory writes +``configure(log_dir="./logs")`` and a deployment overrides the level without +touching code. + +``env`` is a parameter rather than a direct ``os.environ`` read so tests can +inject a mapping instead of mutating the process environment. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field, fields +from pathlib import Path +from typing import Any + +DEFAULT_LEVEL = "INFO" +DEFAULT_MAX_BYTES = 50 * 1024 * 1024 +DEFAULT_BACKUP_COUNT = 5 +DEFAULT_RETENTION_DAYS = 30 +DEFAULT_SLACK_LEVEL = "WARNING" + +#: The levels that get their own file. Ordered least to most severe. +LEVEL_NAMES = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") + +_LEVEL_NUMBERS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50} +# What `logging` itself accepts, so a value copied from another config still works. +_LEVEL_ALIASES = {"WARN": "WARNING", "FATAL": "CRITICAL"} + +_COLOR_MODES = ("auto", "always", "never") + + +class ConfigError(ValueError): + """A setting was given, but not in a form that can be used.""" + + +def normalise_level(value: str, *, source: str = "level") -> str: + """Canonical upper-case level name, or a message naming the valid ones.""" + name = str(value).strip().upper() + name = _LEVEL_ALIASES.get(name, name) + if name not in _LEVEL_NUMBERS: + raise ConfigError( + f"{source}={value!r} is not a log level. Use one of: {', '.join(LEVEL_NAMES)}" + ) + return name + + +def level_number(value: str) -> int: + """The `logging` integer for a level name. + + Replaces the private ``logging._nameToLevel`` the first version reached for. + """ + return _LEVEL_NUMBERS[normalise_level(value)] + + +def detect_log_dir(start: Path | None = None) -> str: + """ + Walk up from the working directory for a project root and put ``log/`` there. + + Anchored on the working directory rather than this file, so an installed + package logs into the consumer's project instead of into site-packages. + """ + current = Path.cwd() if start is None else Path(start) + for parent in [current, *current.parents]: + for marker in (".venv", "pyproject.toml", ".git"): + if (parent / marker).exists(): + return str(parent / "log") + return str(current / "log") + + +def _as_int(raw: str, source: str) -> int: + try: + return int(raw.strip()) + except ValueError: + raise ConfigError(f"{source}={raw!r} must be a whole number") from None + + +@dataclass(frozen=True) +class LoggerConfig: + """Everything a :class:`~loggerhelper.logger.Logger` needs, already validated.""" + + log_dir: str = field(default_factory=detect_log_dir) + level: str = DEFAULT_LEVEL + #: Tags every line and separates one logger from another. Optional. + name: str | None = None + #: Per-file size cap before rotation. 0 disables size rotation. + max_bytes: int = DEFAULT_MAX_BYTES + backup_count: int = DEFAULT_BACKUP_COUNT + #: Dated directories older than this are removed. 0 keeps everything. + retention_days: int = DEFAULT_RETENTION_DAYS + color: str = "auto" + #: Unset means the Slack handler is never attached at all. + slack_webhook_url: str | None = None + slack_level: str = DEFAULT_SLACK_LEVEL + #: Free-form deployment tag ("prod", "staging") shown on Slack alerts. + slack_env: str | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "log_dir", str(self.log_dir)) + object.__setattr__(self, "level", normalise_level(self.level, source="level")) + object.__setattr__( + self, "slack_level", normalise_level(self.slack_level, source="slack_level") + ) + + color = str(self.color).strip().lower() + if color not in _COLOR_MODES: + raise ConfigError(f"color={self.color!r} must be one of: {', '.join(_COLOR_MODES)}") + object.__setattr__(self, "color", color) + + for attr in ("max_bytes", "backup_count", "retention_days"): + value = getattr(self, attr) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ConfigError(f"{attr}={value!r} must be a whole number >= 0") + + url = self.slack_webhook_url + if url is not None: + url = url.strip() + if not url: + url = None + elif not url.startswith("https://"): + # The handler posts to this with urllib; anything but https is + # either a typo or credentials over the wire in clear text. + raise ConfigError(f"slack_webhook_url={self.slack_webhook_url!r} must be https://") + object.__setattr__(self, "slack_webhook_url", url) + + @property + def slack_enabled(self) -> bool: + """No webhook configured means the Slack plugin stays out of the way.""" + return bool(self.slack_webhook_url) + + @classmethod + def from_env(cls, *, env: Mapping[str, str] | None = None, **overrides: Any) -> LoggerConfig: + """ + Build a config from the environment, with ``overrides`` winning. + + An override of ``None`` means "not given" and falls through to the + environment -- so callers can pass optional arguments straight down + without branching on whether the user supplied them. + """ + env = os.environ if env is None else env + values: dict[str, Any] = {} + remaining = dict(overrides) + + def pick(field_name: str, env_key: str, cast: Any = None) -> None: + given = remaining.pop(field_name, None) + if given is not None: + values[field_name] = given + return + raw = env.get(env_key) + if raw is None or not raw.strip(): + return + values[field_name] = cast(raw, env_key) if cast else raw.strip() + + pick("log_dir", "LOG_DIR") + pick("level", "LOG_LEVEL") + pick("name", "LOG_NAME") + pick("max_bytes", "LOG_MAX_BYTES", _as_int) + pick("backup_count", "LOG_BACKUP_COUNT", _as_int) + pick("retention_days", "LOG_RETENTION_DAYS", _as_int) + pick("color", "LOG_COLOR") + pick("slack_webhook_url", "SLACK_WEBHOOK_URL") + pick("slack_level", "SLACK_LOG_LEVEL") + pick("slack_env", "SLACK_ENV") + + if remaining: + unknown = ", ".join(sorted(remaining)) + known = ", ".join(f.name for f in fields(cls)) + raise ConfigError(f"unknown setting(s): {unknown}. Known settings: {known}") + + return cls(**values) + + +def parse_dotenv(text: str) -> dict[str, str]: + """ + Parse ``KEY=value`` lines. Deliberately small: no interpolation, no export. + + Kept here rather than taking a dependency, since this package promises to + install nothing. + """ + values: dict[str, str] = {} + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("export "): + stripped = stripped[len("export ") :].lstrip() + key, separator, value = stripped.partition("=") + if not separator: + continue + key = key.strip() + if not key: + continue + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + values[key] = value + return values + + +def load_repo_dotenv( + *, filename: str = ".env", start: Path | None = None, override: bool = False +) -> Path | None: + """ + Apply the nearest ``.env`` walking up from the working directory. + + Off by default: a library that edits ``os.environ`` on import surprises the + application that imported it. ``configure(load_dotenv=True)`` opts in. + """ + current = Path.cwd() if start is None else Path(start) + for parent in [current, *current.parents]: + candidate = parent / filename + if candidate.is_file(): + for key, value in parse_dotenv(candidate.read_text(encoding="utf-8")).items(): + if override or key not in os.environ: + os.environ[key] = value + return candidate + return None diff --git a/src/loggerhelper/_internal/decorators.py b/src/loggerhelper/_internal/decorators.py new file mode 100644 index 0000000..5833a77 --- /dev/null +++ b/src/loggerhelper/_internal/decorators.py @@ -0,0 +1,97 @@ +""" +The ``@catch`` decorator: log an escaping exception, and say which definition +it escaped from. + +Three things changed from the first version's ``@logger.error``: + +* it re-raises by default. Swallowing turned a precise exception into a stray + ``None`` that the caller could not tell from a real result. +* it emits one record carrying the traceback, not two. Two records interleave + with other lines in the file, and fire any alerting integration twice. +* it works on ``async def``. Decorating a coroutine function previously + returned the coroutine object without awaiting it, so nothing was ever caught. +""" + +from __future__ import annotations + +import functools +import inspect +import os +from typing import Any, Callable, TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) + +#: ``emit(level_name, message, exception)`` +EmitFn = Callable[[str, str, BaseException], None] + + +def describe_failure(func: Callable[..., Any], exc: BaseException) -> str: + """ + ``TypeError in service.pool.AdapterPool.acquire (pool.py:87): ...`` + + ``__qualname__`` so a method reads as ``Class.method``, and the line comes + from the deepest traceback frame -- where the exception was actually raised, + not where the decorator happens to sit. + """ + where = f"{getattr(func, '__module__', '?')}.{getattr(func, '__qualname__', func)}" + + location = "" + frame = getattr(exc, "__traceback__", None) + if frame is not None: + while frame.tb_next is not None: + frame = frame.tb_next + filename = os.path.basename(frame.tb_frame.f_code.co_filename) + location = f" ({filename}:{frame.tb_lineno})" + + return f"{type(exc).__name__} in {where}{location}: {exc}" + + +def catch( + emit: EmitFn, + func: F | None = None, + *, + level: str = "ERROR", + reraise: bool = True, + default: Any = None, + exclude: tuple[type[BaseException], ...] = (), +) -> Any: + """ + Wrap ``func`` so an escaping exception is logged through ``emit``. + + Usable bare (``@catch``) or called (``@catch(level="CRITICAL")``). + ``emit`` is a callable rather than a logger so the decorator can be applied + at import time without forcing a logger to be built. + """ + + def decorate(target: F) -> F: + if inspect.iscoroutinefunction(target): + + @functools.wraps(target) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await target(*args, **kwargs) + except exclude: + raise + except Exception as exc: + emit(level, describe_failure(target, exc), exc) + if reraise: + raise + return default + + return async_wrapper # type: ignore[return-value] + + @functools.wraps(target) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return target(*args, **kwargs) + except exclude: + raise + except Exception as exc: + emit(level, describe_failure(target, exc), exc) + if reraise: + raise + return default + + return wrapper # type: ignore[return-value] + + return decorate if func is None else decorate(func) diff --git a/src/loggerhelper/_internal/format.py b/src/loggerhelper/_internal/format.py new file mode 100644 index 0000000..cc76c44 --- /dev/null +++ b/src/loggerhelper/_internal/format.py @@ -0,0 +1,170 @@ +""" +How a record turns into a line. + +Two things happen here. ``OriginFilter`` answers "which definition wrote this", +which is the whole point of the ``(module.function:line)`` field. The formatters +then render it, coloured on a terminal and plain everywhere else. +""" + +from __future__ import annotations + +import copy +import logging +import os +import sys +from functools import lru_cache +from pathlib import Path +from typing import IO + +#: The line every handler renders. `origin` comes from OriginFilter. +BASE_FORMAT = "[%(asctime)s]-%(levelname)s-(%(origin)s)=> %(message)s" +DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + + +class Color: + RESET = "\033[0m" + GREY = "\033[90m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + RED = "\033[31m" + CRITICAL_RED = "\033[91m" + + +# Keyed on levelno rather than levelname: the name gets wrapped in escape codes +# on its way out, the number never changes. +LEVEL_COLORS = { + logging.DEBUG: Color.GREY, + logging.INFO: Color.GREEN, + logging.WARNING: Color.YELLOW, + logging.ERROR: Color.RED, + logging.CRITICAL: Color.CRITICAL_RED, +} + +# Deep nesting is not worth a directory walk per source file. +_MAX_PACKAGE_DEPTH = 10 + + +@lru_cache(maxsize=512) +def _dotted_module(pathname: str, fallback: str) -> str: + """ + ``src/service/pool.py`` -> ``service.pool``, by walking up while there is an + ``__init__.py``. Cached, so the directory probing costs once per source file. + """ + try: + path = Path(pathname) + except (TypeError, ValueError): + return fallback + if path.suffix != ".py": + return fallback + + directory = path.parent + parts = [] if path.stem == "__init__" else [path.stem] + for _ in range(_MAX_PACKAGE_DEPTH): + try: + if not (directory / "__init__.py").exists(): + break + except OSError: + break + parts.append(directory.name) + directory = directory.parent + + return ".".join(reversed(parts)) if parts else fallback + + +class OriginFilter(logging.Filter): + """ + Attach ``record.origin`` -- ``module.function:line`` of the call site. + + This replaces the random per-process UUID the first version printed, which + carried no information a single-process program could use. Correct output + depends on the caller passing ``stacklevel`` so the record points at user + code and not at this package. + """ + + def __init__(self, tag: str | None = None) -> None: + super().__init__() + self.tag = tag + + def filter(self, record: logging.LogRecord) -> bool: + module = _dotted_module(record.pathname, record.module) + origin = f"{module}.{record.funcName}:{record.lineno}" + record.origin = f"{self.tag}|{origin}" if self.tag else origin + return True + + +class PlainFormatter(logging.Formatter): + """What goes into a file: no escape codes, ever.""" + + +class ColorFormatter(logging.Formatter): + """ + The console line, with the level and timestamp coloured. + + The first version coloured by running ``str.replace`` over the rendered + line, so a message that happened to contain ``-INFO-`` or the same timestamp + got mangled too. Colour is applied to the fields instead, and to a *copy* of + the record -- the file handlers format the same object and must not see the + escape codes. + """ + + def __init__( + self, fmt: str | None = None, datefmt: str | None = None, *, use_color: bool = True + ) -> None: + super().__init__(fmt, datefmt) + self.use_color = use_color + + def format(self, record: logging.LogRecord) -> str: + color = LEVEL_COLORS.get(record.levelno, "") if self.use_color else "" + if not color: + return super().format(record) + clone = copy.copy(record) + clone.levelname = f"{color}{record.levelname}{Color.RESET}" + return super().format(clone) + + def formatTime( # noqa: N802 - name fixed by logging.Formatter + self, record: logging.LogRecord, datefmt: str | None = None + ) -> str: + stamp = super().formatTime(record, datefmt) + color = LEVEL_COLORS.get(record.levelno, "") if self.use_color else "" + return f"{color}{stamp}{Color.RESET}" if color else stamp + + +def _enable_windows_ansi() -> bool: + """Turn on virtual terminal processing so escape codes render, not print.""" + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + # -11 is STD_OUTPUT_HANDLE, -12 STD_ERROR_HANDLE; 0x4 enables VT. + for stream_id in (-11, -12): + handle = kernel32.GetStdHandle(stream_id) + mode = ctypes.c_uint32() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return False + kernel32.SetConsoleMode(handle, mode.value | 0x4) + return True + except Exception: + return False + + +def should_use_color(mode: str, stream: IO[str] | None = None) -> bool: + """ + ``always`` / ``never`` are taken at face value; ``auto`` asks the stream. + + Without this, piping output to a file or running under CI filled the log + with escape codes -- the first version coloured unconditionally. + """ + if mode == "never": + return False + if mode == "auto": + if os.environ.get("NO_COLOR"): + return False + stream = sys.stderr if stream is None else stream + try: + if not stream.isatty(): + return False + except (AttributeError, ValueError): + return False + if sys.platform == "win32": + return _enable_windows_ansi() + return True diff --git a/src/loggerhelper/_internal/handler.py b/src/loggerhelper/_internal/handler.py new file mode 100644 index 0000000..1512f9f --- /dev/null +++ b/src/loggerhelper/_internal/handler.py @@ -0,0 +1,149 @@ +""" +Where lines land. + +One file per level under a dated directory, as before -- but the date is +decided when a record is written rather than when the handler was built, so a +process that outlives midnight rolls over instead of writing into yesterday +forever. +""" + +from __future__ import annotations + +import logging +import logging.handlers +import re +import shutil +from datetime import date, timedelta +from pathlib import Path +from typing import IO + +from .config import LoggerConfig, level_number +from .format import BASE_FORMAT, DATE_FORMAT, ColorFormatter, PlainFormatter, should_use_color + +_DATE_DIR = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _today() -> str: + return date.today().isoformat() + + +def prune_old_logs(log_dir: str, retention_days: int) -> int: + """ + Delete dated directories older than the retention window. Returns the count. + + Only directories named exactly ``YYYY-MM-DD`` directly under ``log_dir`` are + touched -- whatever else the user keeps there is not ours to remove. + """ + if retention_days <= 0: + return 0 + cutoff = date.today() - timedelta(days=retention_days) + root = Path(log_dir) + try: + entries = list(root.iterdir()) + except OSError: + return 0 + + removed = 0 + for entry in entries: + if not _DATE_DIR.match(entry.name) or not entry.is_dir(): + continue + try: + when = date.fromisoformat(entry.name) + except ValueError: + continue + if when < cutoff: + shutil.rmtree(entry, ignore_errors=True) + removed += 1 + return removed + + +class DailyDirRotatingFileHandler(logging.handlers.RotatingFileHandler): + """ + ``//.log``, rotated by size and by day. + + Opened lazily (``delay=True``): attaching the handler creates nothing, so a + program that only ever logs INFO does not leave four empty files behind. + """ + + def __init__( + self, + log_dir: str, + level_name: str, + *, + max_bytes: int = 0, + backup_count: int = 0, + retention_days: int = 0, + encoding: str = "utf-8", + ) -> None: + self.log_dir = Path(log_dir) + self.level_name = level_name + self.retention_days = retention_days + self._current_day = _today() + super().__init__( + str(self._path_for(self._current_day)), + maxBytes=max_bytes, + backupCount=backup_count, + encoding=encoding, + delay=True, + ) + + def _path_for(self, day: str) -> Path: + return self.log_dir / day / f"{self.level_name}.log" + + def _open(self) -> IO[str]: + # The directory may not exist yet, or may have been cleaned up under us. + Path(self.baseFilename).parent.mkdir(parents=True, exist_ok=True) + return super()._open() + + def shouldRollover(self, record: logging.LogRecord) -> int: # noqa: N802 - logging API + if _today() != self._current_day: + # Checked before delegating: the parent opens the stream to measure + # it, and it would open yesterday's file to do so. + return 1 + return super().shouldRollover(record) + + def doRollover(self) -> None: # noqa: N802 - logging API + today = _today() + if today == self._current_day: + super().doRollover() + return + + # A new day gets a fresh directory, so there is nothing to rename -- + # just point at the new path and let the next emit open it. + if self.stream: + self.stream.close() + self.stream = None + self._current_day = today + self.baseFilename = str(self._path_for(today)) + prune_old_logs(str(self.log_dir), self.retention_days) + if not self.delay: + self.stream = self._open() + + +def get_file_handler(level_name: str, config: LoggerConfig) -> logging.Handler: + """One handler per level, each taking only records of exactly that level.""" + handler = DailyDirRotatingFileHandler( + config.log_dir, + level_name, + max_bytes=config.max_bytes, + backup_count=config.backup_count, + retention_days=config.retention_days, + ) + target = level_number(level_name) + handler.setLevel(target) + handler.addFilter(lambda record: record.levelno == target) + handler.setFormatter(PlainFormatter(BASE_FORMAT, DATE_FORMAT)) + return handler + + +def get_console_handler(config: LoggerConfig, stream: IO[str] | None = None) -> logging.Handler: + handler = logging.StreamHandler(stream) + handler.setLevel(level_number(config.level)) + handler.setFormatter( + ColorFormatter( + BASE_FORMAT, + DATE_FORMAT, + use_color=should_use_color(config.color, handler.stream), + ) + ) + return handler diff --git a/src/loggerhelper/_internal/notifiers/__init__.py b/src/loggerhelper/_internal/notifiers/__init__.py new file mode 100644 index 0000000..0b42849 --- /dev/null +++ b/src/loggerhelper/_internal/notifiers/__init__.py @@ -0,0 +1,7 @@ +""" +Optional outbound alerting. + +Each notifier is attached only when its configuration is present, so a +deployment that sets nothing gets exactly the behaviour it had before. Future +transports (Telegram, email) belong here alongside ``slack``. +""" diff --git a/src/loggerhelper/_internal/notifiers/slack.py b/src/loggerhelper/_internal/notifiers/slack.py new file mode 100644 index 0000000..2c5fa73 --- /dev/null +++ b/src/loggerhelper/_internal/notifiers/slack.py @@ -0,0 +1,267 @@ +""" +Slack alerts for WARNING and above, when a webhook is configured. + +Two constraints shape this. Logging must never block the caller, so the POST +happens on a background thread behind a bounded queue. And an error loop must +not flood the channel (or get the app rate-limited by Slack), so repeats of the +same failure are collapsed into a count. + +Sending nothing is always preferable to raising: a broken webhook is not the +application's problem to handle. +""" + +from __future__ import annotations + +import atexit +import json +import logging +import logging.handlers +import queue +import sys +import threading +import time +import traceback +import urllib.error +import urllib.request +from typing import Any + +from ..config import LoggerConfig, level_number + +#: Slack renders a wall of text badly and rejects very large payloads. +MAX_TEXT_CHARS = 2800 +DEFAULT_THROTTLE_SECONDS = 60.0 +DEFAULT_MAX_PER_MINUTE = 20 +DEFAULT_TIMEOUT = 5.0 +QUEUE_SIZE = 1000 + +LEVEL_COLORS = { + "WARNING": "#d9a441", + "ERROR": "#d64541", + "CRITICAL": "#8b1a1a", +} + + +def _truncate(text: str, limit: int = MAX_TEXT_CHARS) -> str: + if len(text) <= limit: + return text + return text[:limit] + f"\n... [{len(text) - limit} more characters]" + + +class SlackWebhookHandler(logging.Handler): + """POST records to a Slack incoming webhook. Runs on the listener thread.""" + + def __init__( + self, + webhook_url: str, + *, + environment: str | None = None, + service: str | None = None, + throttle_seconds: float = DEFAULT_THROTTLE_SECONDS, + max_per_minute: int = DEFAULT_MAX_PER_MINUTE, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + super().__init__() + if not webhook_url.startswith("https://"): + raise ValueError("Slack webhook URL must be https://") + self.webhook_url = webhook_url + self.environment = environment + self.service = service + self.throttle_seconds = throttle_seconds + self.max_per_minute = max_per_minute + self.timeout = timeout + + self._lock = threading.Lock() + self._last_sent: dict[tuple, float] = {} + self._suppressed: dict[tuple, int] = {} + self._window_start = time.monotonic() + self._window_count = 0 + self._window_dropped = 0 + self._reported_failure = False + + # ---- throttling ------------------------------------------------- + + @staticmethod + def _key(record: logging.LogRecord) -> tuple: + """Same level, same place, same exception type -- treat as a repeat.""" + return ( + record.levelname, + getattr(record, "origin", record.name), + getattr(record, "exc_type", ""), + ) + + def _admit(self, record: logging.LogRecord, now: float) -> int | None: + """ + Decide whether to send. Returns how many repeats were folded in, or + ``None`` to stay quiet. + """ + key = self._key(record) + with self._lock: + if now - self._window_start >= 60.0: + self._window_start = now + self._window_count = 0 + self._window_dropped = 0 + + last = self._last_sent.get(key) + if last is not None and now - last < self.throttle_seconds: + self._suppressed[key] = self._suppressed.get(key, 0) + 1 + return None + + if self._window_count >= self.max_per_minute: + self._window_dropped += 1 + self._suppressed[key] = self._suppressed.get(key, 0) + 1 + return None + + self._last_sent[key] = now + self._window_count += 1 + return self._suppressed.pop(key, 0) + + # ---- rendering -------------------------------------------------- + + def _payload(self, record: logging.LogRecord, suppressed: int) -> dict: + # QueueHandler.prepare already folded the traceback into the message, + # so the first line is the summary and the rest is the traceback. + text = record.getMessage() + title, _, detail = text.partition("\n") + + fields = [] + if self.environment: + fields.append({"title": "Environment", "value": self.environment, "short": True}) + if self.service: + fields.append({"title": "Service", "value": self.service, "short": True}) + origin = getattr(record, "origin", None) + if origin: + fields.append({"title": "Origin", "value": f"`{origin}`", "short": False}) + if suppressed: + fields.append( + { + "title": "Repeats", + "value": f"{suppressed} identical message(s) suppressed since the last alert", + "short": False, + } + ) + + attachment: dict[str, Any] = { + "color": LEVEL_COLORS.get(record.levelname, "#808080"), + "fallback": _truncate(title, 300), + "title": f"{record.levelname}: {_truncate(title, 300)}", + "fields": fields, + "ts": int(record.created), + } + if detail.strip(): + attachment["text"] = f"```{_truncate(detail.strip())}```" + + return {"attachments": [attachment]} + + # ---- delivery --------------------------------------------------- + + def _post(self, payload: dict) -> None: + request = urllib.request.Request( + self.webhook_url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + # The scheme is pinned to https in __init__ and again in LoggerConfig, + # so this cannot be pointed at file:// or ftp://. + with urllib.request.urlopen(request, timeout=self.timeout): # nosec B310 + pass + + def emit(self, record: logging.LogRecord) -> None: + try: + suppressed = self._admit(record, time.monotonic()) + if suppressed is None: + return + self._post(self._payload(record, suppressed)) + except Exception: + self.handleError(record) + + def handleError(self, record: logging.LogRecord) -> None: # noqa: N802 - logging API + """ + Report the first failure and then go quiet. + + A webhook that is down usually stays down for a while; repeating the + stack trace for every record would bury the real output. + """ + with self._lock: + first = not self._reported_failure + self._reported_failure = True + if first: + print( + "loggerhelper: Slack notification failed, staying quiet from here:", + file=sys.stderr, + ) + traceback.print_exc(file=sys.stderr) + + +class _DroppingQueueHandler(logging.handlers.QueueHandler): + """ + Hand records to the listener thread, dropping them if it falls behind. + + A bounded queue is the point: an unbounded one turns a Slack outage during + an error storm into unbounded memory growth. + """ + + def __init__(self, q: queue.Queue) -> None: + super().__init__(q) + self.dropped = 0 + + def prepare(self, record: logging.LogRecord) -> logging.LogRecord: + # The parent clears exc_info (it may not be picklable), so capture the + # exception type first -- the throttle keys on it. + if record.exc_info and record.exc_info[0] is not None: + record.exc_type = record.exc_info[0].__name__ + return super().prepare(record) + + def enqueue(self, record: logging.LogRecord) -> None: + try: + self.queue.put_nowait(record) + except queue.Full: + self.dropped += 1 + + +class _IdempotentQueueListener(logging.handlers.QueueListener): + """ + A listener that can be stopped twice. + + ``Logger.close()`` stops it and so does the ``atexit`` hook; the stdlib + version raises on the second call, which surfaces as a traceback printed + during interpreter shutdown. + """ + + def stop(self) -> None: + if getattr(self, "_thread", None) is None: + return + super().stop() + + +def create_slack_handler( + config: LoggerConfig, +) -> tuple[logging.Handler, logging.handlers.QueueListener] | None: + """ + Build the queued Slack handler, or ``None`` when no webhook is configured. + + Returning ``None`` rather than a no-op handler keeps the "not configured" + case entirely free of cost. + """ + if not config.slack_enabled: + return None + + target = SlackWebhookHandler( + str(config.slack_webhook_url), + environment=config.slack_env, + service=config.name, + ) + threshold = level_number(config.slack_level) + target.setLevel(threshold) + + handler = _DroppingQueueHandler(queue.Queue(maxsize=QUEUE_SIZE)) + handler.setLevel(threshold) + # Only the message and its traceback survive the queue, so that is what the + # formatter has to produce. + handler.setFormatter(logging.Formatter("%(message)s")) + + listener = _IdempotentQueueListener(handler.queue, target, respect_handler_level=True) + listener.start() + # Anything still queued when the process ends should still get sent. + atexit.register(listener.stop) + return handler, listener diff --git a/src/loggerhelper/logger.py b/src/loggerhelper/logger.py new file mode 100644 index 0000000..6ce8414 --- /dev/null +++ b/src/loggerhelper/logger.py @@ -0,0 +1,292 @@ +""" +The logger itself -- the only implementation module a consumer needs to read. + +Nothing is built until the first line is written. That matters because a +package that creates directories while being imported forces every consumer to +wrap it in a lazy holder of their own, and because ``@log.catch`` has to be +usable as a decorator at import time. +""" + +from __future__ import annotations + +import atexit +import contextlib +import logging +import threading +import warnings +from collections.abc import Mapping +from typing import Any, Callable, TypeVar + +from ._internal.config import LEVEL_NAMES, LoggerConfig, level_number, load_repo_dotenv +from ._internal.decorators import catch as _catch +from ._internal.format import OriginFilter +from ._internal.handler import get_console_handler, get_file_handler, prune_old_logs +from ._internal.notifiers.slack import create_slack_handler + +F = TypeVar("F", bound=Callable[..., Any]) + +# How many frames sit between a caller and `logging` when it goes through the +# public methods here: the method itself, plus `_emit`. +_STACK_OFFSET = 2 + + +class Logger: + """ + A colourful console logger that also splits its output into per-level files. + + Construction is free -- no directories, no files, no threads. The first call + that actually logs something builds the handlers. + + Instances are keyed by ``name``: two ``Logger()`` objects with the same name + share one set of handlers, which is what stops the same line being written + twice. Give a distinct ``name`` to a logger that should write somewhere else. + """ + + def __init__( + self, + log_dir: str | None = None, + level: str | None = None, + *, + name: str | None = None, + config: LoggerConfig | None = None, + env: Mapping[str, str] | None = None, + **settings: Any, + ) -> None: + self._explicit_config = config + self._settings: dict[str, Any] = dict(log_dir=log_dir, level=level, name=name, **settings) + self._env = env + self._lock = threading.RLock() + self._logger: logging.Logger | None = None + self._config: LoggerConfig | None = None + self._slack_listener: Any = None + atexit.register(self.close) + + # ---- construction ------------------------------------------------ + + @property + def config(self) -> LoggerConfig: + """The resolved settings. Reading this does not build any handlers.""" + with self._lock: + if self._config is None: + self._config = self._explicit_config or LoggerConfig.from_env( + env=self._env, **self._settings + ) + return self._config + + def _resolve(self) -> logging.Logger: + if self._logger is None: + with self._lock: + if self._logger is None: + self._logger = self._build() + return self._logger + + def _build(self) -> logging.Logger: + config = self.config + logger = logging.getLogger(f"loggerhelper.{config.name}" if config.name else "loggerhelper") + logger.setLevel(level_number(config.level)) + # The application owns the root logger; our records are not its business. + logger.propagate = False + + # Rebuilding must not stack a second set of handlers on the shared + # stdlib logger -- that is what made the first version log twice. + for handler in list(logger.handlers): + logger.removeHandler(handler) + handler.close() + for existing in list(logger.filters): + logger.removeFilter(existing) + + logger.addFilter(OriginFilter(config.name)) + + prune_old_logs(config.log_dir, config.retention_days) + logger.addHandler(get_console_handler(config)) + for level_name in LEVEL_NAMES: + logger.addHandler(get_file_handler(level_name, config)) + + slack = create_slack_handler(config) + if slack is not None: + handler, listener = slack + self._slack_listener = listener + logger.addHandler(handler) + + return logger + + def reconfigure( + self, + *, + config: LoggerConfig | None = None, + env: Mapping[str, str] | None = None, + **settings: Any, + ) -> Logger: + """ + Change settings. Values of ``None`` mean "leave as is". + + Doing this after logging has started is legal but noted in the log -- + earlier lines went somewhere else, and that is worth being able to see. + """ + with self._lock: + started = self._logger is not None + if config is not None: + self._explicit_config = config + self._settings = {} + else: + self._settings.update({k: v for k, v in settings.items() if v is not None}) + if env is not None: + self._env = env + self._config = None + + if started: + self.close() + self.warning("logger reconfigured after use; handlers were rebuilt") + return self + + def close(self) -> None: + """Release files and stop the Slack thread. Logging again rebuilds.""" + with self._lock: + listener, self._slack_listener = self._slack_listener, None + logger, self._logger = self._logger, None + + if listener is not None: + # Nothing useful is left to do if the listener thread is already gone. + with contextlib.suppress(Exception): + listener.stop() + if logger is not None: + for handler in list(logger.handlers): + logger.removeHandler(handler) + handler.close() + + # ---- logging ----------------------------------------------------- + + def _emit(self, level: int, message: Any, *args: Any, **kwargs: Any) -> None: + # stacklevel is what makes `origin` point at the caller instead of at + # this file; every public method must be exactly one frame above here. + kwargs["stacklevel"] = kwargs.pop("stacklevel", 1) + _STACK_OFFSET + self._resolve().log(level, message, *args, **kwargs) + + def debug(self, message: Any, *args: Any, **kwargs: Any) -> None: + """Detail only useful while working on the code.""" + self._emit(logging.DEBUG, message, *args, **kwargs) + + def info(self, message: Any, *args: Any, **kwargs: Any) -> None: + """A normal lifecycle event: started, connected, finished.""" + self._emit(logging.INFO, message, *args, **kwargs) + + def warning(self, message: Any, *args: Any, **kwargs: Any) -> None: + """Degraded, but this operation still succeeded -- a retry, a fallback.""" + self._emit(logging.WARNING, message, *args, **kwargs) + + def error(self, message: Any, *args: Any, **kwargs: Any) -> Any: + """ + This operation failed; the service is still running. + + Also accepts a function, for compatibility with the first version where + ``error`` was the decorator. That use is deprecated. + """ + if callable(message) and not args and not kwargs: + warnings.warn( + "@logger.error as a decorator is deprecated. Use @logger.catch, which " + "re-raises, or @logger.catch(reraise=False) to keep swallowing the " + "exception as before.", + DeprecationWarning, + stacklevel=2, + ) + return self.catch(message, reraise=False) + self._emit(logging.ERROR, message, *args, **kwargs) + return None + + def critical(self, message: Any, *args: Any, **kwargs: Any) -> None: + """The process or service cannot continue.""" + self._emit(logging.CRITICAL, message, *args, **kwargs) + + def exception(self, message: Any, *args: Any, **kwargs: Any) -> None: + """``error`` with the active traceback attached. Use inside ``except``.""" + kwargs.setdefault("exc_info", True) + self._emit(logging.ERROR, message, *args, **kwargs) + + def log(self, level: str, message: Any, *args: Any, **kwargs: Any) -> None: + """Log at a level chosen at runtime, e.g. ``log.log("WARNING", msg)``.""" + self._emit(level_number(level), message, *args, **kwargs) + + def warn(self, message: Any, *args: Any, **kwargs: Any) -> None: + """Deprecated alias for :meth:`warning`.""" + warnings.warn( + "Logger.warn() is deprecated; use Logger.warning().", + DeprecationWarning, + stacklevel=2, + ) + self._emit(logging.WARNING, message, *args, **kwargs) + + # ---- decorator --------------------------------------------------- + + def _emit_failure(self, level: str, message: str, exc: BaseException) -> None: + """One record, carrying the traceback, from the decorator.""" + self._resolve().log(level_number(level), message, exc_info=exc, stacklevel=3) + + def catch( + self, + func: F | None = None, + *, + level: str = "ERROR", + reraise: bool = True, + default: Any = None, + exclude: tuple[type[BaseException], ...] = (), + ) -> Any: + """ + Log any exception escaping the decorated function, then re-raise it. + + ``ERROR`` by default because an exception escaping a function means that + operation failed -- regardless of how important the function is. Reserve + ``level="CRITICAL"`` for start-up and other paths where the failure ends + the process. + + @log.catch + def handle(request): ... + + @log.catch(level="CRITICAL") + def load_config(): ... + + @log.catch(reraise=False, default=[]) + def optional_extras(): ... + + Works on ``async def`` too. Decorating does not build the logger, so it + is safe at import time. + """ + return _catch( + self._emit_failure, + func, + level=level, + reraise=reraise, + default=default, + exclude=exclude, + ) + + +#: The shared logger. Import it and start writing; settings come from the +#: environment, or from `configure()`. +log = Logger() + + +def configure( + log_dir: str | None = None, + level: str | None = None, + *, + name: str | None = None, + config: LoggerConfig | None = None, + env: Mapping[str, str] | None = None, + load_dotenv: bool = False, + **settings: Any, +) -> Logger: + """ + Point the shared logger somewhere. Everything is optional. + + configure(log_dir="./logs") + + Call it before the first log line. Afterwards is allowed, and says so in the + log. ``load_dotenv=True`` applies the nearest ``.env`` first; it is off by + default because a library should not edit the environment behind the + application's back. + """ + if load_dotenv: + load_repo_dotenv() + return log.reconfigure( + config=config, env=env, log_dir=log_dir, level=level, name=name, **settings + ) diff --git a/src/loggerhelper/py.typed b/src/loggerhelper/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/_internal/test_config.py b/tests/_internal/test_config.py new file mode 100644 index 0000000..3908c04 --- /dev/null +++ b/tests/_internal/test_config.py @@ -0,0 +1,143 @@ +"""Settings resolution: argument, then environment, then default.""" + +from __future__ import annotations + +import pytest + +from loggerhelper._internal.config import ( + DEFAULT_RETENTION_DAYS, + ConfigError, + LoggerConfig, + detect_log_dir, + level_number, + load_repo_dotenv, + normalise_level, + parse_dotenv, +) + + +class TestPrecedence: + def test_argument_beats_environment(self): + config = LoggerConfig.from_env(env={"LOG_LEVEL": "ERROR"}, level="DEBUG") + assert config.level == "DEBUG" + + def test_environment_beats_default(self): + assert LoggerConfig.from_env(env={"LOG_LEVEL": "ERROR"}).level == "ERROR" + + def test_default_applies_when_neither_is_given(self): + assert LoggerConfig.from_env(env={}).level == "INFO" + + def test_none_argument_is_not_a_value(self): + config = LoggerConfig.from_env(env={"LOG_LEVEL": "ERROR"}, level=None) + assert config.level == "ERROR" + + def test_blank_environment_variable_is_ignored(self): + assert LoggerConfig.from_env(env={"LOG_LEVEL": " "}).level == "INFO" + + +class TestParsing: + def test_integers_come_through(self): + config = LoggerConfig.from_env( + env={"LOG_MAX_BYTES": "1024", "LOG_BACKUP_COUNT": "2", "LOG_RETENTION_DAYS": "7"} + ) + assert (config.max_bytes, config.backup_count, config.retention_days) == (1024, 2, 7) + + def test_a_non_numeric_size_names_the_variable(self): + with pytest.raises(ConfigError, match="LOG_MAX_BYTES"): + LoggerConfig.from_env(env={"LOG_MAX_BYTES": "big"}) + + def test_an_unknown_level_lists_the_valid_ones(self): + with pytest.raises(ConfigError, match="CRITICAL"): + LoggerConfig.from_env(env={"LOG_LEVEL": "LOUD"}) + + @pytest.mark.parametrize( + "given,expected", + [("warn", "WARNING"), ("WARNING", "WARNING"), (" info ", "INFO"), ("fatal", "CRITICAL")], + ) + def test_level_aliases_and_casing(self, given, expected): + assert normalise_level(given) == expected + + def test_level_number_matches_logging(self): + import logging + + assert level_number("warn") == logging.WARNING + + def test_an_unknown_setting_is_rejected_by_name(self): + with pytest.raises(ConfigError, match="colur"): + LoggerConfig.from_env(env={}, colur="always") + + def test_negative_sizes_are_rejected(self): + with pytest.raises(ConfigError, match="max_bytes"): + LoggerConfig(max_bytes=-1) + + def test_an_unknown_colour_mode_is_rejected(self): + with pytest.raises(ConfigError, match="auto, always, never"): + LoggerConfig(color="rainbow") + + +class TestSlackSettings: + def test_no_webhook_means_disabled(self): + assert LoggerConfig.from_env(env={}).slack_enabled is False + + def test_a_webhook_enables_it(self): + config = LoggerConfig.from_env(env={"SLACK_WEBHOOK_URL": "https://hooks.slack.com/x"}) + assert config.slack_enabled is True + assert config.slack_level == "WARNING" + + def test_the_slack_level_can_be_raised(self): + config = LoggerConfig.from_env( + env={"SLACK_WEBHOOK_URL": "https://hooks.slack.com/x", "SLACK_LOG_LEVEL": "critical"} + ) + assert config.slack_level == "CRITICAL" + + def test_plain_http_is_refused(self): + """The handler posts this URL; http would put the secret on the wire.""" + with pytest.raises(ConfigError, match="https"): + LoggerConfig(slack_webhook_url="http://hooks.slack.com/x") + + def test_an_empty_webhook_is_the_same_as_unset(self): + assert LoggerConfig(slack_webhook_url=" ").slack_enabled is False + + +class TestDefaults: + def test_the_log_directory_defaults_to_the_project_root(self, tmp_path, monkeypatch): + (tmp_path / "pyproject.toml").write_text("", encoding="utf-8") + nested = tmp_path / "a" / "b" + nested.mkdir(parents=True) + monkeypatch.chdir(nested) + assert detect_log_dir() == str(tmp_path / "log") + + def test_retention_has_a_default(self): + assert LoggerConfig().retention_days == DEFAULT_RETENTION_DAYS + + +class TestDotenv: + def test_parsing_covers_comments_quotes_and_export(self): + parsed = parse_dotenv( + "\n".join( + [ + "# a comment", + "", + "LOG_LEVEL=DEBUG", + 'LOG_DIR="./logs"', + "export SLACK_ENV=prod", + "not-an-assignment", + ] + ) + ) + assert parsed == {"LOG_LEVEL": "DEBUG", "LOG_DIR": "./logs", "SLACK_ENV": "prod"} + + def test_loading_does_not_overwrite_what_is_already_set(self, tmp_path, monkeypatch): + (tmp_path / ".env").write_text("LOG_LEVEL=DEBUG\nLOG_NAME=from-file\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LOG_LEVEL", "ERROR") + + found = load_repo_dotenv() + + assert found == tmp_path / ".env" + assert LoggerConfig.from_env().level == "ERROR" + assert LoggerConfig.from_env().name == "from-file" + + def test_missing_file_is_not_an_error(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert load_repo_dotenv(filename=".env.absent") is None diff --git a/tests/_internal/test_decorators.py b/tests/_internal/test_decorators.py new file mode 100644 index 0000000..837cf35 --- /dev/null +++ b/tests/_internal/test_decorators.py @@ -0,0 +1,188 @@ +"""`@catch`: what it logs, what it re-raises, and what it says failed.""" + +from __future__ import annotations + +import pytest + +from loggerhelper._internal.decorators import catch, describe_failure + + +@pytest.fixture +def emitted(): + """Collect (level, message, exception) instead of building a logger.""" + calls: list[tuple] = [] + calls.append # noqa: B018 - documents the shape below + return calls + + +@pytest.fixture +def emit(emitted): + def record(level, message, exc): + emitted.append((level, message, exc)) + + return record + + +class TestReraising: + def test_the_exception_is_re_raised_by_default(self, emit, emitted): + @catch(emit) + def failing(): + raise ValueError("boom") + + with pytest.raises(ValueError, match="boom"): + failing() + assert len(emitted) == 1 + + def test_swallowing_is_opt_in(self, emit, emitted): + @catch(emit, reraise=False, default="fallback") + def failing(): + raise ValueError("boom") + + assert failing() == "fallback" + assert len(emitted) == 1 + + def test_a_successful_call_is_untouched(self, emit, emitted): + @catch(emit) + def fine(a, b): + return a + b + + assert fine(1, 2) == 3 + assert emitted == [] + + def test_excluded_types_are_re_raised_without_logging(self, emit, emitted): + @catch(emit, exclude=(KeyError,)) + def failing(): + raise KeyError("expected") + + with pytest.raises(KeyError): + failing() + assert emitted == [] + + def test_keyboard_interrupt_is_never_caught(self, emit, emitted): + @catch(emit, reraise=False) + def interrupted(): + raise KeyboardInterrupt + + with pytest.raises(KeyboardInterrupt): + interrupted() + assert emitted == [] + + +class TestLevels: + def test_the_default_is_error(self, emit, emitted): + """An exception escaping means this operation failed -- that is ERROR.""" + + @catch(emit, reraise=False) + def failing(): + raise ValueError("boom") + + failing() + assert emitted[0][0] == "ERROR" + + def test_the_level_can_be_raised_for_start_up_paths(self, emit, emitted): + @catch(emit, level="CRITICAL", reraise=False) + def load_config(): + raise OSError("no config") + + load_config() + assert emitted[0][0] == "CRITICAL" + + +class TestMessage: + def test_the_message_names_the_definition_that_failed(self, emit, emitted): + @catch(emit, reraise=False) + def parse_row(): + raise ValueError("bad row") + + parse_row() + message = emitted[0][1] + assert "ValueError in" in message + assert "parse_row" in message + assert "bad row" in message + + def test_a_method_is_named_with_its_class(self, emit, emitted): + class Pool: + @catch(emit, reraise=False) + def acquire(self): + raise ConnectionError("refused") + + Pool().acquire() + assert "Pool.acquire" in emitted[0][1] + + def test_the_line_is_where_it_was_raised_not_where_it_was_decorated(self, emit, emitted): + def failing(): + raise ValueError("boom") + + raised_at = failing.__code__.co_firstlineno + 1 + catch(emit, failing, reraise=False)() + + assert f":{raised_at})" in emitted[0][1] + + def test_the_exception_object_is_handed_over_for_the_traceback(self, emit, emitted): + """One record carrying exc_info, rather than a second record of text.""" + + @catch(emit, reraise=False) + def failing(): + raise ValueError("boom") + + failing() + assert isinstance(emitted[0][2], ValueError) + + def test_describe_failure_survives_an_exception_with_no_traceback(self): + assert "ValueError in" in describe_failure(test_describe_failure_survives, ValueError("x")) + + +def test_describe_failure_survives(*_): # helper referenced above + pass + + +class TestWrapping: + def test_the_wrapped_function_keeps_its_identity(self, emit): + @catch(emit) + def documented(): + """A docstring.""" + + assert documented.__name__ == "documented" + assert documented.__doc__ == "A docstring." + + def test_it_works_bare_and_called(self, emit, emitted): + @catch(emit, reraise=False) + def called_form(): + raise ValueError("a") + + def bare_target(): + raise ValueError("b") + + bare = catch(emit, bare_target, reraise=False) + + called_form() + bare() + assert len(emitted) == 2 + + +class TestAsync: + async def test_an_async_failure_is_logged(self, emit, emitted): + """v1 returned the coroutine unawaited, so nothing was ever caught.""" + + @catch(emit, reraise=False, default="fallback") + async def failing(): + raise ValueError("boom") + + assert await failing() == "fallback" + assert "failing" in emitted[0][1] + + async def test_an_async_success_returns_its_value(self, emit, emitted): + @catch(emit) + async def fine(): + return 7 + + assert await fine() == 7 + assert emitted == [] + + async def test_an_async_failure_re_raises_by_default(self, emit): + @catch(emit) + async def failing(): + raise ValueError("boom") + + with pytest.raises(ValueError): + await failing() diff --git a/tests/_internal/test_format.py b/tests/_internal/test_format.py new file mode 100644 index 0000000..ae67e68 --- /dev/null +++ b/tests/_internal/test_format.py @@ -0,0 +1,112 @@ +"""Colour, and the `origin` field that replaced the random identifier.""" + +from __future__ import annotations + +import io +import logging + +import pytest + +from loggerhelper._internal.format import ( + BASE_FORMAT, + DATE_FORMAT, + ColorFormatter, + OriginFilter, + PlainFormatter, + should_use_color, +) + +ESCAPE = "\033[" + + +def make_record(message="hello", level=logging.INFO): + return logging.LogRecord("test", level, __file__, 42, message, None, None) + + +class TestOriginFilter: + def test_it_names_module_function_and_line(self): + record = make_record() + record.funcName = "acquire" + OriginFilter().filter(record) + assert record.origin.endswith(".acquire:42") + + def test_a_name_tag_is_prefixed(self): + record = make_record() + OriginFilter("etl-worker").filter(record) + assert record.origin.startswith("etl-worker|") + + def test_a_module_inside_a_package_gets_its_dotted_path(self, tmp_path): + package = tmp_path / "service" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + module = package / "pool.py" + module.write_text("", encoding="utf-8") + + record = logging.LogRecord("test", logging.INFO, str(module), 8, "x", None, None) + record.funcName = "acquire" + OriginFilter().filter(record) + + assert record.origin == "service.pool.acquire:8" + + +class TestColour: + def test_the_plain_formatter_never_emits_escape_codes(self): + record = make_record() + OriginFilter().filter(record) + assert ESCAPE not in PlainFormatter(BASE_FORMAT, DATE_FORMAT).format(record) + + def test_the_colour_formatter_colours_the_level_and_timestamp(self): + record = make_record() + OriginFilter().filter(record) + rendered = ColorFormatter(BASE_FORMAT, DATE_FORMAT, use_color=True).format(record) + assert rendered.count(ESCAPE) >= 4 + + def test_colouring_does_not_mutate_the_shared_record(self): + """The same record then goes to the file handlers, which must stay plain.""" + record = make_record() + OriginFilter().filter(record) + ColorFormatter(BASE_FORMAT, DATE_FORMAT, use_color=True).format(record) + assert record.levelname == "INFO" + + def test_a_message_containing_the_level_marker_is_not_mangled(self): + """v1 coloured by str.replace over the rendered line, which hit the message.""" + record = make_record(message="parsed -INFO- from the header") + OriginFilter().filter(record) + rendered = ColorFormatter(BASE_FORMAT, DATE_FORMAT, use_color=True).format(record) + assert "parsed -INFO- from the header" in rendered + + def test_disabling_colour_produces_the_same_text_as_plain(self): + record = make_record() + OriginFilter().filter(record) + plain = PlainFormatter(BASE_FORMAT, DATE_FORMAT).format(record) + uncoloured = ColorFormatter(BASE_FORMAT, DATE_FORMAT, use_color=False).format(record) + assert plain == uncoloured + + +class TestColourDetection: + def test_never_is_never(self): + assert should_use_color("never") is False + + def test_a_non_tty_gets_no_colour(self): + """Redirected output filled the file with escape codes in v1.""" + assert should_use_color("auto", io.StringIO()) is False + + def test_no_color_env_var_is_respected(self, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + + class Tty(io.StringIO): + def isatty(self): + return True + + assert should_use_color("auto", Tty()) is False + + @pytest.mark.parametrize("mode", ["auto", "always"]) + def test_a_tty_may_get_colour(self, mode, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + + class Tty(io.StringIO): + def isatty(self): + return True + + # True on posix; on Windows it depends on the console accepting VT. + assert isinstance(should_use_color(mode, Tty()), bool) diff --git a/tests/_internal/test_handler.py b/tests/_internal/test_handler.py new file mode 100644 index 0000000..5d654d3 --- /dev/null +++ b/tests/_internal/test_handler.py @@ -0,0 +1,141 @@ +"""Files, directories, rollover and retention.""" + +from __future__ import annotations + +import logging +from datetime import date, timedelta + +import pytest + +from loggerhelper._internal import handler as handler_module +from loggerhelper._internal.config import LoggerConfig +from loggerhelper._internal.handler import ( + DailyDirRotatingFileHandler, + get_console_handler, + get_file_handler, + prune_old_logs, +) + + +def make_record(level=logging.INFO, message="hello"): + record = logging.LogRecord("test", level, __file__, 1, message, None, None) + record.origin = "test.func:1" + return record + + +@pytest.fixture +def handler(tmp_path): + built = DailyDirRotatingFileHandler(str(tmp_path), "INFO", retention_days=3) + built.setFormatter(logging.Formatter("%(message)s")) + yield built + built.close() + + +class TestLazyOpening: + def test_building_creates_nothing(self, tmp_path): + built = DailyDirRotatingFileHandler(str(tmp_path / "log"), "INFO") + try: + assert not (tmp_path / "log").exists() + finally: + built.close() + + def test_the_first_record_creates_the_dated_directory(self, handler, tmp_path): + handler.emit(make_record()) + assert (tmp_path / date.today().isoformat() / "INFO.log").exists() + + +class TestMidnight: + def test_the_directory_follows_the_date(self, handler, tmp_path, monkeypatch): + """v1 froze the date at construction and wrote into it forever.""" + handler.emit(make_record(message="today")) + + tomorrow = (date.today() + timedelta(days=1)).isoformat() + monkeypatch.setattr(handler_module, "_today", lambda: tomorrow) + handler.emit(make_record(message="tomorrow")) + + assert "today" in (tmp_path / date.today().isoformat() / "INFO.log").read_text() + assert "tomorrow" in (tmp_path / tomorrow / "INFO.log").read_text() + + def test_rolling_over_prunes_expired_directories(self, handler, tmp_path, monkeypatch): + stale = tmp_path / (date.today() - timedelta(days=40)).isoformat() + stale.mkdir(parents=True) + (stale / "INFO.log").write_text("old", encoding="utf-8") + + handler.emit(make_record()) + tomorrow = (date.today() + timedelta(days=1)).isoformat() + monkeypatch.setattr(handler_module, "_today", lambda: tomorrow) + handler.emit(make_record()) + + assert not stale.exists() + + +class TestSizeRotation: + def test_a_full_file_is_rotated(self, tmp_path): + built = DailyDirRotatingFileHandler(str(tmp_path), "INFO", max_bytes=200, backup_count=2) + built.setFormatter(logging.Formatter("%(message)s")) + try: + for index in range(40): + built.emit(make_record(message=f"line {index} " + "x" * 20)) + finally: + built.close() + + today = tmp_path / date.today().isoformat() + assert (today / "INFO.log.1").exists() + assert not (today / "INFO.log.3").exists(), "backup_count should cap the copies" + + def test_zero_max_bytes_never_rotates(self, handler, tmp_path): + for index in range(50): + handler.emit(make_record(message=f"line {index}")) + assert not (tmp_path / date.today().isoformat() / "INFO.log.1").exists() + + +class TestPruning: + def test_only_expired_dated_directories_go(self, tmp_path): + keep = tmp_path / date.today().isoformat() + drop = tmp_path / (date.today() - timedelta(days=10)).isoformat() + unrelated = tmp_path / "archive" + for directory in (keep, drop, unrelated): + directory.mkdir(parents=True) + + removed = prune_old_logs(str(tmp_path), retention_days=5) + + assert removed == 1 + assert keep.exists() and unrelated.exists() + assert not drop.exists() + + def test_zero_retention_keeps_everything(self, tmp_path): + old = tmp_path / (date.today() - timedelta(days=999)).isoformat() + old.mkdir(parents=True) + assert prune_old_logs(str(tmp_path), retention_days=0) == 0 + assert old.exists() + + def test_a_missing_directory_is_not_an_error(self, tmp_path): + assert prune_old_logs(str(tmp_path / "absent"), retention_days=5) == 0 + + +class TestLevelSeparation: + def test_a_file_handler_takes_only_its_own_level(self, tmp_path): + config = LoggerConfig(log_dir=str(tmp_path), level="DEBUG") + built = get_file_handler("WARNING", config) + try: + assert built.filter(make_record(level=logging.WARNING)) + assert not built.filter(make_record(level=logging.ERROR)) + assert not built.filter(make_record(level=logging.INFO)) + finally: + built.close() + + def test_debug_gets_a_file_too(self, tmp_path): + """v1 defined a colour for DEBUG but gave it nowhere to land.""" + config = LoggerConfig(log_dir=str(tmp_path), level="DEBUG") + built = get_file_handler("DEBUG", config) + try: + assert built.filter(make_record(level=logging.DEBUG)) + finally: + built.close() + + +class TestConsole: + def test_the_console_honours_the_configured_level(self, tmp_path): + config = LoggerConfig(log_dir=str(tmp_path), level="ERROR", color="never") + built = get_console_handler(config) + assert built.level == logging.ERROR diff --git a/tests/_internal/test_slack.py b/tests/_internal/test_slack.py new file mode 100644 index 0000000..ec0b01b --- /dev/null +++ b/tests/_internal/test_slack.py @@ -0,0 +1,251 @@ +""" +Slack alerting. No test here may touch the network. + +`urlopen` is replaced everywhere, and the listener thread is drained before any +assertion -- reading the captured calls while the background thread is still +working is how this suite would otherwise go flaky. +""" + +from __future__ import annotations + +import json +import logging + +import pytest + +from loggerhelper._internal.config import LoggerConfig +from loggerhelper._internal.notifiers import slack as slack_module +from loggerhelper._internal.notifiers.slack import SlackWebhookHandler, create_slack_handler + +WEBHOOK = "https://hooks.slack.com/services/T000/B000/xxx" + + +@pytest.fixture +def posted(monkeypatch): + """Capture every payload that would have been sent.""" + calls: list[dict] = [] + + class Response: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def fake_urlopen(request, timeout=None): + calls.append(json.loads(request.data.decode("utf-8"))) + return Response() + + monkeypatch.setattr(slack_module.urllib.request, "urlopen", fake_urlopen) + return calls + + +def make_record(level=logging.ERROR, message="it failed", origin="service.pool.acquire:87"): + record = logging.LogRecord("test", level, __file__, 1, message, None, None) + record.origin = origin + return record + + +class TestAttachment: + def test_no_webhook_means_no_handler_at_all(self): + assert create_slack_handler(LoggerConfig(log_dir=".")) is None + + def test_a_webhook_attaches_a_queued_handler(self, tmp_path): + config = LoggerConfig(log_dir=str(tmp_path), slack_webhook_url=WEBHOOK) + built = create_slack_handler(config) + assert built is not None + handler, listener = built + try: + assert handler.level == logging.WARNING + finally: + listener.stop() + + def test_the_threshold_follows_the_configured_level(self, tmp_path): + config = LoggerConfig( + log_dir=str(tmp_path), slack_webhook_url=WEBHOOK, slack_level="CRITICAL" + ) + handler, listener = create_slack_handler(config) + try: + assert handler.level == logging.CRITICAL + finally: + listener.stop() + + +class TestPayload: + def test_the_level_and_message_are_in_the_attachment(self, posted): + handler = SlackWebhookHandler(WEBHOOK, environment="prod", service="etl") + handler.emit(make_record()) + + attachment = posted[0]["attachments"][0] + assert attachment["title"].startswith("ERROR:") + assert "it failed" in attachment["title"] + assert {"title": "Environment", "value": "prod", "short": True} in attachment["fields"] + + def test_the_origin_travels_with_the_alert(self, posted): + handler = SlackWebhookHandler(WEBHOOK) + handler.emit(make_record()) + values = [f["value"] for f in posted[0]["attachments"][0]["fields"]] + assert "`service.pool.acquire:87`" in values + + def test_a_long_traceback_is_truncated(self, posted): + handler = SlackWebhookHandler(WEBHOOK) + handler.emit(make_record(message="summary\n" + "x" * 10_000)) + text = posted[0]["attachments"][0]["text"] + assert "more characters" in text + assert len(text) < 4000 + + def test_each_level_gets_its_own_colour(self, posted): + handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=0) + handler.emit(make_record(level=logging.WARNING)) + handler.emit(make_record(level=logging.CRITICAL)) + colors = {p["attachments"][0]["color"] for p in posted} + assert len(colors) == 2 + + +class TestThrottling: + def test_a_repeat_of_the_same_failure_is_held_back(self, posted): + handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=600) + for _ in range(5): + handler.emit(make_record()) + assert len(posted) == 1, "an error loop must not flood the channel" + + def test_a_different_origin_is_not_throttled(self, posted): + handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=600) + handler.emit(make_record(origin="a.b:1")) + handler.emit(make_record(origin="c.d:2")) + assert len(posted) == 2 + + def test_the_suppressed_count_is_reported_on_the_next_alert(self, posted, monkeypatch): + clock = [1000.0] + monkeypatch.setattr(slack_module.time, "monotonic", lambda: clock[0]) + + handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=60) + handler.emit(make_record()) + for _ in range(3): + handler.emit(make_record()) + + clock[0] += 120 + handler.emit(make_record()) + + fields = posted[-1]["attachments"][0]["fields"] + repeats = [f["value"] for f in fields if f["title"] == "Repeats"] + assert repeats and repeats[0].startswith("3 ") + + def test_the_per_minute_cap_stops_a_storm(self, posted): + handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=0, max_per_minute=3) + for index in range(20): + handler.emit(make_record(origin=f"mod.fn:{index}")) + assert len(posted) == 3 + + +class TestFailureIsolation: + def test_a_broken_webhook_does_not_raise(self, monkeypatch, capsys): + def explode(request, timeout=None): + raise OSError("network unreachable") + + monkeypatch.setattr(slack_module.urllib.request, "urlopen", explode) + handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=0) + + handler.emit(make_record()) # must not raise + + assert "Slack notification failed" in capsys.readouterr().err + + def test_repeated_failures_are_reported_once(self, monkeypatch, capsys): + def explode(request, timeout=None): + raise OSError("network unreachable") + + monkeypatch.setattr(slack_module.urllib.request, "urlopen", explode) + handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=0) + + for _ in range(5): + handler.emit(make_record(origin=f"mod.fn:{_}")) + + assert capsys.readouterr().err.count("Slack notification failed") == 1 + + def test_plain_http_is_refused(self): + with pytest.raises(ValueError, match="https"): + SlackWebhookHandler("http://hooks.slack.com/x") + + def test_stopping_twice_is_harmless(self, tmp_path): + """close() stops the listener and so does atexit; the second must be a no-op.""" + config = LoggerConfig(log_dir=str(tmp_path), slack_webhook_url=WEBHOOK) + _handler, listener = create_slack_handler(config) + listener.stop() + listener.stop() + + def test_a_full_queue_drops_rather_than_grows(self, tmp_path): + import queue + + from loggerhelper._internal.notifiers.slack import _DroppingQueueHandler + + handler = _DroppingQueueHandler(queue.Queue(maxsize=2)) + handler.setFormatter(logging.Formatter("%(message)s")) + for _ in range(10): + handler.emit(make_record()) + assert handler.dropped == 8 + + +class TestEndToEnd: + def test_a_warning_reaches_slack_and_an_info_does_not(self, posted, tmp_path): + from loggerhelper import Logger + + logger = Logger( + log_dir=str(tmp_path), + name="slack-e2e", + env={}, + color="never", + slack_webhook_url=WEBHOOK, + ) + try: + logger.info("routine") + logger.warning("degraded") + finally: + logger.close() # stops the listener, draining the queue + + assert len(posted) == 1 + assert "degraded" in posted[0]["attachments"][0]["title"] + + def test_the_traceback_from_catch_is_included(self, posted, tmp_path): + from loggerhelper import Logger + + logger = Logger( + log_dir=str(tmp_path), + name="slack-tb", + env={}, + color="never", + slack_webhook_url=WEBHOOK, + ) + + @logger.catch(reraise=False) + def failing(): + raise ValueError("boom") + + try: + failing() + finally: + logger.close() + + assert "ValueError: boom" in posted[0]["attachments"][0]["text"] + + def test_one_failure_produces_one_alert(self, posted, tmp_path): + """v1 logged the message and the traceback separately -- two alerts.""" + from loggerhelper import Logger + + logger = Logger( + log_dir=str(tmp_path), + name="slack-once", + env={}, + color="never", + slack_webhook_url=WEBHOOK, + ) + + @logger.catch(reraise=False) + def failing(): + raise ValueError("boom") + + try: + failing() + finally: + logger.close() + + assert len(posted) == 1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7a1d5f1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +""" +Shared fixtures. + +The two hazards in this suite are the module-level singleton (state carried +between tests) and open file handles (Windows refuses to delete a directory +whose files are still open), so both are torn down after every test. +""" + +from __future__ import annotations + +import logging + +import pytest + +import loggerhelper +from loggerhelper.logger import Logger + + +@pytest.fixture(autouse=True) +def reset_shared_logger(): + """Put the shared `log` back the way it was found.""" + yield + loggerhelper.log.close() + loggerhelper.log.reconfigure(config=None) + loggerhelper.log._settings = {} + loggerhelper.log._explicit_config = None + loggerhelper.log._env = None + for name in list(logging.Logger.manager.loggerDict): + if name.startswith("loggerhelper"): + existing = logging.getLogger(name) + for handler in list(existing.handlers): + existing.removeHandler(handler) + handler.close() + + +@pytest.fixture +def log_dir(tmp_path): + """A directory the logger *may* use; deliberately not created up front.""" + return tmp_path / "log" + + +@pytest.fixture +def make_logger(log_dir): + """Build throwaway loggers, each closed when the test finishes.""" + built: list[Logger] = [] + + def factory(**settings): + settings.setdefault("log_dir", str(log_dir)) + settings.setdefault("name", f"test{len(built)}") + settings.setdefault("env", {}) + logger = Logger(**settings) + built.append(logger) + return logger + + yield factory + + for logger in built: + logger.close() diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000..49ef497 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,57 @@ +""" +The v1 spellings still work, and say they are on the way out. + +`Database_connector_mcp` pins this package as a wheel and calls both of these, +so neither may break in the version that renames them. +""" + +from __future__ import annotations + +from datetime import date + +import pytest + + +def read(log_dir, level): + return (log_dir / date.today().isoformat() / f"{level}.log").read_text(encoding="utf-8") + + +def test_warn_still_logs_and_warns(make_logger, log_dir): + logger = make_logger() + + with pytest.warns(DeprecationWarning, match="use Logger.warning"): + logger.warn("old spelling") + + assert "old spelling" in read(log_dir, "WARNING") + + +def test_error_as_a_decorator_still_swallows(make_logger, log_dir): + """v1 returned None instead of raising; code written against it relies on that.""" + logger = make_logger() + + with pytest.warns(DeprecationWarning, match="decorator is deprecated"): + + @logger.error + def failing(): + raise RuntimeError("v1 behaviour") + + assert failing() is None + assert "RuntimeError in" in read(log_dir, "ERROR") + + +def test_error_with_a_message_does_not_warn(make_logger, recwarn): + logger = make_logger() + logger.error("an ordinary message") + assert not [w for w in recwarn if issubclass(w.category, DeprecationWarning)] + + +def test_positional_constructor_arguments_still_work(tmp_path): + """v1's `Logger("./log", "INFO")` must keep meaning the same thing.""" + from loggerhelper import Logger + + logger = Logger(str(tmp_path / "log"), "WARNING") + try: + assert logger.config.log_dir == str(tmp_path / "log") + assert logger.config.level == "WARNING" + finally: + logger.close() diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..824e0f1 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,189 @@ +"""The Logger itself: laziness, no duplicate output, and where `origin` points.""" + +from __future__ import annotations + +import logging + +import pytest + +import loggerhelper +from loggerhelper import Logger, configure + + +def read(log_dir, level, day=None): + from datetime import date + + day = day or date.today().isoformat() + return (log_dir / day / f"{level}.log").read_text(encoding="utf-8") + + +class TestLaziness: + def test_constructing_writes_nothing(self, make_logger, log_dir): + make_logger() + assert not log_dir.exists() + + def test_decorating_does_not_build_the_logger(self, make_logger, log_dir): + logger = make_logger() + + @logger.catch + def never_called(): # pragma: no cover - the point is that it isn't + pass + + assert not log_dir.exists() + + def test_the_first_line_builds_everything(self, make_logger, log_dir): + logger = make_logger() + logger.info("hello") + assert "hello" in read(log_dir, "INFO") + + def test_only_the_levels_used_get_a_file(self, make_logger, log_dir): + """Four empty files per run is noise; handlers open on demand.""" + logger = make_logger() + logger.info("hello") + + from datetime import date + + written = {p.name for p in (log_dir / date.today().isoformat()).iterdir()} + assert written == {"INFO.log"} + + +class TestNoDuplicateOutput: + def test_two_loggers_with_the_same_name_do_not_double_up(self, log_dir, capsys): + first = Logger(log_dir=str(log_dir), name="shared", env={}, color="never") + first.info("from the first") + second = Logger(log_dir=str(log_dir), name="shared", env={}, color="never") + second.info("from the second") + + try: + assert read(log_dir, "INFO").count("from the second") == 1 + finally: + first.close() + second.close() + + def test_records_do_not_reach_the_root_logger(self, make_logger): + """The application owns root; our lines are not its business.""" + logger = make_logger() + logger.info("mine") + assert logger._resolve().propagate is False + + +class TestOrigin: + def test_origin_names_the_caller_not_the_package(self, make_logger, log_dir): + logger = make_logger() + logger.warning("careful") + + line = read(log_dir, "WARNING") + assert "test_logger" in line + assert "logger.py" not in line + + def test_no_random_identifier_is_printed(self, make_logger, log_dir): + """v1 printed 4 hex characters of uuid4 that meant nothing.""" + logger = make_logger() + logger.info("plain") + assert read(log_dir, "INFO").count("=>") == 1 + + +class TestLevelMethods: + @pytest.mark.parametrize( + "method,level", + [ + ("debug", "DEBUG"), + ("info", "INFO"), + ("warning", "WARNING"), + ("error", "ERROR"), + ("critical", "CRITICAL"), + ], + ) + def test_each_level_writes_to_its_own_file(self, make_logger, log_dir, method, level): + logger = make_logger(level="DEBUG") + getattr(logger, method)("message for %s", level) + assert f"message for {level}" in read(log_dir, level) + + def test_error_takes_a_message(self, make_logger, log_dir): + """In v1 this raised AttributeError: the name belonged to the decorator.""" + logger = make_logger() + logger.error("the job failed") + assert "the job failed" in read(log_dir, "ERROR") + + def test_lazy_percent_formatting_is_passed_through(self, make_logger, log_dir): + logger = make_logger() + logger.info("took %s ms", 12) + assert "took 12 ms" in read(log_dir, "INFO") + + def test_exception_attaches_the_active_traceback(self, make_logger, log_dir): + logger = make_logger() + try: + raise ValueError("boom") + except ValueError: + logger.exception("while handling the request") + + written = read(log_dir, "ERROR") + assert "while handling the request" in written + assert "ValueError: boom" in written + + def test_log_takes_the_level_by_name(self, make_logger, log_dir): + logger = make_logger() + logger.log("CRITICAL", "chosen at runtime") + assert "chosen at runtime" in read(log_dir, "CRITICAL") + + def test_below_the_threshold_is_not_written(self, make_logger, log_dir): + logger = make_logger(level="WARNING") + logger.info("too quiet") + logger.warning("loud enough") + assert not (log_dir / "INFO.log").exists() + assert "loud enough" in read(log_dir, "WARNING") + + +class TestConfigureAndClose: + def test_configure_only_needs_a_directory(self, tmp_path): + target = tmp_path / "somewhere" + configure(log_dir=str(target), env={}, color="never") + loggerhelper.log.info("routed") + assert "routed" in read(target, "INFO") + + def test_configure_after_use_rebuilds_and_says_so(self, tmp_path): + first = tmp_path / "first" + second = tmp_path / "second" + configure(log_dir=str(first), env={}, color="never") + loggerhelper.log.info("early") + + configure(log_dir=str(second)) + loggerhelper.log.info("late") + + assert "early" in read(first, "INFO") + assert "late" in read(second, "INFO") + assert "reconfigured after use" in read(second, "WARNING") + + def test_close_releases_the_files_and_logging_again_recovers(self, make_logger, log_dir): + logger = make_logger() + logger.info("before") + logger.close() + + assert logger._resolve().handlers, "logging again should rebuild" + logger.info("after") + assert "after" in read(log_dir, "INFO") + + +class TestConfigProperty: + def test_reading_config_does_not_build_handlers(self, make_logger, log_dir): + logger = make_logger(level="warning") + assert logger.config.level == "WARNING" + assert not log_dir.exists() + + def test_explicit_argument_beats_environment(self, tmp_path): + chosen = tmp_path / "chosen" + logger = Logger(log_dir=str(chosen), env={"LOG_DIR": str(tmp_path / "ignored")}) + assert logger.config.log_dir == str(chosen) + + def test_a_none_argument_falls_through_to_the_environment(self, tmp_path): + """So callers can forward optional arguments without branching.""" + wanted = tmp_path / "from-env" + logger = Logger(log_dir=None, env={"LOG_DIR": str(wanted)}) + assert logger.config.log_dir == str(wanted) + + +def test_stdlib_logger_name_is_stable(make_logger): + """v1 used a random name per instance, which leaked a logger every time.""" + logger = make_logger(name="stable") + logger.info("x") + assert logger._resolve() is logging.getLogger("loggerhelper.stable") diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..a0c27ca --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,33 @@ +"""What `from loggerhelper import ...` promises.""" + +from __future__ import annotations + +import loggerhelper + + +def test_the_documented_names_are_importable(): + from loggerhelper import ConfigError, Logger, LoggerConfig, configure, log + + assert callable(configure) + assert isinstance(log, Logger) + assert issubclass(ConfigError, ValueError) + assert LoggerConfig().level == "INFO" + + +def test_all_matches_what_the_module_actually_exports(): + for name in loggerhelper.__all__: + assert hasattr(loggerhelper, name), f"__all__ names {name}, which is missing" + + +def test_internals_are_not_part_of_the_public_namespace(): + assert "_internal" not in loggerhelper.__all__ + + +def test_importing_creates_nothing_on_disk(tmp_path, monkeypatch): + """A package that makes directories on import forces consumers to wrap it.""" + import importlib + + monkeypatch.chdir(tmp_path) + importlib.reload(loggerhelper) + + assert list(tmp_path.iterdir()) == [] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ac76036 --- /dev/null +++ b/uv.lock @@ -0,0 +1,534 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "loggerhelper" +version = "2.0.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pre-commit", version = "4.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pre-commit", version = "4.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=3.7" }, + { name = "pytest", specifier = ">=8.2" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "ruff", specifier = ">=0.4" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/36/0a/062135c9a98dac804265073cc3afdbec5ae1aa37980bb354f461bafe81b4/platformdirs-4.11.1.tar.gz", hash = "sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27", size = 32396, upload-time = "2026-08-07T23:06:48.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl", hash = "sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386", size = 23261, upload-time = "2026-08-07T23:06:47.219Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "identify", version = "2.6.15", source = { registry = "https://pypi.org/simple" } }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "cfgv", version = "3.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "identify", version = "2.6.19", source = { registry = "https://pypi.org/simple" } }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "backports-asyncio-runner" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "filelock", version = "3.32.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/b7/1581a8103855c43567776aa34135e5ec3c597346c23bfd10c7eb5e0b10a4/python_discovery-1.5.1.tar.gz", hash = "sha256:e2ea8b884cd1701f386eda8cf327b87743f1dc21b7f784470799537d95635384", size = 77200, upload-time = "2026-07-31T22:06:02.48Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "filelock", version = "3.32.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/10/8b7a5454efc032be50c1c5641467dbb5d31314500205212b2aa66d3c8af4/virtualenv-21.7.3.tar.gz", hash = "sha256:5e9e287f5c808070eea3b40403d3368248e64b24f1b60bd9a36e85ac841d2c3e", size = 5525482, upload-time = "2026-08-08T14:43:20.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/2a/83d779d2dfb61f101d7b1c10073d18984e37262d8b4f171c99911a952430/virtualenv-21.7.3-py3-none-any.whl", hash = "sha256:26dfda3c34f29bf1a3ca167426a67658d59979b9954e705aef60a5f724ce1773", size = 5504590, upload-time = "2026-08-08T14:43:18.57Z" }, +] From b7da7649908b2908d6482e16030827dd5d7c99e4 Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 11 Aug 2026 00:16:09 +0800 Subject: [PATCH 2/3] refactor: drop type annotations, make the README the full reference The annotations were not carrying their weight in a package this size, and without them the README has to be complete -- so it now documents every method, argument and accepted value in one place. - No annotations anywhere in src or tests, and no `from __future__` imports. - LoggerConfig becomes a plain class with an explicit __init__; a dataclass cannot exist without annotated fields. SETTINGS replaces dataclasses.fields() for the error messages. - py.typed removed: it advertises inline types the package no longer ships. - LoggerConfig.replace() added, and a config passed to Logger() is now a base rather than the last word -- `Logger(config=shared, name=worker)` silently dropped the name before, which the README would have promised worked. - requires-python stays >=3.9, with the reasoning recorded: the code runs on 3.8 (verified, 126 tests) and `logging`'s stacklevel= is the hard floor, but 3.8 went end of life in October 2024. Co-Authored-By: Claude Opus 5 --- README.md | 382 +++++++++++++++--- pyproject.toml | 3 + src/loggerhelper/_internal/config.py | 164 +++++--- src/loggerhelper/_internal/decorators.py | 36 +- src/loggerhelper/_internal/format.py | 25 +- src/loggerhelper/_internal/handler.py | 36 +- src/loggerhelper/_internal/notifiers/slack.py | 53 ++- src/loggerhelper/logger.py | 97 ++--- src/loggerhelper/py.typed | 0 tests/_internal/test_config.py | 24 +- tests/_internal/test_decorators.py | 4 +- tests/_internal/test_format.py | 2 - tests/_internal/test_handler.py | 2 - tests/_internal/test_slack.py | 4 +- tests/conftest.py | 4 +- tests/test_compat.py | 2 - tests/test_logger.py | 12 +- tests/test_public_api.py | 2 - 18 files changed, 553 insertions(+), 299 deletions(-) delete mode 100644 src/loggerhelper/py.typed diff --git a/README.md b/README.md index ac533ba..55e0049 100644 --- a/README.md +++ b/README.md @@ -7,47 +7,116 @@ A small logging package with no dependencies: - A decorator that logs an escaping exception and names the definition it escaped from - Optional Slack alerts, attached only when a webhook is configured +This README is the complete reference. The source carries no type annotations, +so every signature and accepted value is written out below. + +--- + +## Contents + +- [Install](#install) +- [Quick start](#quick-start) +- [Logging a message](#logging-a-message) +- [The error decorator](#the-error-decorator) +- [Choosing a level](#choosing-a-level) +- [Configuring](#configuring) +- [Environment variables](#environment-variables) +- [Slack alerts](#slack-alerts) +- [More than one logger](#more-than-one-logger) +- [What ends up on disk](#what-ends-up-on-disk) +- [Full API](#full-api) +- [Upgrading from 1.x](#upgrading-from-1x) +- [Development](#development) + +--- + ## Install ```bash pip install loggerhelper ``` -Requires Python 3.9 or newer. +Requires Python 3.9 or newer. (The code itself runs on 3.8 — `logging`'s +`stacklevel=` argument is the hard floor — but 3.8 is past end of life, so it is +not a supported version.) + +--- -## Usage +## Quick start ```python from loggerhelper import log +log.info("System started") +``` + +That is the whole setup. Nothing is created on disk until the first line is +written, so importing this costs nothing and no initialisation step is needed. + +To choose where the files go: + +```python +from loggerhelper import configure + +configure(log_dir="./logs") +``` + +--- + +## Logging a message + +Every method takes the same arguments as the standard library's, so anything you +already know about `logging` applies. + +```python +log.debug("cache hit for key=%s", key) log.info("System started") log.warning("retry 2 succeeded, carrying on") log.error("could not reach the queue") log.critical("configuration is missing, shutting down") +``` + +**Lazy formatting.** Pass the arguments instead of building the string, and the +formatting only happens if the record is actually emitted: -log.info("took %s ms", elapsed) # lazy formatting, as in `logging` +```python +log.info("took %s ms for %s rows", elapsed, count) +``` +**Inside `except`.** `exception()` is `error()` with the active traceback +attached: + +```python try: parse(row) except ValueError: - log.exception("bad row, skipping") # attaches the active traceback + log.exception("bad row, skipping") ``` -That is the whole setup. Nothing is created on disk until the first line is -written, so importing this costs nothing. +**Choosing the level at runtime:** -To choose where the files go: +```python +level = "WARNING" if recovered else "ERROR" +log.log(level, "upstream call finished badly") +``` + +**Attaching the traceback yourself,** or passing extra record fields: ```python -from loggerhelper import configure +log.error("upload failed", exc_info=exc) +log.info("request done", extra={"request_id": rid}) +``` + +Each line records where it was called from: -configure(log_dir="./logs") +``` +[2026-08-11 09:14:02]-INFO-(service.pool.acquire:87)=> connection established + ^^^^^^^^^^^^^^^^^^^^^^ module.function:line ``` -Everything else has a default. Call `configure()` before the first log line; -calling it later is allowed and says so in the log. +--- -### The error decorator +## The error decorator ```python from loggerhelper import log @@ -58,91 +127,203 @@ def handle(request): ``` An exception escaping `handle` is logged at `ERROR` with its traceback, and then -re-raised. The message names the definition and the line that actually raised: +**re-raised** — the caller still sees the failure. The message names the +definition and the line that actually raised: ``` [2026-08-11 09:14:02]-ERROR-(service.pool.acquire:87)=> ConnectionRefusedError in service.pool.AdapterPool.acquire (pool.py:87): [Errno 111] refused ``` -Options: +For a method, the name includes the class (`AdapterPool.acquire`). The line +number is where the exception was raised, not where the decorator sits. + +### Options + +```python +@log.catch(level="CRITICAL") # start-up paths, where failure ends the process +@log.catch(reraise=False) # swallow it and return None +@log.catch(reraise=False, default=[]) # swallow it and return something else +@log.catch(exclude=(KeyError,)) # let these through unlogged +``` + +| Argument | Default | Meaning | +| --------- | --------- | ----------------------------------------------------------- | +| `level` | `"ERROR"` | Level to log the failure at | +| `reraise` | `True` | Re-raise after logging; `False` swallows and returns `default` | +| `default` | `None` | What to return when `reraise=False` | +| `exclude` | `()` | Exception types to re-raise without logging | + +### Notes + +- **`async def` works.** The coroutine is awaited inside the guard. +- **One record, not two.** The message and its traceback are a single log entry, + so they cannot be interleaved with other lines and only fire one Slack alert. +- **`KeyboardInterrupt` and `SystemExit` are never caught.** +- **Decorating does not build the logger,** so it is safe at import time. ```python -@log.catch(level="CRITICAL") # start-up paths, where failure ends the process -@log.catch(reraise=False, default=[]) # swallow and return a fallback instead -@log.catch(exclude=(KeyError,)) # let these through without logging +@log.catch +async def fetch(url): + ... ``` -`async def` works too. Decorating never builds the logger, so this is safe at -import time. +--- -### Choosing a level +## Choosing a level -Pick by **blast radius** -- what the failure costs -- not by how important the +Pick by **blast radius** — what the failure costs — not by how important the function is. That is what makes the decorator's default correct without you -having to decide per function. +deciding per function. -| Level | The event | Example | -| ---------- | ------------------------------------------------------- | --------------------------------------------- | -| `DEBUG` | Detail only useful while working on the code | the SQL that was built, a cache hit | -| `INFO` | A normal lifecycle event | service started, connection established | -| `WARNING` | Degraded, but **this operation still succeeded** | a retry worked, a fallback was used | -| `ERROR` | **This operation failed**; the service is still running | an exception escaped a request handler | -| `CRITICAL` | **The process or service cannot continue** | required config missing, connection pool dead | +| Level | The event | Example | Slack | +| ---------- | ------------------------------------------------------- | --------------------------------------------- | ----- | +| `DEBUG` | Detail only useful while working on the code | the SQL that was built, a cache hit | no | +| `INFO` | A normal lifecycle event | service started, connection established | no | +| `WARNING` | Degraded, but **this operation still succeeded** | a retry worked, a fallback was used | yes | +| `ERROR` | **This operation failed**; the service is still running | an exception escaped a request handler | yes | +| `CRITICAL` | **The process or service cannot continue** | required config missing, connection pool dead | yes | -The line that follows from this: an unhandled exception escaping a function -means that operation failed, so `@log.catch` defaults to `ERROR`. Reserve -`level="CRITICAL"` for start-up and other paths where the failure ends the -process. `WARNING` is for things that recovered -- if it did not recover, it is -an `ERROR`. +What follows from this: -### Slack alerts +- An unhandled exception escaping a function means that operation failed, so + `@log.catch` defaults to `ERROR`. +- Reserve `level="CRITICAL"` for start-up and other paths where the failure ends + the process. +- `WARNING` is for things that **recovered**. If it did not recover, it is an + `ERROR`. -Set a webhook and alerts start; set nothing and the handler is never attached. +```python +@log.catch(level="CRITICAL") +def load_config(): # if this fails there is no service + ... -```bash -export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." -export SLACK_LOG_LEVEL="WARNING" # optional, this is the default -export SLACK_ENV="prod" # optional tag shown on the alert +@log.catch +def handle(request): # if this fails, this request failed + ... ``` -Sending happens on a background thread behind a bounded queue, so logging never -blocks on the network and a Slack outage cannot slow the application down. -Repeats of the same failure (same level, same origin, same exception type) are -collapsed into a count rather than flooding the channel, and a failing webhook -is reported to stderr once and then ignored. +--- -### Settings +## Configuring Explicit argument wins, then the environment variable, then the default. +```python +from loggerhelper import configure + +configure(log_dir="./logs") # the common case +configure(log_dir="./logs", level="DEBUG") +configure(level="WARNING", retention_days=7) +configure(load_dotenv=True) # apply the nearest .env first +``` + +`configure()` accepts every setting in the table below, plus: + +| Argument | Default | Meaning | +| ------------- | ------- | -------------------------------------------------------------- | +| `config` | `None` | A ready-made `LoggerConfig`, instead of individual settings | +| `env` | `None` | Mapping to read instead of `os.environ` (useful in tests) | +| `load_dotenv` | `False` | Apply the nearest `.env` before reading settings | + +Call it before the first log line. Calling it afterwards is allowed — the +handlers are rebuilt and a `WARNING` is written saying so, because earlier lines +went somewhere else. + +An argument of `None` means "not given" and falls through to the environment, so +you can forward optional values without branching: + +```python +configure(log_dir=args.log_dir, level=args.log_level) # None for either is fine +``` + +--- + +## Environment variables + +Everything is optional. + | Variable | Default | Meaning | | -------------------- | -------------------- | ------------------------------------------------------------------- | | `LOG_DIR` | `/log` | Where the dated directories go | -| `LOG_LEVEL` | `INFO` | Threshold | -| `LOG_NAME` | unset | Tag on every line; separates one logger from another | -| `LOG_MAX_BYTES` | `52428800` (50 MB) | Per-file cap before rotation; `0` disables | +| `LOG_LEVEL` | `INFO` | Threshold: `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL` | +| `LOG_NAME` | unset | Tag on every line; also separates one logger from another | +| `LOG_MAX_BYTES` | `52428800` (50 MB) | Per-file cap before rotation; `0` disables size rotation | | `LOG_BACKUP_COUNT` | `5` | Rotated copies kept | | `LOG_RETENTION_DAYS` | `30` | Dated directories older than this are deleted; `0` keeps everything | | `LOG_COLOR` | `auto` | `auto` / `always` / `never` | +| `NO_COLOR` | unset | Any value disables colour (the cross-tool convention) | | `SLACK_WEBHOOK_URL` | unset | Enables Slack when set. Must be `https://` | | `SLACK_LOG_LEVEL` | `WARNING` | Threshold for alerts | -| `SLACK_ENV` | unset | Deployment tag on alerts | +| `SLACK_ENV` | unset | Deployment tag ("prod", "staging") shown on alerts | + +`LOG_DIR` is detected by walking up from the working directory for a `.venv`, +`pyproject.toml` or `.git`, and putting `log/` next to it — so an installed copy +logs into your project, not into `site-packages`. + +Colour is on when the stream is a terminal and off otherwise, so redirected +output and CI logs stay free of escape codes. + +--- + +## Slack alerts -`configure(load_dotenv=True)` applies the nearest `.env` first. It is off by -default: a library should not edit the environment behind the application's back. +Set a webhook and alerts start. Set nothing and the handler is never attached — +there is no cost and no code path taken. -Multiple loggers writing to different places: +```bash +export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." +export SLACK_LOG_LEVEL="WARNING" # optional, this is the default +export SLACK_ENV="prod" # optional tag shown on the alert +``` + +Or in code: + +```python +configure(slack_webhook_url="https://hooks.slack.com/services/...", slack_level="ERROR") +``` + +An alert carries the level, the message, the origin, the deployment tag and the +traceback, coloured by severity. + +What it does for you: + +- **Never blocks.** Sending happens on a background thread behind a bounded + queue, so a slow or dead webhook cannot slow the application down. If the + queue fills up, records are dropped rather than growing memory. +- **Does not flood the channel.** Repeats of the same failure (same level, same + origin, same exception type) within 60 seconds are collapsed, and the next + alert reports how many were folded in. There is also a cap of 20 alerts per + minute. +- **Never raises.** A failing webhook is reported to stderr once and then + ignored. +- **`https://` only.** An `http://` webhook is rejected rather than putting the + secret on the wire in clear text. + +--- + +## More than one logger ```python from loggerhelper import Logger audit = Logger(log_dir="./audit", name="audit") +audit.info("user 42 exported the customer table") ``` -Instances are keyed by `name`, which is what stops the same line being written -twice when a module is imported more than once. +`Logger(...)` takes the same settings as `configure(...)`. Instances are keyed by +`name`: two `Logger()` objects with the same name share one set of handlers, +which is what stops the same line being written twice when a module is imported +more than once. Give a distinct `name` to a logger that should write somewhere +else. -### On disk +```python +logger = Logger(log_dir="./logs", level="DEBUG", name="worker") +logger.close() # release the files; logging again rebuilds +``` + +--- + +## What ends up on disk ``` log/ @@ -154,9 +335,84 @@ log/ └── CRITICAL.log ``` -Only levels that were actually used get a file. The date is decided when a -record is written, so a process that outlives midnight rolls into the new -directory. +- Each file holds **only** its own level — an `ERROR` never appears in + `INFO.log`. +- Only levels that were actually used get a file. +- The date is decided when a record is written, so a process that outlives + midnight rolls into the new directory. +- A file over `LOG_MAX_BYTES` rotates to `INFO.log.1`, `INFO.log.2`, up to + `LOG_BACKUP_COUNT` copies. +- Directories older than `LOG_RETENTION_DAYS` are deleted at rollover. Only + directories named exactly `YYYY-MM-DD` are touched. + +--- + +## Full API + +Everything importable from `loggerhelper`: + +```python +from loggerhelper import log, Logger, configure, LoggerConfig, ConfigError +``` + +### `log` + +The shared logger, ready to use. An instance of `Logger`. + +### `Logger(log_dir=None, level=None, name=None, config=None, env=None, **settings)` + +| Method | Purpose | +| ---------------------------- | ---------------------------------------------------------- | +| `debug(msg, *args, **kw)` | Development detail | +| `info(msg, *args, **kw)` | Normal lifecycle event | +| `warning(msg, *args, **kw)` | Degraded but recovered | +| `error(msg, *args, **kw)` | This operation failed | +| `critical(msg, *args, **kw)` | Cannot continue | +| `exception(msg, ...)` | `error` with the active traceback | +| `log(level, msg, ...)` | Level chosen at runtime, by name | +| `catch(func=None, level="ERROR", reraise=True, default=None, exclude=())` | The decorator | +| `close()` | Release files and stop the Slack thread | +| `reconfigure(config=None, env=None, **settings)` | Change settings after construction | +| `config` | Property: the resolved `LoggerConfig`; reading it builds nothing | +| `warn(msg, ...)` | Deprecated alias for `warning` | + +All message methods accept `*args` for lazy `%` formatting, and `exc_info=`, +`extra=`, `stack_info=`, `stacklevel=` as in the standard library. + +### `configure(log_dir=None, level=None, name=None, config=None, env=None, load_dotenv=False, **settings)` + +Applies settings to the shared `log` and returns it. + +### `LoggerConfig(log_dir=None, level="INFO", name=None, max_bytes=52428800, backup_count=5, retention_days=30, color="auto", slack_webhook_url=None, slack_level="WARNING", slack_env=None)` + +A validated settings object. Build one directly if you want to share the same +settings between loggers: + +```python +from loggerhelper import Logger, LoggerConfig + +settings = LoggerConfig(log_dir="./logs", level="DEBUG", retention_days=7) +worker = Logger(config=settings, name="worker") +audit = Logger(config=settings, name="audit", log_dir="./audit") +``` + +Settings passed alongside a `config` are applied on top of it, so one config can +act as the base for several loggers that differ in a detail. + +| Member | Purpose | +| ------------------------------- | ------------------------------------------------------- | +| `LoggerConfig.from_env(env=None, **overrides)` | Build from the environment, overrides winning | +| `config.replace(**changes)` | A copy with some settings changed; `None` means "leave it" | +| `config.slack_enabled` | Whether a webhook was configured | +| `LoggerConfig.SETTINGS` | The names of every accepted setting | + +### `ConfigError` + +Raised for a setting that cannot be used — an unknown level, a non-numeric size, +an `http://` webhook. Subclasses `ValueError`, and the message names the setting +and lists the valid values. + +--- ## Upgrading from 1.x @@ -165,7 +421,7 @@ directory. | `logs = Logger()` | `from loggerhelper import log` | Or keep constructing your own; both work | | `logs.warn(msg)` | `log.warning(msg)` | `warn` still works, with a `DeprecationWarning` | | `@logs.error` | `@log.catch` | `@logs.error` still works, with a `DeprecationWarning` | -| `logs.error("message")` | `log.error("message")` | This *raised* in 1.x -- the name belonged to the decorator | +| `logs.error("message")` | `log.error("message")` | This *raised* in 1.x — the name belonged to the decorator | Three behaviour changes to know about: @@ -184,6 +440,8 @@ Three behaviour changes to know about: assert not path.exists() or not path.read_text() # nothing logged at this level ``` +--- + ## Development ```bash @@ -192,6 +450,10 @@ uv run pre-commit install uv run pytest ``` +`src/loggerhelper/logger.py` is the only implementation module a consumer needs +to read. Everything under `src/loggerhelper/_internal/` is detail and may change +between releases. + ## License MIT diff --git a/pyproject.toml b/pyproject.toml index 7560c1b..047c42f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,9 @@ authors = [ ] license = {text = "MIT"} readme = "README.md" +# The code runs on 3.8 (verified) -- `logging`'s `stacklevel=` argument is the +# hard floor, and that arrived in 3.8. 3.9 is declared because 3.8 reached end +# of life in October 2024 and is not worth promising support for. requires-python = ">=3.9" dependencies = [] diff --git a/src/loggerhelper/_internal/config.py b/src/loggerhelper/_internal/config.py index 01c4f78..db78a89 100644 --- a/src/loggerhelper/_internal/config.py +++ b/src/loggerhelper/_internal/config.py @@ -10,13 +10,8 @@ inject a mapping instead of mutating the process environment. """ -from __future__ import annotations - import os -from collections.abc import Mapping -from dataclasses import dataclass, field, fields from pathlib import Path -from typing import Any DEFAULT_LEVEL = "INFO" DEFAULT_MAX_BYTES = 50 * 1024 * 1024 @@ -38,7 +33,7 @@ class ConfigError(ValueError): """A setting was given, but not in a form that can be used.""" -def normalise_level(value: str, *, source: str = "level") -> str: +def normalise_level(value, source="level"): """Canonical upper-case level name, or a message naming the valid ones.""" name = str(value).strip().upper() name = _LEVEL_ALIASES.get(name, name) @@ -49,15 +44,16 @@ def normalise_level(value: str, *, source: str = "level") -> str: return name -def level_number(value: str) -> int: - """The `logging` integer for a level name. +def level_number(value): + """ + The `logging` integer for a level name. Replaces the private ``logging._nameToLevel`` the first version reached for. """ return _LEVEL_NUMBERS[normalise_level(value)] -def detect_log_dir(start: Path | None = None) -> str: +def detect_log_dir(start=None): """ Walk up from the working directory for a project root and put ``log/`` there. @@ -72,68 +68,113 @@ def detect_log_dir(start: Path | None = None) -> str: return str(current / "log") -def _as_int(raw: str, source: str) -> int: +def _as_int(raw, source): try: return int(raw.strip()) except ValueError: raise ConfigError(f"{source}={raw!r} must be a whole number") from None -@dataclass(frozen=True) class LoggerConfig: - """Everything a :class:`~loggerhelper.logger.Logger` needs, already validated.""" - - log_dir: str = field(default_factory=detect_log_dir) - level: str = DEFAULT_LEVEL - #: Tags every line and separates one logger from another. Optional. - name: str | None = None - #: Per-file size cap before rotation. 0 disables size rotation. - max_bytes: int = DEFAULT_MAX_BYTES - backup_count: int = DEFAULT_BACKUP_COUNT - #: Dated directories older than this are removed. 0 keeps everything. - retention_days: int = DEFAULT_RETENTION_DAYS - color: str = "auto" - #: Unset means the Slack handler is never attached at all. - slack_webhook_url: str | None = None - slack_level: str = DEFAULT_SLACK_LEVEL - #: Free-form deployment tag ("prod", "staging") shown on Slack alerts. - slack_env: str | None = None - - def __post_init__(self) -> None: - object.__setattr__(self, "log_dir", str(self.log_dir)) - object.__setattr__(self, "level", normalise_level(self.level, source="level")) - object.__setattr__( - self, "slack_level", normalise_level(self.slack_level, source="slack_level") - ) - - color = str(self.color).strip().lower() - if color not in _COLOR_MODES: - raise ConfigError(f"color={self.color!r} must be one of: {', '.join(_COLOR_MODES)}") - object.__setattr__(self, "color", color) + """ + Everything a Logger needs, already validated. + + ``log_dir`` where the dated directories go; detected if omitted + ``level`` threshold, one of LEVEL_NAMES + ``name`` tag on every line; also separates one logger from another + ``max_bytes`` per-file cap before rotation; 0 disables size rotation + ``backup_count`` rotated copies kept + ``retention_days`` dated directories older than this are deleted; 0 keeps all + ``color`` "auto", "always" or "never" + ``slack_webhook_url`` unset means the Slack handler is never attached + ``slack_level`` threshold for Slack alerts + ``slack_env`` deployment tag ("prod") shown on alerts + """ - for attr in ("max_bytes", "backup_count", "retention_days"): - value = getattr(self, attr) + #: Every accepted setting, in the order they appear in __init__. + SETTINGS = ( + "log_dir", + "level", + "name", + "max_bytes", + "backup_count", + "retention_days", + "color", + "slack_webhook_url", + "slack_level", + "slack_env", + ) + + def __init__( + self, + log_dir=None, + level=DEFAULT_LEVEL, + name=None, + max_bytes=DEFAULT_MAX_BYTES, + backup_count=DEFAULT_BACKUP_COUNT, + retention_days=DEFAULT_RETENTION_DAYS, + color="auto", + slack_webhook_url=None, + slack_level=DEFAULT_SLACK_LEVEL, + slack_env=None, + ): + self.log_dir = str(detect_log_dir() if log_dir is None else log_dir) + self.level = normalise_level(level, source="level") + self.slack_level = normalise_level(slack_level, source="slack_level") + self.name = name + self.slack_env = slack_env + + mode = str(color).strip().lower() + if mode not in _COLOR_MODES: + raise ConfigError(f"color={color!r} must be one of: {', '.join(_COLOR_MODES)}") + self.color = mode + + for attr, value in ( + ("max_bytes", max_bytes), + ("backup_count", backup_count), + ("retention_days", retention_days), + ): if isinstance(value, bool) or not isinstance(value, int) or value < 0: raise ConfigError(f"{attr}={value!r} must be a whole number >= 0") + setattr(self, attr, value) - url = self.slack_webhook_url + url = slack_webhook_url if url is not None: - url = url.strip() - if not url: - url = None - elif not url.startswith("https://"): - # The handler posts to this with urllib; anything but https is - # either a typo or credentials over the wire in clear text. - raise ConfigError(f"slack_webhook_url={self.slack_webhook_url!r} must be https://") - object.__setattr__(self, "slack_webhook_url", url) + url = url.strip() or None + if url is not None and not url.startswith("https://"): + # The handler posts to this with urllib; anything but https is + # either a typo or credentials over the wire in clear text. + raise ConfigError(f"slack_webhook_url={slack_webhook_url!r} must be https://") + self.slack_webhook_url = url + + def __repr__(self): + shown = ", ".join(f"{name}={getattr(self, name)!r}" for name in self.SETTINGS) + return f"LoggerConfig({shown})" @property - def slack_enabled(self) -> bool: + def slack_enabled(self): """No webhook configured means the Slack plugin stays out of the way.""" return bool(self.slack_webhook_url) + def replace(self, **changes): + """ + A copy with some settings changed. A change of ``None`` is ignored. + + Lets one config act as a base for several loggers that differ in a + detail or two, rather than each having to repeat the whole set. + """ + unknown = set(changes) - set(self.SETTINGS) + if unknown: + raise ConfigError( + f"unknown setting(s): {', '.join(sorted(unknown))}. " + f"Known settings: {', '.join(self.SETTINGS)}" + ) + values = {name: getattr(self, name) for name in self.SETTINGS} + values.update({k: v for k, v in changes.items() if v is not None}) + return LoggerConfig(**values) + @classmethod - def from_env(cls, *, env: Mapping[str, str] | None = None, **overrides: Any) -> LoggerConfig: + def from_env(cls, env=None, **overrides): """ Build a config from the environment, with ``overrides`` winning. @@ -142,10 +183,10 @@ def from_env(cls, *, env: Mapping[str, str] | None = None, **overrides: Any) -> without branching on whether the user supplied them. """ env = os.environ if env is None else env - values: dict[str, Any] = {} + values = {} remaining = dict(overrides) - def pick(field_name: str, env_key: str, cast: Any = None) -> None: + def pick(field_name, env_key, cast=None): given = remaining.pop(field_name, None) if given is not None: values[field_name] = given @@ -168,20 +209,21 @@ def pick(field_name: str, env_key: str, cast: Any = None) -> None: if remaining: unknown = ", ".join(sorted(remaining)) - known = ", ".join(f.name for f in fields(cls)) - raise ConfigError(f"unknown setting(s): {unknown}. Known settings: {known}") + raise ConfigError( + f"unknown setting(s): {unknown}. Known settings: {', '.join(cls.SETTINGS)}" + ) return cls(**values) -def parse_dotenv(text: str) -> dict[str, str]: +def parse_dotenv(text): """ Parse ``KEY=value`` lines. Deliberately small: no interpolation, no export. Kept here rather than taking a dependency, since this package promises to install nothing. """ - values: dict[str, str] = {} + values = {} for line in text.splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#"): @@ -201,9 +243,7 @@ def parse_dotenv(text: str) -> dict[str, str]: return values -def load_repo_dotenv( - *, filename: str = ".env", start: Path | None = None, override: bool = False -) -> Path | None: +def load_repo_dotenv(filename=".env", start=None, override=False): """ Apply the nearest ``.env`` walking up from the working directory. diff --git a/src/loggerhelper/_internal/decorators.py b/src/loggerhelper/_internal/decorators.py index 5833a77..8dbc19c 100644 --- a/src/loggerhelper/_internal/decorators.py +++ b/src/loggerhelper/_internal/decorators.py @@ -12,20 +12,12 @@ returned the coroutine object without awaiting it, so nothing was ever caught. """ -from __future__ import annotations - import functools import inspect import os -from typing import Any, Callable, TypeVar - -F = TypeVar("F", bound=Callable[..., Any]) - -#: ``emit(level_name, message, exception)`` -EmitFn = Callable[[str, str, BaseException], None] -def describe_failure(func: Callable[..., Any], exc: BaseException) -> str: +def describe_failure(func, exc): """ ``TypeError in service.pool.AdapterPool.acquire (pool.py:87): ...`` @@ -46,28 +38,20 @@ def describe_failure(func: Callable[..., Any], exc: BaseException) -> str: return f"{type(exc).__name__} in {where}{location}: {exc}" -def catch( - emit: EmitFn, - func: F | None = None, - *, - level: str = "ERROR", - reraise: bool = True, - default: Any = None, - exclude: tuple[type[BaseException], ...] = (), -) -> Any: +def catch(emit, func=None, level="ERROR", reraise=True, default=None, exclude=()): """ Wrap ``func`` so an escaping exception is logged through ``emit``. - Usable bare (``@catch``) or called (``@catch(level="CRITICAL")``). - ``emit`` is a callable rather than a logger so the decorator can be applied - at import time without forcing a logger to be built. + ``emit(level, message, exception)`` is a callable rather than a logger, so + the decorator can be applied at import time without forcing a logger to be + built. Usable bare (``@catch``) or called (``@catch(level="CRITICAL")``). """ - def decorate(target: F) -> F: + def decorate(target): if inspect.iscoroutinefunction(target): @functools.wraps(target) - async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + async def async_wrapper(*args, **kwargs): try: return await target(*args, **kwargs) except exclude: @@ -78,10 +62,10 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: raise return default - return async_wrapper # type: ignore[return-value] + return async_wrapper @functools.wraps(target) - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(*args, **kwargs): try: return target(*args, **kwargs) except exclude: @@ -92,6 +76,6 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: raise return default - return wrapper # type: ignore[return-value] + return wrapper return decorate if func is None else decorate(func) diff --git a/src/loggerhelper/_internal/format.py b/src/loggerhelper/_internal/format.py index cc76c44..009ae6d 100644 --- a/src/loggerhelper/_internal/format.py +++ b/src/loggerhelper/_internal/format.py @@ -6,15 +6,12 @@ then render it, coloured on a terminal and plain everywhere else. """ -from __future__ import annotations - import copy import logging import os import sys from functools import lru_cache from pathlib import Path -from typing import IO #: The line every handler renders. `origin` comes from OriginFilter. BASE_FORMAT = "[%(asctime)s]-%(levelname)s-(%(origin)s)=> %(message)s" @@ -45,7 +42,7 @@ class Color: @lru_cache(maxsize=512) -def _dotted_module(pathname: str, fallback: str) -> str: +def _dotted_module(pathname, fallback): """ ``src/service/pool.py`` -> ``service.pool``, by walking up while there is an ``__init__.py``. Cached, so the directory probing costs once per source file. @@ -81,11 +78,11 @@ class OriginFilter(logging.Filter): code and not at this package. """ - def __init__(self, tag: str | None = None) -> None: + def __init__(self, tag=None): super().__init__() self.tag = tag - def filter(self, record: logging.LogRecord) -> bool: + def filter(self, record): module = _dotted_module(record.pathname, record.module) origin = f"{module}.{record.funcName}:{record.lineno}" record.origin = f"{self.tag}|{origin}" if self.tag else origin @@ -107,13 +104,11 @@ class ColorFormatter(logging.Formatter): escape codes. """ - def __init__( - self, fmt: str | None = None, datefmt: str | None = None, *, use_color: bool = True - ) -> None: + def __init__(self, fmt=None, datefmt=None, use_color=True): super().__init__(fmt, datefmt) self.use_color = use_color - def format(self, record: logging.LogRecord) -> str: + def format(self, record): color = LEVEL_COLORS.get(record.levelno, "") if self.use_color else "" if not color: return super().format(record) @@ -121,20 +116,18 @@ def format(self, record: logging.LogRecord) -> str: clone.levelname = f"{color}{record.levelname}{Color.RESET}" return super().format(clone) - def formatTime( # noqa: N802 - name fixed by logging.Formatter - self, record: logging.LogRecord, datefmt: str | None = None - ) -> str: + def formatTime(self, record, datefmt=None): # noqa: N802 - name fixed by logging stamp = super().formatTime(record, datefmt) color = LEVEL_COLORS.get(record.levelno, "") if self.use_color else "" return f"{color}{stamp}{Color.RESET}" if color else stamp -def _enable_windows_ansi() -> bool: +def _enable_windows_ansi(): """Turn on virtual terminal processing so escape codes render, not print.""" try: import ctypes - kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + kernel32 = ctypes.windll.kernel32 # -11 is STD_OUTPUT_HANDLE, -12 STD_ERROR_HANDLE; 0x4 enables VT. for stream_id in (-11, -12): handle = kernel32.GetStdHandle(stream_id) @@ -147,7 +140,7 @@ def _enable_windows_ansi() -> bool: return False -def should_use_color(mode: str, stream: IO[str] | None = None) -> bool: +def should_use_color(mode, stream=None): """ ``always`` / ``never`` are taken at face value; ``auto`` asks the stream. diff --git a/src/loggerhelper/_internal/handler.py b/src/loggerhelper/_internal/handler.py index 1512f9f..b844e6b 100644 --- a/src/loggerhelper/_internal/handler.py +++ b/src/loggerhelper/_internal/handler.py @@ -7,27 +7,24 @@ forever. """ -from __future__ import annotations - import logging import logging.handlers import re import shutil from datetime import date, timedelta from pathlib import Path -from typing import IO -from .config import LoggerConfig, level_number +from .config import level_number from .format import BASE_FORMAT, DATE_FORMAT, ColorFormatter, PlainFormatter, should_use_color _DATE_DIR = re.compile(r"^\d{4}-\d{2}-\d{2}$") -def _today() -> str: +def _today(): return date.today().isoformat() -def prune_old_logs(log_dir: str, retention_days: int) -> int: +def prune_old_logs(log_dir, retention_days): """ Delete dated directories older than the retention window. Returns the count. @@ -67,14 +64,13 @@ class DailyDirRotatingFileHandler(logging.handlers.RotatingFileHandler): def __init__( self, - log_dir: str, - level_name: str, - *, - max_bytes: int = 0, - backup_count: int = 0, - retention_days: int = 0, - encoding: str = "utf-8", - ) -> None: + log_dir, + level_name, + max_bytes=0, + backup_count=0, + retention_days=0, + encoding="utf-8", + ): self.log_dir = Path(log_dir) self.level_name = level_name self.retention_days = retention_days @@ -87,22 +83,22 @@ def __init__( delay=True, ) - def _path_for(self, day: str) -> Path: + def _path_for(self, day): return self.log_dir / day / f"{self.level_name}.log" - def _open(self) -> IO[str]: + def _open(self): # The directory may not exist yet, or may have been cleaned up under us. Path(self.baseFilename).parent.mkdir(parents=True, exist_ok=True) return super()._open() - def shouldRollover(self, record: logging.LogRecord) -> int: # noqa: N802 - logging API + def shouldRollover(self, record): # noqa: N802 - logging API if _today() != self._current_day: # Checked before delegating: the parent opens the stream to measure # it, and it would open yesterday's file to do so. return 1 return super().shouldRollover(record) - def doRollover(self) -> None: # noqa: N802 - logging API + def doRollover(self): # noqa: N802 - logging API today = _today() if today == self._current_day: super().doRollover() @@ -120,7 +116,7 @@ def doRollover(self) -> None: # noqa: N802 - logging API self.stream = self._open() -def get_file_handler(level_name: str, config: LoggerConfig) -> logging.Handler: +def get_file_handler(level_name, config): """One handler per level, each taking only records of exactly that level.""" handler = DailyDirRotatingFileHandler( config.log_dir, @@ -136,7 +132,7 @@ def get_file_handler(level_name: str, config: LoggerConfig) -> logging.Handler: return handler -def get_console_handler(config: LoggerConfig, stream: IO[str] | None = None) -> logging.Handler: +def get_console_handler(config, stream=None): handler = logging.StreamHandler(stream) handler.setLevel(level_number(config.level)) handler.setFormatter( diff --git a/src/loggerhelper/_internal/notifiers/slack.py b/src/loggerhelper/_internal/notifiers/slack.py index 2c5fa73..e57341c 100644 --- a/src/loggerhelper/_internal/notifiers/slack.py +++ b/src/loggerhelper/_internal/notifiers/slack.py @@ -10,8 +10,6 @@ application's problem to handle. """ -from __future__ import annotations - import atexit import json import logging @@ -21,11 +19,9 @@ import threading import time import traceback -import urllib.error import urllib.request -from typing import Any -from ..config import LoggerConfig, level_number +from ..config import level_number #: Slack renders a wall of text badly and rejects very large payloads. MAX_TEXT_CHARS = 2800 @@ -41,7 +37,7 @@ } -def _truncate(text: str, limit: int = MAX_TEXT_CHARS) -> str: +def _truncate(text, limit=MAX_TEXT_CHARS): if len(text) <= limit: return text return text[:limit] + f"\n... [{len(text) - limit} more characters]" @@ -52,14 +48,13 @@ class SlackWebhookHandler(logging.Handler): def __init__( self, - webhook_url: str, - *, - environment: str | None = None, - service: str | None = None, - throttle_seconds: float = DEFAULT_THROTTLE_SECONDS, - max_per_minute: int = DEFAULT_MAX_PER_MINUTE, - timeout: float = DEFAULT_TIMEOUT, - ) -> None: + webhook_url, + environment=None, + service=None, + throttle_seconds=DEFAULT_THROTTLE_SECONDS, + max_per_minute=DEFAULT_MAX_PER_MINUTE, + timeout=DEFAULT_TIMEOUT, + ): super().__init__() if not webhook_url.startswith("https://"): raise ValueError("Slack webhook URL must be https://") @@ -71,8 +66,8 @@ def __init__( self.timeout = timeout self._lock = threading.Lock() - self._last_sent: dict[tuple, float] = {} - self._suppressed: dict[tuple, int] = {} + self._last_sent = {} + self._suppressed = {} self._window_start = time.monotonic() self._window_count = 0 self._window_dropped = 0 @@ -81,7 +76,7 @@ def __init__( # ---- throttling ------------------------------------------------- @staticmethod - def _key(record: logging.LogRecord) -> tuple: + def _key(record): """Same level, same place, same exception type -- treat as a repeat.""" return ( record.levelname, @@ -89,7 +84,7 @@ def _key(record: logging.LogRecord) -> tuple: getattr(record, "exc_type", ""), ) - def _admit(self, record: logging.LogRecord, now: float) -> int | None: + def _admit(self, record, now): """ Decide whether to send. Returns how many repeats were folded in, or ``None`` to stay quiet. @@ -117,7 +112,7 @@ def _admit(self, record: logging.LogRecord, now: float) -> int | None: # ---- rendering -------------------------------------------------- - def _payload(self, record: logging.LogRecord, suppressed: int) -> dict: + def _payload(self, record, suppressed): # QueueHandler.prepare already folded the traceback into the message, # so the first line is the summary and the rest is the traceback. text = record.getMessage() @@ -140,7 +135,7 @@ def _payload(self, record: logging.LogRecord, suppressed: int) -> dict: } ) - attachment: dict[str, Any] = { + attachment = { "color": LEVEL_COLORS.get(record.levelname, "#808080"), "fallback": _truncate(title, 300), "title": f"{record.levelname}: {_truncate(title, 300)}", @@ -154,7 +149,7 @@ def _payload(self, record: logging.LogRecord, suppressed: int) -> dict: # ---- delivery --------------------------------------------------- - def _post(self, payload: dict) -> None: + def _post(self, payload): request = urllib.request.Request( self.webhook_url, data=json.dumps(payload).encode("utf-8"), @@ -166,7 +161,7 @@ def _post(self, payload: dict) -> None: with urllib.request.urlopen(request, timeout=self.timeout): # nosec B310 pass - def emit(self, record: logging.LogRecord) -> None: + def emit(self, record): try: suppressed = self._admit(record, time.monotonic()) if suppressed is None: @@ -175,7 +170,7 @@ def emit(self, record: logging.LogRecord) -> None: except Exception: self.handleError(record) - def handleError(self, record: logging.LogRecord) -> None: # noqa: N802 - logging API + def handleError(self, record): # noqa: N802 - logging API """ Report the first failure and then go quiet. @@ -201,18 +196,18 @@ class _DroppingQueueHandler(logging.handlers.QueueHandler): an error storm into unbounded memory growth. """ - def __init__(self, q: queue.Queue) -> None: + def __init__(self, q): super().__init__(q) self.dropped = 0 - def prepare(self, record: logging.LogRecord) -> logging.LogRecord: + def prepare(self, record): # The parent clears exc_info (it may not be picklable), so capture the # exception type first -- the throttle keys on it. if record.exc_info and record.exc_info[0] is not None: record.exc_type = record.exc_info[0].__name__ return super().prepare(record) - def enqueue(self, record: logging.LogRecord) -> None: + def enqueue(self, record): try: self.queue.put_nowait(record) except queue.Full: @@ -228,15 +223,13 @@ class _IdempotentQueueListener(logging.handlers.QueueListener): during interpreter shutdown. """ - def stop(self) -> None: + def stop(self): if getattr(self, "_thread", None) is None: return super().stop() -def create_slack_handler( - config: LoggerConfig, -) -> tuple[logging.Handler, logging.handlers.QueueListener] | None: +def create_slack_handler(config): """ Build the queued Slack handler, or ``None`` when no webhook is configured. diff --git a/src/loggerhelper/logger.py b/src/loggerhelper/logger.py index 6ce8414..490bdcb 100644 --- a/src/loggerhelper/logger.py +++ b/src/loggerhelper/logger.py @@ -7,15 +7,11 @@ usable as a decorator at import time. """ -from __future__ import annotations - import atexit import contextlib import logging import threading import warnings -from collections.abc import Mapping -from typing import Any, Callable, TypeVar from ._internal.config import LEVEL_NAMES, LoggerConfig, level_number, load_repo_dotenv from ._internal.decorators import catch as _catch @@ -23,8 +19,6 @@ from ._internal.handler import get_console_handler, get_file_handler, prune_old_logs from ._internal.notifiers.slack import create_slack_handler -F = TypeVar("F", bound=Callable[..., Any]) - # How many frames sit between a caller and `logging` when it goes through the # public methods here: the method itself, plus `_emit`. _STACK_OFFSET = 2 @@ -40,47 +34,43 @@ class Logger: Instances are keyed by ``name``: two ``Logger()`` objects with the same name share one set of handlers, which is what stops the same line being written twice. Give a distinct ``name`` to a logger that should write somewhere else. + + Accepts every setting LoggerConfig does; see the README for the full list. """ - def __init__( - self, - log_dir: str | None = None, - level: str | None = None, - *, - name: str | None = None, - config: LoggerConfig | None = None, - env: Mapping[str, str] | None = None, - **settings: Any, - ) -> None: + def __init__(self, log_dir=None, level=None, name=None, config=None, env=None, **settings): self._explicit_config = config - self._settings: dict[str, Any] = dict(log_dir=log_dir, level=level, name=name, **settings) + self._settings = dict(log_dir=log_dir, level=level, name=name, **settings) self._env = env self._lock = threading.RLock() - self._logger: logging.Logger | None = None - self._config: LoggerConfig | None = None - self._slack_listener: Any = None + self._logger = None + self._config = None + self._slack_listener = None atexit.register(self.close) # ---- construction ------------------------------------------------ @property - def config(self) -> LoggerConfig: + def config(self): """The resolved settings. Reading this does not build any handlers.""" with self._lock: if self._config is None: - self._config = self._explicit_config or LoggerConfig.from_env( - env=self._env, **self._settings - ) + if self._explicit_config is not None: + # A ready-made config is a base, not the last word: settings + # passed alongside it must still take effect, not vanish. + self._config = self._explicit_config.replace(**self._settings) + else: + self._config = LoggerConfig.from_env(env=self._env, **self._settings) return self._config - def _resolve(self) -> logging.Logger: + def _resolve(self): if self._logger is None: with self._lock: if self._logger is None: self._logger = self._build() return self._logger - def _build(self) -> logging.Logger: + def _build(self): config = self.config logger = logging.getLogger(f"loggerhelper.{config.name}" if config.name else "loggerhelper") logger.setLevel(level_number(config.level)) @@ -110,13 +100,7 @@ def _build(self) -> logging.Logger: return logger - def reconfigure( - self, - *, - config: LoggerConfig | None = None, - env: Mapping[str, str] | None = None, - **settings: Any, - ) -> Logger: + def reconfigure(self, config=None, env=None, **settings): """ Change settings. Values of ``None`` mean "leave as is". @@ -139,7 +123,7 @@ def reconfigure( self.warning("logger reconfigured after use; handlers were rebuilt") return self - def close(self) -> None: + def close(self): """Release files and stop the Slack thread. Logging again rebuilds.""" with self._lock: listener, self._slack_listener = self._slack_listener, None @@ -156,25 +140,25 @@ def close(self) -> None: # ---- logging ----------------------------------------------------- - def _emit(self, level: int, message: Any, *args: Any, **kwargs: Any) -> None: + def _emit(self, level, message, *args, **kwargs): # stacklevel is what makes `origin` point at the caller instead of at # this file; every public method must be exactly one frame above here. kwargs["stacklevel"] = kwargs.pop("stacklevel", 1) + _STACK_OFFSET self._resolve().log(level, message, *args, **kwargs) - def debug(self, message: Any, *args: Any, **kwargs: Any) -> None: + def debug(self, message, *args, **kwargs): """Detail only useful while working on the code.""" self._emit(logging.DEBUG, message, *args, **kwargs) - def info(self, message: Any, *args: Any, **kwargs: Any) -> None: + def info(self, message, *args, **kwargs): """A normal lifecycle event: started, connected, finished.""" self._emit(logging.INFO, message, *args, **kwargs) - def warning(self, message: Any, *args: Any, **kwargs: Any) -> None: + def warning(self, message, *args, **kwargs): """Degraded, but this operation still succeeded -- a retry, a fallback.""" self._emit(logging.WARNING, message, *args, **kwargs) - def error(self, message: Any, *args: Any, **kwargs: Any) -> Any: + def error(self, message, *args, **kwargs): """ This operation failed; the service is still running. @@ -193,21 +177,21 @@ def error(self, message: Any, *args: Any, **kwargs: Any) -> Any: self._emit(logging.ERROR, message, *args, **kwargs) return None - def critical(self, message: Any, *args: Any, **kwargs: Any) -> None: + def critical(self, message, *args, **kwargs): """The process or service cannot continue.""" self._emit(logging.CRITICAL, message, *args, **kwargs) - def exception(self, message: Any, *args: Any, **kwargs: Any) -> None: + def exception(self, message, *args, **kwargs): """``error`` with the active traceback attached. Use inside ``except``.""" kwargs.setdefault("exc_info", True) self._emit(logging.ERROR, message, *args, **kwargs) - def log(self, level: str, message: Any, *args: Any, **kwargs: Any) -> None: + def log(self, level, message, *args, **kwargs): """Log at a level chosen at runtime, e.g. ``log.log("WARNING", msg)``.""" self._emit(level_number(level), message, *args, **kwargs) - def warn(self, message: Any, *args: Any, **kwargs: Any) -> None: - """Deprecated alias for :meth:`warning`.""" + def warn(self, message, *args, **kwargs): + """Deprecated alias for warning().""" warnings.warn( "Logger.warn() is deprecated; use Logger.warning().", DeprecationWarning, @@ -217,19 +201,11 @@ def warn(self, message: Any, *args: Any, **kwargs: Any) -> None: # ---- decorator --------------------------------------------------- - def _emit_failure(self, level: str, message: str, exc: BaseException) -> None: + def _emit_failure(self, level, message, exc): """One record, carrying the traceback, from the decorator.""" self._resolve().log(level_number(level), message, exc_info=exc, stacklevel=3) - def catch( - self, - func: F | None = None, - *, - level: str = "ERROR", - reraise: bool = True, - default: Any = None, - exclude: tuple[type[BaseException], ...] = (), - ) -> Any: + def catch(self, func=None, level="ERROR", reraise=True, default=None, exclude=()): """ Log any exception escaping the decorated function, then re-raise it. @@ -261,20 +237,13 @@ def optional_extras(): ... #: The shared logger. Import it and start writing; settings come from the -#: environment, or from `configure()`. +#: environment, or from configure(). log = Logger() def configure( - log_dir: str | None = None, - level: str | None = None, - *, - name: str | None = None, - config: LoggerConfig | None = None, - env: Mapping[str, str] | None = None, - load_dotenv: bool = False, - **settings: Any, -) -> Logger: + log_dir=None, level=None, name=None, config=None, env=None, load_dotenv=False, **settings +): """ Point the shared logger somewhere. Everything is optional. diff --git a/src/loggerhelper/py.typed b/src/loggerhelper/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/tests/_internal/test_config.py b/tests/_internal/test_config.py index 3908c04..e1773f9 100644 --- a/tests/_internal/test_config.py +++ b/tests/_internal/test_config.py @@ -1,7 +1,5 @@ """Settings resolution: argument, then environment, then default.""" -from __future__ import annotations - import pytest from loggerhelper._internal.config import ( @@ -99,6 +97,28 @@ def test_an_empty_webhook_is_the_same_as_unset(self): assert LoggerConfig(slack_webhook_url=" ").slack_enabled is False +class TestReplace: + def test_a_copy_differs_only_where_asked(self): + base = LoggerConfig(log_dir="/base", level="DEBUG", retention_days=7) + derived = base.replace(name="worker") + + assert derived.name == "worker" + assert (derived.log_dir, derived.level, derived.retention_days) == ("/base", "DEBUG", 7) + assert base.name is None, "the original must not be touched" + + def test_none_means_leave_it_alone(self): + base = LoggerConfig(level="DEBUG") + assert base.replace(level=None).level == "DEBUG" + + def test_an_unknown_setting_is_rejected(self): + with pytest.raises(ConfigError, match="colur"): + LoggerConfig().replace(colur="always") + + def test_the_copy_is_validated_too(self): + with pytest.raises(ConfigError, match="LOUD"): + LoggerConfig().replace(level="LOUD") + + class TestDefaults: def test_the_log_directory_defaults_to_the_project_root(self, tmp_path, monkeypatch): (tmp_path / "pyproject.toml").write_text("", encoding="utf-8") diff --git a/tests/_internal/test_decorators.py b/tests/_internal/test_decorators.py index 837cf35..87d9b60 100644 --- a/tests/_internal/test_decorators.py +++ b/tests/_internal/test_decorators.py @@ -1,7 +1,5 @@ """`@catch`: what it logs, what it re-raises, and what it says failed.""" -from __future__ import annotations - import pytest from loggerhelper._internal.decorators import catch, describe_failure @@ -10,7 +8,7 @@ @pytest.fixture def emitted(): """Collect (level, message, exception) instead of building a logger.""" - calls: list[tuple] = [] + calls = [] calls.append # noqa: B018 - documents the shape below return calls diff --git a/tests/_internal/test_format.py b/tests/_internal/test_format.py index ae67e68..f5a9d60 100644 --- a/tests/_internal/test_format.py +++ b/tests/_internal/test_format.py @@ -1,7 +1,5 @@ """Colour, and the `origin` field that replaced the random identifier.""" -from __future__ import annotations - import io import logging diff --git a/tests/_internal/test_handler.py b/tests/_internal/test_handler.py index 5d654d3..ee30c82 100644 --- a/tests/_internal/test_handler.py +++ b/tests/_internal/test_handler.py @@ -1,7 +1,5 @@ """Files, directories, rollover and retention.""" -from __future__ import annotations - import logging from datetime import date, timedelta diff --git a/tests/_internal/test_slack.py b/tests/_internal/test_slack.py index ec0b01b..78f9886 100644 --- a/tests/_internal/test_slack.py +++ b/tests/_internal/test_slack.py @@ -6,8 +6,6 @@ working is how this suite would otherwise go flaky. """ -from __future__ import annotations - import json import logging @@ -23,7 +21,7 @@ @pytest.fixture def posted(monkeypatch): """Capture every payload that would have been sent.""" - calls: list[dict] = [] + calls = [] class Response: def __enter__(self): diff --git a/tests/conftest.py b/tests/conftest.py index 7a1d5f1..379543a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,6 @@ whose files are still open), so both are torn down after every test. """ -from __future__ import annotations - import logging import pytest @@ -42,7 +40,7 @@ def log_dir(tmp_path): @pytest.fixture def make_logger(log_dir): """Build throwaway loggers, each closed when the test finishes.""" - built: list[Logger] = [] + built = [] def factory(**settings): settings.setdefault("log_dir", str(log_dir)) diff --git a/tests/test_compat.py b/tests/test_compat.py index 49ef497..1f6c646 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -5,8 +5,6 @@ so neither may break in the version that renames them. """ -from __future__ import annotations - from datetime import date import pytest diff --git a/tests/test_logger.py b/tests/test_logger.py index 824e0f1..203aae4 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -1,7 +1,5 @@ """The Logger itself: laziness, no duplicate output, and where `origin` points.""" -from __future__ import annotations - import logging import pytest @@ -175,6 +173,16 @@ def test_explicit_argument_beats_environment(self, tmp_path): logger = Logger(log_dir=str(chosen), env={"LOG_DIR": str(tmp_path / "ignored")}) assert logger.config.log_dir == str(chosen) + def test_a_ready_made_config_can_still_be_adjusted(self, tmp_path): + """Settings passed alongside a config must take effect, not be dropped.""" + from loggerhelper import LoggerConfig + + shared = LoggerConfig(log_dir=str(tmp_path / "shared"), level="DEBUG") + logger = Logger(config=shared, name="worker") + + assert logger.config.name == "worker" + assert logger.config.level == "DEBUG" + def test_a_none_argument_falls_through_to_the_environment(self, tmp_path): """So callers can forward optional arguments without branching.""" wanted = tmp_path / "from-env" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index a0c27ca..d7442dd 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1,7 +1,5 @@ """What `from loggerhelper import ...` promises.""" -from __future__ import annotations - import loggerhelper From 2f3bea301f7d5a0c384be71a4326da699eaa2efe Mon Sep 17 00:00:00 2001 From: Marcus Date: Tue, 11 Aug 2026 00:28:51 +0800 Subject: [PATCH 3/3] feat(slack): support a bot token, and document where credentials come from The webhook URL was the only way in, which assumed the app had Incoming Webhooks enabled. An app set up any other way has a bot token instead -- and neither credential appears on the Basic Information page, where you would first look. - SLACK_BOT_TOKEN + SLACK_CHANNEL post to chat.postMessage, as an alternative to SLACK_WEBHOOK_URL. Setting both is refused: they name different destinations and picking one silently would be worse than saying so. - chat.postMessage answers 200 with ok:false for a bad channel or a missing scope, so the response body is checked. A webhook signals failure with the status code, which urlopen already raises on. - Config errors point at the page the value is actually on, and repr() masks both credentials so a config is safe to log. - SlackWebhookHandler becomes SlackHandler, since it is no longer webhook-only. README: - A Deployment section covering `uv build`, the `python -m build` alternative, the clean-environment import check, and consuming the wheel by path. Both build commands verified. - Step-by-step for obtaining each Slack credential, including the /invite that is easy to miss. - A section on loading the environment from a .env file, since `export` does not survive a new shell. - pyproject takes the version from __init__.py rather than repeating it, so the README does not have to ask for a two-place bump. Co-Authored-By: Claude Opus 5 --- README.md | 179 ++++++++++++++++-- pyproject.toml | 7 +- src/loggerhelper/_internal/config.py | 48 ++++- src/loggerhelper/_internal/notifiers/slack.py | 67 +++++-- tests/_internal/test_config.py | 29 +++ tests/_internal/test_slack.py | 154 ++++++++++++--- uv.lock | 1 - 7 files changed, 427 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 55e0049..354f013 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ so every signature and accepted value is written out below. - [Full API](#full-api) - [Upgrading from 1.x](#upgrading-from-1x) - [Development](#development) +- [Deployment](#deployment) --- @@ -252,7 +253,9 @@ Everything is optional. | `LOG_RETENTION_DAYS` | `30` | Dated directories older than this are deleted; `0` keeps everything | | `LOG_COLOR` | `auto` | `auto` / `always` / `never` | | `NO_COLOR` | unset | Any value disables colour (the cross-tool convention) | -| `SLACK_WEBHOOK_URL` | unset | Enables Slack when set. Must be `https://` | +| `SLACK_WEBHOOK_URL` | unset | Incoming webhook. Enables Slack. Must be `https://` | +| `SLACK_BOT_TOKEN` | unset | `xoxb-…` token. Enables Slack. The alternative to a webhook | +| `SLACK_CHANNEL` | unset | Which channel the bot token posts to. Required with it | | `SLACK_LOG_LEVEL` | `WARNING` | Threshold for alerts | | `SLACK_ENV` | unset | Deployment tag ("prod", "staging") shown on alerts | @@ -263,29 +266,110 @@ logs into your project, not into `site-packages`. Colour is on when the stream is a terminal and off otherwise, so redirected output and CI logs stay free of escape codes. +### Loading them from a `.env` file + +`export` on every shell is tedious and does not survive a new terminal, a +service restart or a colleague's machine. Put the values in a `.env` file next +to your `pyproject.toml` instead: + +```dotenv +# .env -- keep this out of version control +LOG_DIR=./logs +LOG_LEVEL=INFO +LOG_RETENTION_DAYS=14 + +SLACK_BOT_TOKEN=xoxb-YOUR-BOT-TOKEN +SLACK_CHANNEL=#alerts +SLACK_ENV=prod +``` + +and ask for it once, at start-up: + +```python +from loggerhelper import configure + +configure(load_dotenv=True) +``` + +The nearest `.env` walking up from the working directory is applied. Values +already in the real environment **win** — so a container or CI that sets +`SLACK_ENV` properly is not overridden by a stale file. Comments, blank lines, +`export ` prefixes and quoted values are all understood. + +It is off unless you ask, because a library that edits `os.environ` while being +imported surprises the application that imported it. If your project already +loads a `.env` some other way, keep doing that and leave `load_dotenv` alone. + +**Add `.env` to your `.gitignore`.** It holds a credential. + --- ## Slack alerts -Set a webhook and alerts start. Set nothing and the handler is never attached — -there is no cost and no code path taken. +Set a credential and alerts start. Set nothing and the handler is never +attached — there is no cost and no code path taken. -```bash -export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." -export SLACK_LOG_LEVEL="WARNING" # optional, this is the default -export SLACK_ENV="prod" # optional tag shown on the alert +### Getting a credential + +Slack has two ways in, and **neither appears on the Basic Information → +App Credentials page.** The Client Secret, Signing Secret and Verification Token +shown there are for the OAuth flow and for verifying requests Slack sends *to* +you; none of them can post a message. Pick whichever of these fits your app: + +**A. Incoming webhook** — fewest steps, one URL per channel. + +1. In your app, sidebar → **Features → Incoming Webhooks** +2. Turn **Activate Incoming Webhooks** on +3. **Add New Webhook to Workspace**, pick the channel, **Allow** +4. Copy the URL: `https://hooks.slack.com/services/T…/B…/…` + +```dotenv +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/xxxxxxxx +``` + +**B. Bot token** — one credential for any number of channels. + +1. Sidebar → **Features → OAuth & Permissions** +2. Under **Scopes → Bot Token Scopes**, add `chat:write` +3. **Install to Workspace** (top of the same page), **Allow** +4. Copy the **Bot User OAuth Token**: `xoxb-…` +5. Invite the bot into the channel: `/invite @your-app-name` + +```dotenv +SLACK_BOT_TOKEN=xoxb-YOUR-BOT-TOKEN +SLACK_CHANNEL=#alerts +``` + +Step 5 is easy to forget — without it Slack answers `not_in_channel` and the +alert is reported to stderr rather than delivered. + +Set one or the other. Setting both is refused, because they mean two different +destinations and silently picking one would be worse than saying so. + +### Configuring it + +Anywhere the [environment variables](#environment-variables) can come from — +including a [`.env` file](#loading-them-from-a-env-file), which is the usual +answer: + +```dotenv +SLACK_BOT_TOKEN=xoxb-... +SLACK_CHANNEL=#alerts +SLACK_LOG_LEVEL=WARNING # optional, this is the default +SLACK_ENV=prod # optional tag shown on the alert ``` Or in code: ```python -configure(slack_webhook_url="https://hooks.slack.com/services/...", slack_level="ERROR") +configure(slack_bot_token="xoxb-...", slack_channel="#alerts", slack_level="ERROR") +configure(slack_webhook_url="https://hooks.slack.com/services/...") ``` An alert carries the level, the message, the origin, the deployment tag and the traceback, coloured by severity. -What it does for you: +### What it does for you - **Never blocks.** Sending happens on a background thread behind a bounded queue, so a slow or dead webhook cannot slow the application down. If the @@ -296,8 +380,12 @@ What it does for you: minute. - **Never raises.** A failing webhook is reported to stderr once and then ignored. -- **`https://` only.** An `http://` webhook is rejected rather than putting the - secret on the wire in clear text. +- **Notices silent rejections.** `chat.postMessage` answers `200 OK` with + `{"ok": false}` for a bad channel or a missing scope; that is treated as the + failure it is, not as a delivered message. +- **`https://` only,** and credentials never appear in a `repr()` — an + `http://` webhook is rejected rather than putting the secret on the wire in + clear text. --- @@ -383,7 +471,16 @@ All message methods accept `*args` for lazy `%` formatting, and `exc_info=`, Applies settings to the shared `log` and returns it. -### `LoggerConfig(log_dir=None, level="INFO", name=None, max_bytes=52428800, backup_count=5, retention_days=30, color="auto", slack_webhook_url=None, slack_level="WARNING", slack_env=None)` +### `LoggerConfig(...)` + +```python +LoggerConfig( + log_dir=None, level="INFO", name=None, + max_bytes=52428800, backup_count=5, retention_days=30, color="auto", + slack_webhook_url=None, slack_bot_token=None, slack_channel=None, + slack_level="WARNING", slack_env=None, +) +``` A validated settings object. Build one directly if you want to share the same settings between loggers: @@ -403,9 +500,12 @@ act as the base for several loggers that differ in a detail. | ------------------------------- | ------------------------------------------------------- | | `LoggerConfig.from_env(env=None, **overrides)` | Build from the environment, overrides winning | | `config.replace(**changes)` | A copy with some settings changed; `None` means "leave it" | -| `config.slack_enabled` | Whether a webhook was configured | +| `config.slack_enabled` | Whether a Slack credential was configured | | `LoggerConfig.SETTINGS` | The names of every accepted setting | +`repr()` masks the Slack credentials, so a config is safe to log or to include +in a traceback. + ### `ConfigError` Raised for a setting that cannot be used — an unknown level, a non-numeric size, @@ -454,6 +554,59 @@ uv run pytest to read. Everything under `src/loggerhelper/_internal/` is detail and may change between releases. +--- + +## Deployment + +Build the distributions into `dist/`: + +```bash +uv build +``` + +That writes both `dist/loggerhelper--py3-none-any.whl` and +`dist/loggerhelper-.tar.gz`. + +> **Note — building without uv** +> +> The build backend is `hatchling`, so any PEP 517 front end works: +> +> ```bash +> python -m build +> ``` +> +> `python -m build` needs `pip install build` first. `uv build` needs nothing +> installed and is the same output, so prefer it unless you have a reason not to. +> +> Clear `dist/` between builds — both commands add to it rather than replacing +> it, so old versions linger and get uploaded by accident. + +Check the wheel actually imports before releasing it, from an environment that +has nothing else in it: + +```bash +uv run --no-project --isolated --with dist/loggerhelper-2.0.0-py3-none-any.whl python -c "import loggerhelper; print(loggerhelper.__version__)" +``` + +Publish: + +```bash +uv publish +``` + +To consume it from another project without a package index, point at the wheel +directly — this is what `Database_connector_mcp` does: + +```toml +[tool.uv.sources] +loggerhelper = { path = "lib/loggerhelper-2.0.0-py3-none-any.whl" } +``` + +Before tagging a release, bump `__version__` in `src/loggerhelper/__init__.py`. +That is the only place it is written — `pyproject.toml` declares the version +`dynamic` and reads it from there, so the built artefact and +`loggerhelper.__version__` cannot disagree. + ## License MIT diff --git a/pyproject.toml b/pyproject.toml index 047c42f..10196f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,8 @@ [project] name = "loggerhelper" -version = "2.0.0" +# Read from src/loggerhelper/__init__.py, so the version lives in one place and +# `loggerhelper.__version__` cannot drift from what was built. +dynamic = ["version"] description = "Colorful logger with per-level log files, error decorator and optional Slack alerts" authors = [ {name = "Marcus", email = "marcus.lin@stockfeel.com.tw"} @@ -20,6 +22,9 @@ Homepage = "https://github.com/Marcus/LoggerPackage" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.version] +path = "src/loggerhelper/__init__.py" + [tool.hatch.build.targets.wheel] packages = ["src/loggerhelper"] diff --git a/src/loggerhelper/_internal/config.py b/src/loggerhelper/_internal/config.py index db78a89..e3725cb 100644 --- a/src/loggerhelper/_internal/config.py +++ b/src/loggerhelper/_internal/config.py @@ -86,9 +86,13 @@ class LoggerConfig: ``backup_count`` rotated copies kept ``retention_days`` dated directories older than this are deleted; 0 keeps all ``color`` "auto", "always" or "never" - ``slack_webhook_url`` unset means the Slack handler is never attached + ``slack_webhook_url`` an incoming webhook; the channel is baked into the URL + ``slack_bot_token`` an ``xoxb-`` token, the other way to reach Slack + ``slack_channel`` which channel the bot token posts to; required with it ``slack_level`` threshold for Slack alerts ``slack_env`` deployment tag ("prod") shown on alerts + + Slack stays entirely unconfigured unless one of the two credentials is set. """ #: Every accepted setting, in the order they appear in __init__. @@ -101,6 +105,8 @@ class LoggerConfig: "retention_days", "color", "slack_webhook_url", + "slack_bot_token", + "slack_channel", "slack_level", "slack_env", ) @@ -115,6 +121,8 @@ def __init__( retention_days=DEFAULT_RETENTION_DAYS, color="auto", slack_webhook_url=None, + slack_bot_token=None, + slack_channel=None, slack_level=DEFAULT_SLACK_LEVEL, slack_env=None, ): @@ -138,23 +146,47 @@ def __init__( raise ConfigError(f"{attr}={value!r} must be a whole number >= 0") setattr(self, attr, value) - url = slack_webhook_url - if url is not None: - url = url.strip() or None + url = slack_webhook_url.strip() or None if slack_webhook_url is not None else None if url is not None and not url.startswith("https://"): # The handler posts to this with urllib; anything but https is # either a typo or credentials over the wire in clear text. raise ConfigError(f"slack_webhook_url={slack_webhook_url!r} must be https://") self.slack_webhook_url = url + token = slack_bot_token.strip() or None if slack_bot_token is not None else None + if token is not None and not token.startswith("xox"): + raise ConfigError( + "slack_bot_token does not look like a Slack token (it should start with " + "'xoxb-'). Find it under OAuth & Permissions -> Bot User OAuth Token." + ) + self.slack_bot_token = token + self.slack_channel = slack_channel.strip() or None if slack_channel else None + + if url and token: + raise ConfigError( + "slack_webhook_url and slack_bot_token are two different ways to reach " + "Slack; set one, not both. The webhook already names its channel, the " + "token needs slack_channel." + ) + if token and not self.slack_channel: + raise ConfigError( + "slack_bot_token needs slack_channel (SLACK_CHANNEL) -- a token can post " + "anywhere, so it cannot guess which channel you meant." + ) + def __repr__(self): - shown = ", ".join(f"{name}={getattr(self, name)!r}" for name in self.SETTINGS) + #: Credentials must not end up in a log line or a traceback. + secret = ("slack_webhook_url", "slack_bot_token") + shown = ", ".join( + f"{name}={'***' if name in secret and getattr(self, name) else getattr(self, name)!r}" + for name in self.SETTINGS + ) return f"LoggerConfig({shown})" @property def slack_enabled(self): - """No webhook configured means the Slack plugin stays out of the way.""" - return bool(self.slack_webhook_url) + """No credential configured means the Slack plugin stays out of the way.""" + return bool(self.slack_webhook_url or self.slack_bot_token) def replace(self, **changes): """ @@ -204,6 +236,8 @@ def pick(field_name, env_key, cast=None): pick("retention_days", "LOG_RETENTION_DAYS", _as_int) pick("color", "LOG_COLOR") pick("slack_webhook_url", "SLACK_WEBHOOK_URL") + pick("slack_bot_token", "SLACK_BOT_TOKEN") + pick("slack_channel", "SLACK_CHANNEL") pick("slack_level", "SLACK_LOG_LEVEL") pick("slack_env", "SLACK_ENV") diff --git a/src/loggerhelper/_internal/notifiers/slack.py b/src/loggerhelper/_internal/notifiers/slack.py index e57341c..43824b1 100644 --- a/src/loggerhelper/_internal/notifiers/slack.py +++ b/src/loggerhelper/_internal/notifiers/slack.py @@ -1,7 +1,15 @@ """ -Slack alerts for WARNING and above, when a webhook is configured. +Slack alerts for WARNING and above, when a credential is configured. -Two constraints shape this. Logging must never block the caller, so the POST +Slack offers two ways in and this supports both, because which one you have +depends on how the app was set up: + +* an **incoming webhook** (``https://hooks.slack.com/services/...``), which has + its channel baked in; or +* a **bot token** (``xoxb-...``) posted to ``chat.postMessage``, which needs to + be told the channel. + +Two constraints shape the rest. Logging must never block the caller, so the POST happens on a background thread behind a bounded queue. And an error loop must not flood the channel (or get the app rate-limited by Slack), so repeats of the same failure are collapsed into a count. @@ -23,6 +31,9 @@ from ..config import level_number +#: Where a bot token posts. A webhook carries its own URL. +CHAT_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage" + #: Slack renders a wall of text badly and rejects very large payloads. MAX_TEXT_CHARS = 2800 DEFAULT_THROTTLE_SECONDS = 60.0 @@ -43,12 +54,17 @@ def _truncate(text, limit=MAX_TEXT_CHARS): return text[:limit] + f"\n... [{len(text) - limit} more characters]" -class SlackWebhookHandler(logging.Handler): - """POST records to a Slack incoming webhook. Runs on the listener thread.""" +class SlackHandler(logging.Handler): + """ + POST records to Slack, by webhook or by bot token. Runs on the listener + thread, so everything here is allowed to be slow. + """ def __init__( self, - webhook_url, + webhook_url=None, + bot_token=None, + channel=None, environment=None, service=None, throttle_seconds=DEFAULT_THROTTLE_SECONDS, @@ -56,9 +72,17 @@ def __init__( timeout=DEFAULT_TIMEOUT, ): super().__init__() - if not webhook_url.startswith("https://"): + if webhook_url and bot_token: + raise ValueError("give a webhook URL or a bot token, not both") + if not webhook_url and not bot_token: + raise ValueError("a webhook URL or a bot token is required") + if webhook_url and not webhook_url.startswith("https://"): raise ValueError("Slack webhook URL must be https://") + if bot_token and not channel: + raise ValueError("a bot token needs a channel to post to") self.webhook_url = webhook_url + self.bot_token = bot_token + self.channel = channel self.environment = environment self.service = service self.throttle_seconds = throttle_seconds @@ -150,16 +174,31 @@ def _payload(self, record, suppressed): # ---- delivery --------------------------------------------------- def _post(self, payload): + headers = {"Content-Type": "application/json; charset=utf-8"} + if self.bot_token: + url = CHAT_POST_MESSAGE_URL + headers["Authorization"] = f"Bearer {self.bot_token}" + payload = dict(payload, channel=self.channel) + else: + url = self.webhook_url + request = urllib.request.Request( - self.webhook_url, + url, data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, + headers=headers, method="POST", ) # The scheme is pinned to https in __init__ and again in LoggerConfig, # so this cannot be pointed at file:// or ftp://. - with urllib.request.urlopen(request, timeout=self.timeout): # nosec B310 - pass + with urllib.request.urlopen(request, timeout=self.timeout) as response: # nosec B310 + if not self.bot_token: + return + # A webhook signals failure with a non-2xx status, which urlopen + # raises on. chat.postMessage answers 200 with ok:false instead, so + # a bad channel or a missing scope would otherwise look like success. + body = json.loads(response.read().decode("utf-8") or "{}") + if not body.get("ok"): + raise RuntimeError(f"Slack rejected the message: {body.get('error')!r}") def emit(self, record): try: @@ -231,7 +270,7 @@ def stop(self): def create_slack_handler(config): """ - Build the queued Slack handler, or ``None`` when no webhook is configured. + Build the queued Slack handler, or ``None`` when no credential is configured. Returning ``None`` rather than a no-op handler keeps the "not configured" case entirely free of cost. @@ -239,8 +278,10 @@ def create_slack_handler(config): if not config.slack_enabled: return None - target = SlackWebhookHandler( - str(config.slack_webhook_url), + target = SlackHandler( + webhook_url=config.slack_webhook_url, + bot_token=config.slack_bot_token, + channel=config.slack_channel, environment=config.slack_env, service=config.name, ) diff --git a/tests/_internal/test_config.py b/tests/_internal/test_config.py index e1773f9..4418ca1 100644 --- a/tests/_internal/test_config.py +++ b/tests/_internal/test_config.py @@ -96,6 +96,35 @@ def test_plain_http_is_refused(self): def test_an_empty_webhook_is_the_same_as_unset(self): assert LoggerConfig(slack_webhook_url=" ").slack_enabled is False + def test_a_bot_token_with_a_channel_enables_it(self): + config = LoggerConfig.from_env( + env={"SLACK_BOT_TOKEN": "xoxb-1-2-abc", "SLACK_CHANNEL": "#alerts"} + ) + assert config.slack_enabled is True + assert config.slack_channel == "#alerts" + + def test_a_bot_token_without_a_channel_says_so(self): + with pytest.raises(ConfigError, match="SLACK_CHANNEL"): + LoggerConfig(slack_bot_token="xoxb-1-2-abc") + + def test_both_credentials_at_once_is_rejected(self): + with pytest.raises(ConfigError, match="one, not both"): + LoggerConfig( + slack_webhook_url="https://hooks.slack.com/x", + slack_bot_token="xoxb-1-2-abc", + slack_channel="#alerts", + ) + + def test_something_that_is_not_a_token_points_at_the_right_page(self): + """The App Credentials page shows a Client Secret, which is not this.""" + with pytest.raises(ConfigError, match="OAuth & Permissions"): + LoggerConfig(slack_bot_token="8f14e45fceea167a", slack_channel="#alerts") + + def test_credentials_are_not_shown_in_the_repr(self): + config = LoggerConfig(slack_webhook_url="https://hooks.slack.com/services/T/B/secret") + assert "secret" not in repr(config) + assert "slack_webhook_url='***'" in repr(config) or "slack_webhook_url=***" in repr(config) + class TestReplace: def test_a_copy_differs_only_where_asked(self): diff --git a/tests/_internal/test_slack.py b/tests/_internal/test_slack.py index 78f9886..5089ccd 100644 --- a/tests/_internal/test_slack.py +++ b/tests/_internal/test_slack.py @@ -13,31 +13,61 @@ from loggerhelper._internal.config import LoggerConfig from loggerhelper._internal.notifiers import slack as slack_module -from loggerhelper._internal.notifiers.slack import SlackWebhookHandler, create_slack_handler +from loggerhelper._internal.notifiers.slack import SlackHandler, create_slack_handler WEBHOOK = "https://hooks.slack.com/services/T000/B000/xxx" +BOT_TOKEN = "xoxb-0000-1111-abcdef" # nosec B105 - not a real credential -@pytest.fixture -def posted(monkeypatch): - """Capture every payload that would have been sent.""" - calls = [] +class _Response: + """Enough of an HTTP response for the handler to read.""" + + def __init__(self, body=b'{"ok": true}'): + self._body = body + + def read(self): + return self._body - class Response: - def __enter__(self): - return self + def __enter__(self): + return self - def __exit__(self, *exc): - return False + def __exit__(self, *exc): + return False + + +@pytest.fixture +def sent(monkeypatch): + """Capture every request that would have gone out, without a network.""" + calls = [] def fake_urlopen(request, timeout=None): - calls.append(json.loads(request.data.decode("utf-8"))) - return Response() + calls.append(request) + return _Response() monkeypatch.setattr(slack_module.urllib.request, "urlopen", fake_urlopen) return calls +@pytest.fixture +def posted(sent): + """The captured request bodies, which is what most tests care about.""" + + class Bodies: + def __len__(self): + return len(sent) + + def __getitem__(self, index): + request = sent[index] + if isinstance(index, slice): + return [json.loads(r.data.decode("utf-8")) for r in request] + return json.loads(request.data.decode("utf-8")) + + def __iter__(self): + return (json.loads(r.data.decode("utf-8")) for r in sent) + + return Bodies() + + def make_record(level=logging.ERROR, message="it failed", origin="service.pool.acquire:87"): record = logging.LogRecord("test", level, __file__, 1, message, None, None) record.origin = origin @@ -71,7 +101,7 @@ def test_the_threshold_follows_the_configured_level(self, tmp_path): class TestPayload: def test_the_level_and_message_are_in_the_attachment(self, posted): - handler = SlackWebhookHandler(WEBHOOK, environment="prod", service="etl") + handler = SlackHandler(WEBHOOK, environment="prod", service="etl") handler.emit(make_record()) attachment = posted[0]["attachments"][0] @@ -80,35 +110,113 @@ def test_the_level_and_message_are_in_the_attachment(self, posted): assert {"title": "Environment", "value": "prod", "short": True} in attachment["fields"] def test_the_origin_travels_with_the_alert(self, posted): - handler = SlackWebhookHandler(WEBHOOK) + handler = SlackHandler(WEBHOOK) handler.emit(make_record()) values = [f["value"] for f in posted[0]["attachments"][0]["fields"]] assert "`service.pool.acquire:87`" in values def test_a_long_traceback_is_truncated(self, posted): - handler = SlackWebhookHandler(WEBHOOK) + handler = SlackHandler(WEBHOOK) handler.emit(make_record(message="summary\n" + "x" * 10_000)) text = posted[0]["attachments"][0]["text"] assert "more characters" in text assert len(text) < 4000 def test_each_level_gets_its_own_colour(self, posted): - handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=0) + handler = SlackHandler(WEBHOOK, throttle_seconds=0) handler.emit(make_record(level=logging.WARNING)) handler.emit(make_record(level=logging.CRITICAL)) colors = {p["attachments"][0]["color"] for p in posted} assert len(colors) == 2 +class TestBotToken: + """ + The other way into Slack. An app that never enabled Incoming Webhooks has a + bot token instead, and the App Credentials page shows neither. + """ + + def test_a_token_posts_to_chat_postmessage(self, sent, posted): + handler = SlackHandler(bot_token=BOT_TOKEN, channel="#alerts") + handler.emit(make_record()) + + assert sent[0].full_url == slack_module.CHAT_POST_MESSAGE_URL + assert sent[0].get_header("Authorization") == f"Bearer {BOT_TOKEN}" + assert posted[0]["channel"] == "#alerts" + assert posted[0]["attachments"][0]["title"].startswith("ERROR:") + + def test_a_webhook_does_not_send_a_channel_or_a_token(self, sent, posted): + handler = SlackHandler(WEBHOOK) + handler.emit(make_record()) + + assert sent[0].full_url == WEBHOOK + assert sent[0].get_header("Authorization") is None + assert "channel" not in posted[0] + + def test_an_ok_false_response_is_treated_as_a_failure(self, monkeypatch, capsys): + """chat.postMessage answers 200 with ok:false, unlike a webhook.""" + + def rejecting(request, timeout=None): + return _Response(b'{"ok": false, "error": "channel_not_found"}') + + monkeypatch.setattr(slack_module.urllib.request, "urlopen", rejecting) + handler = SlackHandler(bot_token=BOT_TOKEN, channel="#nope", throttle_seconds=0) + + handler.emit(make_record()) # must not raise + + assert "channel_not_found" in capsys.readouterr().err + + def test_a_token_without_a_channel_is_refused(self): + with pytest.raises(ValueError, match="channel"): + SlackHandler(bot_token=BOT_TOKEN) + + def test_both_credentials_at_once_is_refused(self): + with pytest.raises(ValueError, match="not both"): + SlackHandler(webhook_url=WEBHOOK, bot_token=BOT_TOKEN, channel="#a") + + def test_neither_credential_is_refused(self): + with pytest.raises(ValueError, match="required"): + SlackHandler() + + def test_a_configured_token_attaches_the_handler(self, tmp_path): + config = LoggerConfig( + log_dir=str(tmp_path), slack_bot_token=BOT_TOKEN, slack_channel="#alerts" + ) + handler, listener = create_slack_handler(config) + try: + assert handler.level == logging.WARNING + finally: + listener.stop() + + def test_end_to_end_through_a_logger(self, posted, tmp_path): + from loggerhelper import Logger + + logger = Logger( + log_dir=str(tmp_path), + name="slack-bot", + env={}, + color="never", + slack_bot_token=BOT_TOKEN, + slack_channel="#alerts", + ) + try: + logger.error("the job failed") + finally: + logger.close() + + assert posted[0]["channel"] == "#alerts" + assert "the job failed" in posted[0]["attachments"][0]["title"] + + class TestThrottling: def test_a_repeat_of_the_same_failure_is_held_back(self, posted): - handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=600) + handler = SlackHandler(WEBHOOK, throttle_seconds=600) for _ in range(5): handler.emit(make_record()) assert len(posted) == 1, "an error loop must not flood the channel" def test_a_different_origin_is_not_throttled(self, posted): - handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=600) + handler = SlackHandler(WEBHOOK, throttle_seconds=600) handler.emit(make_record(origin="a.b:1")) handler.emit(make_record(origin="c.d:2")) assert len(posted) == 2 @@ -117,7 +225,7 @@ def test_the_suppressed_count_is_reported_on_the_next_alert(self, posted, monkey clock = [1000.0] monkeypatch.setattr(slack_module.time, "monotonic", lambda: clock[0]) - handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=60) + handler = SlackHandler(WEBHOOK, throttle_seconds=60) handler.emit(make_record()) for _ in range(3): handler.emit(make_record()) @@ -130,7 +238,7 @@ def test_the_suppressed_count_is_reported_on_the_next_alert(self, posted, monkey assert repeats and repeats[0].startswith("3 ") def test_the_per_minute_cap_stops_a_storm(self, posted): - handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=0, max_per_minute=3) + handler = SlackHandler(WEBHOOK, throttle_seconds=0, max_per_minute=3) for index in range(20): handler.emit(make_record(origin=f"mod.fn:{index}")) assert len(posted) == 3 @@ -142,7 +250,7 @@ def explode(request, timeout=None): raise OSError("network unreachable") monkeypatch.setattr(slack_module.urllib.request, "urlopen", explode) - handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=0) + handler = SlackHandler(WEBHOOK, throttle_seconds=0) handler.emit(make_record()) # must not raise @@ -153,7 +261,7 @@ def explode(request, timeout=None): raise OSError("network unreachable") monkeypatch.setattr(slack_module.urllib.request, "urlopen", explode) - handler = SlackWebhookHandler(WEBHOOK, throttle_seconds=0) + handler = SlackHandler(WEBHOOK, throttle_seconds=0) for _ in range(5): handler.emit(make_record(origin=f"mod.fn:{_}")) @@ -162,7 +270,7 @@ def explode(request, timeout=None): def test_plain_http_is_refused(self): with pytest.raises(ValueError, match="https"): - SlackWebhookHandler("http://hooks.slack.com/x") + SlackHandler("http://hooks.slack.com/x") def test_stopping_twice_is_harmless(self, tmp_path): """close() stops the listener and so does atexit; the second must be a no-op.""" diff --git a/uv.lock b/uv.lock index ac76036..ceab599 100644 --- a/uv.lock +++ b/uv.lock @@ -143,7 +143,6 @@ wheels = [ [[package]] name = "loggerhelper" -version = "2.0.0" source = { editable = "." } [package.dev-dependencies]