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..354f013 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,612 @@ # 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 +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) +- [Deployment](#deployment) + +--- + +## Install ```bash -pip list | grep build # check if build is installed -python -m build +pip install loggerhelper ``` -## Requirements +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.) -- Python >= 3.7 +--- -## Installation +## Quick start -pip install loggerhelper +```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: + +```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") +``` + +**Choosing the level at runtime:** + +```python +level = "WARNING" if recovered else "ERROR" +log.log(level, "upstream call finished badly") +``` + +**Attaching the traceback yourself,** or passing extra record fields: + +```python +log.error("upload failed", exc_info=exc) +log.info("request done", extra={"request_id": rid}) +``` + +Each line records where it was called from: + +``` +[2026-08-11 09:14:02]-INFO-(service.pool.acquire:87)=> connection established + ^^^^^^^^^^^^^^^^^^^^^^ module.function:line +``` + +--- + +## 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 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 +``` + +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 +async def fetch(url): + ... +``` + +--- + +## 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 +deciding per function. + +| 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 | + +What 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`. + +```python +@log.catch(level="CRITICAL") +def load_config(): # if this fails there is no service + ... + +@log.catch +def handle(request): # if this fails, this request failed + ... +``` + +--- + +## 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: `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 | 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 | + +`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. + +### 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 credential and alerts start. Set nothing and the handler is never +attached — there is no cost and no code path taken. + +### 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 +``` -## Usage +Or in code: + +```python +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 + +- **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. +- **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. + +--- + +## More than one logger ```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") +audit.info("user 42 exported the customer table") +``` + +`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. -# Or specify custom directory -# logs = Logger(log_dir="./logs", level="INFO") +```python +logger = Logger(log_dir="./logs", level="DEBUG", name="worker") +logger.close() # release the files; logging again rebuilds +``` -logs.info("System started") -logs.warn("Be careful") -logs.critical("Unexpected shutdown") +--- -@logs.error -def test(): - raise Exception("example") +## What ends up on disk -test() ``` +log/ +└── 2026-08-11/ + ├── DEBUG.log + ├── INFO.log + ├── WARNING.log + ├── ERROR.log + └── CRITICAL.log +``` + +- 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(...)` + +```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: + +```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 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, +an `http://` webhook. Subclasses `ValueError`, and the message names the setting +and lists the valid values. + +--- + +## Upgrading from 1.x + +| 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 | + +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 +``` + +`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. + +--- + +## 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 -**Console will show colored output, and log files will be created by level.** \ No newline at end of file +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..10196f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,55 @@ [project] name = "loggerhelper" -version = "1.0.0" -description = "Colorful logger with split log files and extensible architecture" +# 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"} + {name = "Marcus", email = "marcus.lin@stockfeel.com.tw"} ] license = {text = "MIT"} readme = "README.md" -requires-python = ">=3.7" -dependencies = [ +# 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 = [] + +[project.urls] +Homepage = "https://github.com/Marcus/LoggerPackage" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/loggerhelper/__init__.py" + +[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..e3725cb --- /dev/null +++ b/src/loggerhelper/_internal/config.py @@ -0,0 +1,295 @@ +""" +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. +""" + +import os +from pathlib import Path + +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, 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) + 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): + """ + 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=None): + """ + 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, source): + try: + return int(raw.strip()) + except ValueError: + raise ConfigError(f"{source}={raw!r} must be a whole number") from None + + +class LoggerConfig: + """ + 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`` 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__. + SETTINGS = ( + "log_dir", + "level", + "name", + "max_bytes", + "backup_count", + "retention_days", + "color", + "slack_webhook_url", + "slack_bot_token", + "slack_channel", + "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_bot_token=None, + slack_channel=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 = 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): + #: 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 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): + """ + 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=None, **overrides): + """ + 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 = {} + remaining = dict(overrides) + + def pick(field_name, env_key, cast=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_bot_token", "SLACK_BOT_TOKEN") + pick("slack_channel", "SLACK_CHANNEL") + pick("slack_level", "SLACK_LOG_LEVEL") + pick("slack_env", "SLACK_ENV") + + if remaining: + unknown = ", ".join(sorted(remaining)) + raise ConfigError( + f"unknown setting(s): {unknown}. Known settings: {', '.join(cls.SETTINGS)}" + ) + + return cls(**values) + + +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 = {} + 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=".env", start=None, override=False): + """ + 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..8dbc19c --- /dev/null +++ b/src/loggerhelper/_internal/decorators.py @@ -0,0 +1,81 @@ +""" +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. +""" + +import functools +import inspect +import os + + +def describe_failure(func, exc): + """ + ``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, func=None, level="ERROR", reraise=True, default=None, exclude=()): + """ + Wrap ``func`` so an escaping exception is logged through ``emit``. + + ``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): + if inspect.iscoroutinefunction(target): + + @functools.wraps(target) + async def async_wrapper(*args, **kwargs): + 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 + + @functools.wraps(target) + def wrapper(*args, **kwargs): + 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 + + 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..009ae6d --- /dev/null +++ b/src/loggerhelper/_internal/format.py @@ -0,0 +1,163 @@ +""" +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. +""" + +import copy +import logging +import os +import sys +from functools import lru_cache +from pathlib import Path + +#: 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, 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. + """ + 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=None): + super().__init__() + self.tag = tag + + 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 + 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=None, datefmt=None, use_color=True): + super().__init__(fmt, datefmt) + self.use_color = use_color + + def format(self, record): + 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(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(): + """Turn on virtual terminal processing so escape codes render, not print.""" + try: + import ctypes + + 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) + 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, stream=None): + """ + ``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..b844e6b --- /dev/null +++ b/src/loggerhelper/_internal/handler.py @@ -0,0 +1,145 @@ +""" +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. +""" + +import logging +import logging.handlers +import re +import shutil +from datetime import date, timedelta +from pathlib import Path + +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(): + return date.today().isoformat() + + +def prune_old_logs(log_dir, retention_days): + """ + 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, + 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 + 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): + return self.log_dir / day / f"{self.level_name}.log" + + 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): # 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): # 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, config): + """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, stream=None): + 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..43824b1 --- /dev/null +++ b/src/loggerhelper/_internal/notifiers/slack.py @@ -0,0 +1,301 @@ +""" +Slack alerts for WARNING and above, when a credential is configured. + +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. + +Sending nothing is always preferable to raising: a broken webhook is not the +application's problem to handle. +""" + +import atexit +import json +import logging +import logging.handlers +import queue +import sys +import threading +import time +import traceback +import urllib.request + +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 +DEFAULT_MAX_PER_MINUTE = 20 +DEFAULT_TIMEOUT = 5.0 +QUEUE_SIZE = 1000 + +LEVEL_COLORS = { + "WARNING": "#d9a441", + "ERROR": "#d64541", + "CRITICAL": "#8b1a1a", +} + + +def _truncate(text, limit=MAX_TEXT_CHARS): + if len(text) <= limit: + return text + return text[:limit] + f"\n... [{len(text) - limit} more characters]" + + +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=None, + bot_token=None, + channel=None, + environment=None, + service=None, + throttle_seconds=DEFAULT_THROTTLE_SECONDS, + max_per_minute=DEFAULT_MAX_PER_MINUTE, + timeout=DEFAULT_TIMEOUT, + ): + super().__init__() + 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 + self.max_per_minute = max_per_minute + self.timeout = timeout + + self._lock = threading.Lock() + self._last_sent = {} + self._suppressed = {} + self._window_start = time.monotonic() + self._window_count = 0 + self._window_dropped = 0 + self._reported_failure = False + + # ---- throttling ------------------------------------------------- + + @staticmethod + def _key(record): + """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, now): + """ + 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, 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() + 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 = { + "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): + 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( + url, + data=json.dumps(payload).encode("utf-8"), + 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) 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: + 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): # 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): + super().__init__(q) + self.dropped = 0 + + 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): + 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): + if getattr(self, "_thread", None) is None: + return + super().stop() + + +def create_slack_handler(config): + """ + 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. + """ + if not config.slack_enabled: + return None + + 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, + ) + 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..490bdcb --- /dev/null +++ b/src/loggerhelper/logger.py @@ -0,0 +1,261 @@ +""" +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. +""" + +import atexit +import contextlib +import logging +import threading +import warnings + +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 + +# 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. + + Accepts every setting LoggerConfig does; see the README for the full list. + """ + + def __init__(self, log_dir=None, level=None, name=None, config=None, env=None, **settings): + self._explicit_config = config + self._settings = dict(log_dir=log_dir, level=level, name=name, **settings) + self._env = env + self._lock = threading.RLock() + self._logger = None + self._config = None + self._slack_listener = None + atexit.register(self.close) + + # ---- construction ------------------------------------------------ + + @property + def config(self): + """The resolved settings. Reading this does not build any handlers.""" + with self._lock: + if self._config is None: + 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): + if self._logger is None: + with self._lock: + if self._logger is None: + self._logger = self._build() + return self._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)) + # 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=None, env=None, **settings): + """ + 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): + """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, 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, *args, **kwargs): + """Detail only useful while working on the code.""" + self._emit(logging.DEBUG, message, *args, **kwargs) + + def info(self, message, *args, **kwargs): + """A normal lifecycle event: started, connected, finished.""" + self._emit(logging.INFO, message, *args, **kwargs) + + 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, *args, **kwargs): + """ + 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, *args, **kwargs): + """The process or service cannot continue.""" + self._emit(logging.CRITICAL, message, *args, **kwargs) + + 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, 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, *args, **kwargs): + """Deprecated alias for 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, 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=None, level="ERROR", reraise=True, default=None, exclude=()): + """ + 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=None, level=None, name=None, config=None, env=None, load_dotenv=False, **settings +): + """ + 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/tests/_internal/test_config.py b/tests/_internal/test_config.py new file mode 100644 index 0000000..4418ca1 --- /dev/null +++ b/tests/_internal/test_config.py @@ -0,0 +1,192 @@ +"""Settings resolution: argument, then environment, then default.""" + +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 + + 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): + 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") + 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..87d9b60 --- /dev/null +++ b/tests/_internal/test_decorators.py @@ -0,0 +1,186 @@ +"""`@catch`: what it logs, what it re-raises, and what it says failed.""" + +import pytest + +from loggerhelper._internal.decorators import catch, describe_failure + + +@pytest.fixture +def emitted(): + """Collect (level, message, exception) instead of building a logger.""" + calls = [] + 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..f5a9d60 --- /dev/null +++ b/tests/_internal/test_format.py @@ -0,0 +1,110 @@ +"""Colour, and the `origin` field that replaced the random identifier.""" + +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..ee30c82 --- /dev/null +++ b/tests/_internal/test_handler.py @@ -0,0 +1,139 @@ +"""Files, directories, rollover and retention.""" + +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..5089ccd --- /dev/null +++ b/tests/_internal/test_slack.py @@ -0,0 +1,357 @@ +""" +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. +""" + +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 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 + + +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 + + def __enter__(self): + return self + + 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(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 + 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 = SlackHandler(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 = 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 = 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 = 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 = 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 = 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 + + 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 = SlackHandler(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 = 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 + + +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 = SlackHandler(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 = SlackHandler(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"): + 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.""" + 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..379543a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,56 @@ +""" +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. +""" + +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 = [] + + 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..1f6c646 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,55 @@ +""" +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 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..203aae4 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,197 @@ +"""The Logger itself: laziness, no duplicate output, and where `origin` points.""" + +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_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" + 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..d7442dd --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,31 @@ +"""What `from loggerhelper import ...` promises.""" + +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..ceab599 --- /dev/null +++ b/uv.lock @@ -0,0 +1,533 @@ +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" +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" }, +]