From 436c9caec692bc1b4a0c235118f02d78b233b27f Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Tue, 4 Aug 2026 20:14:24 +0200 Subject: [PATCH] feat(core): add v1 logger adapter and output modes --- .github/workflows/ci.yml | 4 +- AGENTS.md | 2 +- README.md | 260 +++++---- docs/migration-v1.md | 110 ++++ docs/performance.md | 27 + examples/async_context.py | 8 +- examples/base.py | 21 +- examples/mp_adv_data_processing.py | 56 +- examples/mp_example.py | 68 +-- examples/serialize.py | 19 +- pyproject.toml | 9 +- scripts/benchmark_logging.py | 72 +++ src/logurich/__init__.py | 30 +- src/logurich/context.py | 178 ++++++ src/logurich/core.py | 863 ++++++++++++++++------------- src/logurich/handler.py | 143 +++-- src/logurich/opt_click.py | 29 +- src/logurich/struct.py | 3 + src/logurich/user_input.py | 14 +- src/logurich/utils.py | 16 - tests/conftest.py | 9 +- tests/test_core.py | 851 +++++++++++++++------------- tests/test_mp.py | 203 ++++++- tests/test_opt_click.py | 70 ++- tests/test_rich.py | 242 ++++++-- tests/test_user_input.py | 12 + uv.lock | 305 +++++----- 27 files changed, 2318 insertions(+), 1306 deletions(-) create mode 100644 docs/migration-v1.md create mode 100644 docs/performance.md create mode 100644 scripts/benchmark_logging.py create mode 100644 src/logurich/context.py delete mode 100644 src/logurich/utils.py create mode 100644 tests/test_user_input.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7d3c69..6a6dd71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v9 with: python-version: ${{ matrix.python-version }} - name: Install deps @@ -41,7 +41,7 @@ jobs: with: fetch-depth: 0 - name: Set up uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v9 with: python-version: "3.10" - name: Build dist diff --git a/AGENTS.md b/AGENTS.md index 3e22704..9181228 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,4 +23,4 @@ Follow PEP 8 with four-space indentation and `snake_case` for functions, module- Write tests with pytest and place them under `tests/`, naming files `test_.py` and functions `test_`. Reuse shared fixtures from `tests/conftest.py`. Ensure new log formatting paths have representative assertions, and extend the example scripts when manual verification is useful. Run `uv run pytest` before opening a PR; aim to cover both the standard and rich rendering paths. ## Commit & Pull Request Guidelines -Commits follow Conventional Commit syntax (`type(scope): summary`) as seen in `git log`. Keep changes scoped and mention relevant modules in the scope. Pull requests must include a short summary, linked issues if applicable, and notes on testing (`uv run pytest`). Attach before/after screenshots or logs when changing console output. CI runs the test matrix across Python 3.10–3.13; wait for green builds before merging. +Commits follow Conventional Commit syntax (`type(scope): summary`) as seen in `git log`. Keep changes scoped and mention relevant modules in the scope. Pull requests must include a short summary, linked issues if applicable, and notes on testing (`uv run pytest`). Attach before/after screenshots or logs when changing console output. CI runs the test matrix across Python 3.9–3.13; wait for green builds before merging. diff --git a/README.md b/README.md index 50cfe8f..5b3ef8b 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,178 @@ # logurich [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![PyPI version](https://img.shields.io/pypi/v/logurich.svg)](https://pypi.org/project/logurich/) +[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE) -A Python library combining standard logging and Rich for beautiful logging. +Logurich combines standard-library logging with live Rich renderables, structured +context, text and JSON output, and an optional multiprocessing queue. ## Installation ```bash pip install logurich -pip install logurich[click] +pip install "logurich[click]" ``` -## Usage +## Quick start ```python from rich.panel import Panel from logurich import get_logger, init_logger -init_logger("INFO", enqueue=False) - +init_logger("INFO", console="plain", file="json", enqueue=False) logger = get_logger(__name__) -logger.info("This is a log message") -logger.info("Hello %s", "world") +logger.info("Processed %s items", 12, batch="b-42") -logger.info( - "Rich renderables", - extra={ - "renderables": ( - Panel("Rich panel output", border_style="green"), - ) - }, +request_logger = logger.bind( + request_id=logger.ctx("req-42", style="cyan") ) +request_logger.info("Request completed", duration_ms=17) -with logger.contextualize(app=logger.ctx("demo", style="yellow")): - logger.info("This log has scoped context") +with logger.contextualize(user_id="alice"): + logger.info("Authenticated") -logger.info( - "Per-call context", - extra={ - "context": { - "session": logger.ctx("sess-42", style="cyan", show_key=True), - } - }, +logger.rich( + "INFO", + Panel("Service ready", border_style="green"), + title="Startup", + prefix=True, + width=80, ) +``` + +Context values display their key (`[batch=b-42]`), whether they are raw or +wrapped in `ctx()`. Use `ctx()` to apply a style, rename the key with `label=`, +or hide it with `show_key=False`. Context precedence is: +```text +contextualize()/global context < bind() < per-call keywords ``` -For full IDE autocompletion of `ctx(...)`, `rich(...)`, `bind(...)`, and `contextualize(...)`, use `get_logger(...)` from logurich instead of `logging.getLogger(...)`. It returns the same logger instance but typed as `LogurichLogger`. `logger.ctx(...)` is shorthand for the existing module-level `ctx(...)` helper, and `logger.contextualize(...)` is a convenience alias for `global_context_configure(...)`. The module-level helpers remain supported if you prefer `global_context_configure(...)` or `extra={"context": {"key": ctx(...)}}`. +Every keyword that is not one of stdlib's four (`exc_info`, `stack_info`, +`stacklevel`, `extra`) is context; rendering options are parameters of +`rich()`. To log a stdlib name as context, bind it: `logger.bind(exc_info=...)`. -`logging.getLogger(...)` still works at runtime — only the typing differs. +`bind()`, `new()`, `unbind()`, and `try_unbind()` always return a new +`LogurichLogger`; the original adapter is never mutated. `None` is a real +context value. Remove bound values with `unbind()` or `try_unbind()`, remove +selected ambient values with `global_context_unset()`, or clear all ambient +values with `clear_context()`. -For short-lived scripts and CLIs, `init_logger()` automatically registers an `atexit` hook, so you do not need to call `shutdown_logger()` just to flush logs at process exit. +## Standard-library compatibility -## Named Loggers +`get_logger(name)` returns an explicit `logging.LoggerAdapter` around +`logging.getLogger(name)`. It preserves positional `%s` formatting, +`exc_info`, `stack_info`, `stacklevel`, custom numeric levels, normal `extra`, +and the usual logger properties and handler methods. -Use the standard library to create named loggers: +Each call builds a new adapter, so `get_logger("app") is get_logger("app")` is +`False` even though both wrap the same stdlib logger. Adapters are cheap and +hold only their own bound context; level, handlers, and propagation live on the +shared stdlib logger. Compare `logger.name` rather than adapter identity. -```python -from logurich import get_logger, init_logger +Third-party loggers remain ordinary stdlib loggers: -init_logger("INFO", enqueue=False) +```python +import logging -logger = get_logger(__name__) -logger.info("Hello from %s", __name__) +logging.getLogger("third.party").info( + "Request completed", + extra={"request_id": "req-42"}, +) ``` -Use `get_logger(...)` from logurich for typed access. `logging.getLogger(...)` still works at runtime. +When Logurich is configured, flat third-party `extra` fields are displayed as +context. Third-party loggers do not receive `.ctx()`, `.rich()`, `.bind()`, or +`.contextualize()` methods. A reusable library should not call `init_logger()`; +the application owns handler configuration and shutdown. -## Using Logurich in Reusable Libraries +## Output modes -If you are writing a Python library that will be imported by another program, the library should not call `init_logger()` on its own. Let the main application own logging configuration, handler setup, and shutdown. - -Inside the library, use standard named loggers: +Console and file formats are independent: ```python -# mylib/service.py -from rich.panel import Panel - -from logurich import ctx, get_logger - -logger = get_logger(__name__) - - -def run_job(job_id: str) -> None: - logger.info( - "Starting job %s", - job_id, - extra={"context": {"job": ctx(job_id, style="cyan", show_key=True)}}, - ) - logger.info( - "Job details", - extra={ - "renderables": ( - Panel(f"Job {job_id} is running", border_style="green"), - ) - }, - ) +init_logger( + "INFO", + console="rich", # auto | rich | plain | json + file="json", # text | json + log_filename="app.log", +) ``` -Then configure Logurich once in the main program: +The defaults are `console="plain"` and `file="text"`. -```python -# main.py -from logurich import init_logger -from mylib.service import run_job +`auto` answers "is a human reading this?", not "how pretty can output be": it +resolves to plain text on a TTY and to JSON otherwise, and it never selects the +Rich handler. Rich rendering changes how output is laid out, so it stays an +explicit opt-in via `console="rich"`. -init_logger("INFO", enqueue=False) +When set, `LOGURICH_OUTPUT` always takes precedence over `console=` and +`--logger-console`; it affects only the console mode. Unset the variable to +honour the Python or CLI argument. An unrecognised value emits a `UserWarning` +and falls back to the configured mode, so a typo in a shared environment cannot +break startup. `LOGURICH_EXTRA_*` values continue to be included in JSON +`record.extra`. -run_job("job-42") -``` +JSON and text-file output render Rich objects without ANSI escape codes. The +JSON schema keeps the public `text` and `record` structure from Logurich 0.9. + +## Rich objects -Guidelines for libraries: +`logger.rich(level, *renderables, title="", prefix=True, end="\n", width=None, +highlight=False)` accepts strings and live Rich objects such as `Panel` and +`Table`. Objects stay live until the destination handler renders them. The same +method works after `bind()` and with direct or queued logging. -- Use `get_logger(__name__)` from logurich inside library modules. -- Do not call `init_logger()` or `shutdown_logger()` from library code. -- Emit normal stdlib log calls such as `logger.info("Value %s", value)`. -- Use `extra={"context": ...}` and `extra={"renderables": ...}` only as optional metadata; they render nicely when the consuming application uses Logurich, and `logger.ctx(...)` / `logger.rich(...)` are also available when Logurich has been imported/configured by the application. -- If the library starts worker processes and the application uses `enqueue=True`, accept the queue from the application and call `configure_child_logging(queue)` inside each worker process. +For multiprocessing, serialisable Rich values reach the listener unchanged. If +a renderable cannot be pickled, Logurich explicitly falls back to a plain, +ANSI-free producer-side rendering; other unpicklable record values produce a +clear logging error. ## Multiprocessing -When `enqueue=True`, Logurich is process-safe only if worker processes send records through the shared logging queue created by the parent process. +Only the parent process owns console and file handlers. Every worker must attach +the shared queue explicitly: ```python import multiprocessing as mp -from logurich import configure_child_logging, get_log_queue, get_logger, init_logger +from logurich import ( + LogLevels, + configure_child_logging, + get_log_levels, + get_log_queue, + get_logger, + init_logger, +) -def worker(log_queue: mp.Queue, worker_id: int) -> None: - configure_child_logging(log_queue) - get_logger(f"worker.{worker_id}").info("worker=%s ready", worker_id) +def worker(log_queue: mp.Queue, log_levels: LogLevels, worker_id: int) -> None: + configure_child_logging(log_queue, levels=log_levels) + get_logger(f"worker.{worker_id}").info("Worker ready", worker=worker_id) def main() -> None: init_logger("INFO", enqueue=True) - log_queue = get_log_queue() - - processes = [ - mp.Process(target=worker, args=(log_queue, index), name=f"worker-{index}") - for index in range(3) - ] - - for process in processes: - process.start() - - for process in processes: - process.join() - + queue = get_log_queue() + process = mp.Process(target=worker, args=(queue, get_log_levels(), 1)) + process.start() + process.join() ``` -Only the process that calls `init_logger(..., enqueue=True)` owns the console and file handlers. Child processes must call `configure_child_logging(queue)` before logging. +`levels` is optional: without it a worker enqueues every record and the parent +applies the configured levels. Passing it lets the worker drop filtered records +before they are built and sent. -Call `shutdown_logger()` explicitly only when you need deterministic teardown before process exit, such as in tests or when reconfiguring logging multiple times in the same interpreter. +Execution-local context follows `ContextVar` rules: asyncio tasks inherit it, +but new threads and processes do not inherit it implicitly. Configure worker +context inside each worker. -## Click CLI helper +## Click integration -Install the optional Click extra to automatically expose logger configuration flags inside your commands: +The optional decorator adds `--logger-level`, `--logger-verbose`, +`--logger-filename`, `--logger-level-by-module`, and `--logger-console`: ```python import click @@ -171,48 +183,26 @@ from logurich.opt_click import click_logger_params @click.command() @click_logger_params -def cli(): - logger = get_logger(__name__) - logger.info("Click integration ready!") +def cli() -> None: + get_logger(__name__).info("Ready") ``` -The `click_logger_params` decorator injects `--logger-level`, `--logger-verbose`, `--logger-filename`, `--logger-level-by-module`, and `--logger-rich` flags and configures Logurich before your command logic runs. The usage example above is also available at `examples/click_cli.py`. +Examples: `my-cli --logger-console rich` or +`my-cli --logger-console auto`. The default is `plain`. -## Idempotent initialisation (`force`) - -By default, calling `init_logger()` a second time is a no-op — the existing configuration is kept and the call returns `None`. Pass `force=True` to tear down the current setup and reconfigure from scratch: - -```python -from logurich import init_logger - -init_logger("INFO", enqueue=False) # first call: configures logging -init_logger("DEBUG", enqueue=False) # no-op, returns None -init_logger("DEBUG", enqueue=False, force=True) # reconfigures logging -``` +## Lifecycle -This is useful when tests or interactive sessions need to reset logging between runs. +Repeated `init_logger()` calls are no-ops unless `force=True` is supplied. +Logurich registers shutdown hooks for short-lived programs; call +`shutdown_logger()` when deterministic teardown is needed in tests or before +reconfiguration. -## User input +Users upgrading from 0.9 should read the [v1 migration guide](docs/migration-v1.md). -The `user_input` module provides Rich-enhanced prompts with type coercion, hidden input, and optional timeouts. It does **not** depend on Click. +## Development -```python -from logurich import get_logger, init_logger, user_input, user_input_with_timeout - -init_logger("INFO", enqueue=False) -logger = get_logger(__name__) - -# Basic string input -name = user_input("Enter your name", type=str) - -# Integer input with a default value -count = user_input("How many items?", type=int, default=5) - -# Hidden input (e.g. passwords) -secret = user_input("Enter secret", type=str, hide_input=True) - -# Input with a timeout (5 seconds, Unix only — falls back to regular input on Windows) -answer = user_input_with_timeout("Quick! Type something", timeout_duration=5) +```bash +uv sync --all-groups +uv run pytest +uv run ruff check . ``` - -A runnable example is available at `examples/user_input_example.py`. diff --git a/docs/migration-v1.md b/docs/migration-v1.md new file mode 100644 index 0000000..ece81e1 --- /dev/null +++ b/docs/migration-v1.md @@ -0,0 +1,110 @@ +# Migrating from Logurich 0.9 to v1 + +Logurich v1 replaces the 0.9 logging API atomically. There are no deprecated +aliases or hidden compatibility paths. + +## Logger creation + +Use Logurich's explicit adapter when you need Logurich methods: + +```diff +-logger = logging.getLogger("app") ++logger = get_logger("app") + logger.rich("INFO", panel) +``` + +`get_logger()` no longer returns the same object as `logging.getLogger()`, and +it is not an instance of `logging.Logger`. It also returns a new adapter on +every call, so `get_logger("app") is get_logger("app")` is `False`; compare +`logger.name` instead. Multiple adapters may wrap the same stdlib logger +without sharing bound context, while level, handlers, and propagation stay on +the shared stdlib logger. `BoundLogger` was removed; all binding operations +return `LogurichLogger`. + +## Context and renderables + +Pass Logurich context directly as keywords, and rendering options to `rich()`: + +```diff + logger.info( + "Login", +- extra={"context": {"user": ctx("alice")}}, ++ user=ctx("alice"), + ) + +-logger.info("Summary", extra={"renderables": (panel, table)}) ++logger.rich("INFO", panel, table, title="Summary") +``` + +The two old Logurich payloads in `extra` raise a migration error on a +`LogurichLogger`, as does a bare `renderables=` keyword. Normal stdlib `extra` +remains supported. On third-party stdlib loggers, all `extra` fields—including +fields literally named `context` or `renderables`—are treated as flat context +and receive no legacy interpretation. + +All context values now display their keys, including values built with `ctx()`, +which hid the key in 0.9. Pass `ctx(value, show_key=False)` to hide a key, or +`label=` to rename it; styling a value no longer changes whether its key is +shown. `None` is now a real value: `bind(key=None)` and +`global_context_set(key=None)` retain the key. Call `unbind("key")` to remove a +bound value, `global_context_unset("key")` to remove an ambient value, or +`clear_context()` to clear all ambient values in the current execution. + +Logurich reserves no call keywords of its own and never will: every keyword that +is not one of stdlib's four (`exc_info`, `stack_info`, `stacklevel`, `extra`) is +context, so `logger.info("job", start=t0, end=t1)` logs both values. Rendering +options—`prefix`, `width`, `end`, `highlight`—are parameters of `rich()`. A +keyword that closely resembles a stdlib one still logs as context but emits a +`UserWarning` naming the likely intended keyword. If you need a stdlib name as a +context key, bind it: `logger.bind(exc_info="...")`. + +## Output configuration + +Console and file modes are now independent, and `rich_handler` was removed: + +```diff +-init_logger("INFO", rich_handler=True) ++init_logger("INFO", console="rich") + +-init_logger("INFO", rich_handler=False) ++init_logger("INFO", console="plain") +``` + +The old `LOGURICH_RICH` and `LOGURICH_SERIALIZE` variables are ignored, and +`init_logger()` emits a `UserWarning` when either is present so a stale variable +in a shared environment cannot break startup. Replace them with +`LOGURICH_OUTPUT=auto|rich|plain|json`, and set the file mode independently in +Python: + +```python +init_logger("INFO", console="json", file="json") +``` + +When set, `LOGURICH_OUTPUT` always takes precedence over both the Python +`console=` argument and the Click `--logger-console` option. Unset the variable +to honour either explicit argument. An unrecognised value emits a `UserWarning` +and falls back to the configured mode rather than raising. It never changes the +file mode. + +`auto` resolves to plain text on a TTY and to JSON otherwise. It never selects +the Rich handler, which stays an explicit `console="rich"` opt-in. + +The Click flag changed from `--logger-rich` to the explicit choice +`--logger-console auto|rich|plain|json`. The old flag is an unknown option. + +## Imports to find and replace + +Search applications for: + +- `logging.getLogger()` followed by `.ctx()`, `.rich()`, `.bind()`, or + `.contextualize()`; +- `extra={"context": ...}` and `extra={"renderables": ...}`; +- `rich_handler`; +- `LOGURICH_RICH` and `LOGURICH_SERIALIZE`; +- imports of `BoundLogger`; +- imports of `logurich.utils` or `parse_bool_env`; +- `--logger-rich`; +- `ctx(...)` calls that relied on the key being hidden by default; +- identity or `isinstance(..., logging.Logger)` checks on `get_logger()`; +- `bind(...=None)` calls that previously relied on `None` being ignored; +- `global_context_set(...=None)` calls that previously removed ambient values. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..680245a --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,27 @@ +# Performance reference + +`scripts/benchmark_logging.py` is a dependency-free smoke benchmark for the v1 +logging paths. Run it from the repository root: + +```bash +uv run python scripts/benchmark_logging.py +``` + +Reference run on Python 3.11.13 (2026-08-01): + +| Scenario | Time per call | +|---|---:| +| filtered DEBUG | 0.56 µs | +| plain INFO | 65.31 µs | +| INFO with two context values | 80.11 µs | +| live Panel and Table | 789.25 µs | +| plain exception | 147.66 µs | +| Rich exception | 4,108.87 µs | +| queued INFO submission | 14.68 µs | + +These figures are diagnostic rather than portable limits: Rich rendering and +thread scheduling depend heavily on the host. Before the final v1 release, a +repeatable run on the same CI runner should stay within 25% of its rolling +baseline for the filtered, plain, context, and queue paths. A larger change must +be explained by a functional or output-quality improvement rather than accepted +without investigation. diff --git a/examples/async_context.py b/examples/async_context.py index b7351a2..9dd523b 100644 --- a/examples/async_context.py +++ b/examples/async_context.py @@ -38,9 +38,7 @@ def background_thread_step() -> None: def background_process_step(log_queue: mp.Queue, request_id: str) -> None: configure_child_logging(log_queue) - with global_context_configure( - request_id=ctx(request_id, show_key=True, style="bold cyan") - ): + with global_context_configure(request_id=ctx(request_id, style="bold cyan")): process_log.info("Process task started") time.sleep(0.03) process_log.info("Process task finished") @@ -66,9 +64,7 @@ async def run_process_step(log_queue: mp.Queue, request_id: str) -> None: async def handle_request(request_id: str, log_queue: mp.Queue) -> None: - with global_context_configure( - request_id=ctx(request_id, show_key=True, style="bold cyan") - ): + with global_context_configure(request_id=ctx(request_id, style="bold cyan")): request_log.info("Request started") await fetch_user_profile() await asyncio.gather( diff --git a/examples/base.py b/examples/base.py index 3594afe..1412bac 100644 --- a/examples/base.py +++ b/examples/base.py @@ -1,7 +1,7 @@ from rich.panel import Panel from rich.table import Table -from logurich import ctx, get_logger, init_logger +from logurich import get_logger, init_logger def create_rich_table() -> Table: @@ -19,14 +19,11 @@ def create_rich_table() -> Table: logger.info("This is a basic log message") logger.info("Hello %s", "world") - logger.info( - "Structured output", - extra={ - "renderables": ( - Panel("Rich panel content", border_style="green"), - create_rich_table(), - ) - }, + logger.rich( + "INFO", + Panel("Rich panel content", border_style="green"), + create_rich_table(), + title="Structured output", ) with logger.contextualize(app=logger.ctx("example", style="yellow")): @@ -34,7 +31,7 @@ def create_rich_table() -> Table: logger.info( "This log has per-call context", - extra={"context": {"session": ctx("sess-42", style="cyan", show_key=True)}}, + session=logger.ctx("sess-42", style="cyan"), ) try: @@ -44,13 +41,13 @@ def create_rich_table() -> Table: # ── bind() ── per-instance context ────────────────────────────── api_logger = logger.bind( - ctx_module=logger.ctx("API", style="magenta"), + module=logger.ctx("API", style="magenta"), ) api_logger.info("Request received") api_logger.info("Processing complete") # Chained bind req_logger = api_logger.bind( - request_id=ctx("req-99", style="cyan", show_key=True), + request_id=logger.ctx("req-99", style="cyan"), ) req_logger.info("Handling request") diff --git a/examples/mp_adv_data_processing.py b/examples/mp_adv_data_processing.py index ddc5ef9..a4e9aca 100644 --- a/examples/mp_adv_data_processing.py +++ b/examples/mp_adv_data_processing.py @@ -11,6 +11,7 @@ from logurich import ( configure_child_logging, ctx, + get_log_levels, get_log_queue, get_logger, global_context_configure, @@ -30,8 +31,8 @@ def generate_data(num_items): ] -def init_worker(log_queue): - configure_child_logging(log_queue) +def init_worker(log_queue, log_levels): + configure_child_logging(log_queue, levels=log_levels) def process_item(item): @@ -49,18 +50,14 @@ def process_item(item): for key, value in item.items(): table.add_row(str(key), str(value)) - logger.info( - "Complex item %s requires special handling", - item["id"], - extra={ - "renderables": ( - Panel( - f"Complex item {item['id']} requires special handling", - border_style="yellow", - ), - table, - ) - }, + logger.rich( + "INFO", + Panel( + f"Complex item {item['id']} requires special handling", + border_style="yellow", + ), + table, + title=f"Complex item {item['id']} requires special handling", ) if random.random() < 0.1: @@ -92,21 +89,17 @@ def worker_entry(item): def main(): init_logger("INFO", log_verbose=2, enqueue=True) log_queue = get_log_queue() + log_levels = get_log_levels() logger = get_logger("processor.main") - with global_context_configure( - group=ctx("DataProcessor", style="green", show_key=True) - ): - logger.info( - "Starting parallel data processing example", - extra={ - "renderables": ( - Panel( - "Starting parallel data processing example", - border_style="blue", - ), - ) - }, + with global_context_configure(group=ctx("DataProcessor", style="green")): + logger.rich( + "INFO", + Panel( + "Starting parallel data processing example", + border_style="blue", + ), + title="Starting parallel data processing example", ) data = generate_data(20) @@ -118,7 +111,7 @@ def main(): for item in data[:5]: table.add_row(str(item["id"]), item["content"], str(item["complexity"])) table.add_row("...", "...", "...") - logger.info("Items to process", extra={"renderables": (table,)}) + logger.rich("INFO", table, title="Items to process") start_time = time.time() # Pool tasks cannot receive the queue directly under spawn, so configure @@ -126,7 +119,7 @@ def main(): with mp.Pool( processes=min(4, mp.cpu_count()), initializer=init_worker, - initargs=(log_queue,), + initargs=(log_queue, log_levels), ) as pool: results = pool.map(worker_entry, data) @@ -158,10 +151,7 @@ def main(): str(result["process_id"]), ) - logger.info( - "Processing summary", - extra={"renderables": (results_table, sample_results)}, - ) + logger.rich("INFO", results_table, sample_results, title="Processing summary") if __name__ == "__main__": diff --git a/examples/mp_example.py b/examples/mp_example.py index c0a4e1a..24c214a 100644 --- a/examples/mp_example.py +++ b/examples/mp_example.py @@ -8,6 +8,7 @@ from logurich import ( configure_child_logging, ctx, + get_log_levels, get_log_queue, get_logger, global_context_configure, @@ -15,11 +16,11 @@ ) -def worker_function(log_queue, worker_id): - configure_child_logging(log_queue) +def worker_function(log_queue, log_levels, worker_id): + configure_child_logging(log_queue, levels=log_levels) logger = get_logger(f"workers.{worker_id}") - with global_context_configure(worker=ctx(f"Worker-{worker_id}", show_key=True)): + with global_context_configure(worker=ctx(f"Worker-{worker_id}")): logger.info("Worker %s starting", worker_id) logger.debug("Worker %s debug message", worker_id) @@ -29,59 +30,47 @@ def worker_function(log_queue, worker_id): table.add_row("Process ID", str(mp.current_process().pid)) table.add_row("Random Value", str(random.randint(1, 100))) - logger.info( - "Worker %s status", - worker_id, - extra={ - "renderables": ( - Panel( - f"Worker {worker_id} is processing data", - border_style="green", - ), - table, - ) - }, + logger.rich( + "INFO", + Panel( + f"Worker {worker_id} is processing data", + border_style="green", + ), + table, + title=f"Worker {worker_id} status", ) for i in range(3): logger.info("Worker %s step %s/3", worker_id, i + 1) time.sleep(random.uniform(0.1, 0.5)) - logger.info( - "Worker %s completed successfully", - worker_id, - extra={ - "renderables": ( - Panel( - f"Worker {worker_id} completed successfully", - border_style="bold green", - ), - ) - }, + logger.rich( + "INFO", + Panel( + f"Worker {worker_id} completed successfully", + border_style="bold green", + ), + title=f"Worker {worker_id} completed successfully", ) def main() -> None: init_logger("INFO", log_verbose=2, enqueue=True) log_queue = get_log_queue() + log_levels = get_log_levels() get_logger("main").info("Multiprocessing example starting") - with global_context_configure( - process=ctx("Main-Process", style="magenta", show_key=True) - ): + with global_context_configure(process=ctx("Main-Process", style="magenta")): processes = [ - mp.Process(target=worker_function, args=(log_queue, i + 1)) + mp.Process(target=worker_function, args=(log_queue, log_levels, i + 1)) for i in range(3) ] - get_logger("main").info( - "Starting worker processes", - extra={ - "renderables": ( - Panel("Starting worker processes", border_style="blue"), - ) - }, + get_logger("main").rich( + "INFO", + Panel("Starting worker processes", border_style="blue"), + title="Starting worker processes", ) for process in processes: @@ -95,10 +84,7 @@ def main() -> None: for index, process in enumerate(processes, start=1): table.add_row(f"Worker {index}", str(process.pid), "Running") - get_logger("main").info( - "Workers started", - extra={"renderables": (table,)}, - ) + get_logger("main").rich("INFO", table, title="Workers started") for index, process in enumerate(processes, start=1): process.join() diff --git a/examples/serialize.py b/examples/serialize.py index 1f5a1b3..277d3ba 100644 --- a/examples/serialize.py +++ b/examples/serialize.py @@ -1,5 +1,3 @@ -import os - from rich.panel import Panel from rich.table import Table @@ -16,11 +14,10 @@ def build_table() -> Table: if __name__ == "__main__": - os.environ["LOGURICH_SERIALIZE"] = "1" - os.environ["LOGURICH_EXTRA_APP"] = "serialize-demo" - init_logger( "INFO", + console="json", + file="json", enqueue=False, log_filename="serialize-demo.log", rotation=None, @@ -30,15 +27,13 @@ def build_table() -> Table: logger.info( "Basic serialized message", - extra={ - "user": "alice", - "action": "test", - "items": [1, 2, 3], - "nested": {"key": "value"}, - }, + user="alice", + action="test", + items=[1, 2, 3], + nested={"key": "value"}, ) - with global_context_configure(request_id=ctx("req-42", show_key=True)): + with global_context_configure(request_id=ctx("req-42")): logger.info("Message with scoped context") logger.rich( diff --git a/pyproject.toml b/pyproject.toml index ca959ae..3fa48b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "logurich" -version = "0.9.3" +version = "1.0.0b1" description = "A Python library combining standard logging and Rich for beautiful logging." authors = [ { name = "PakitoSec", email = "jeromep83@gmail.com" } @@ -19,7 +19,7 @@ keywords = [ "pretty-logging", ] dependencies = [ - "rich==14.2.0" + "rich>=13.7.0" ] classifiers = [ "Development Status :: 4 - Beta", @@ -52,9 +52,12 @@ dev = [ ] [build-system] -requires = ["uv_build>=0.9.20,<0.10.0"] +requires = ["uv_build>=0.12.1,<0.13.0"] build-backend = "uv_build" +[tool.uv] +required-version = ">=0.12.1,<0.13.0" + [tool.ruff] line-length = 88 target-version = "py39" diff --git a/scripts/benchmark_logging.py b/scripts/benchmark_logging.py new file mode 100644 index 0000000..84861cf --- /dev/null +++ b/scripts/benchmark_logging.py @@ -0,0 +1,72 @@ +"""Small dependency-free Logurich performance smoke benchmark.""" + +from __future__ import annotations + +import gc +import logging +import time +from io import StringIO +from typing import Callable + +from rich.panel import Panel +from rich.table import Table + +from logurich import get_logger, init_logger, shutdown_logger +from logurich.console import rich_configure_console + + +def measure(label: str, operation: Callable[[], None], iterations: int) -> None: + gc.collect() + start = time.perf_counter() + for _ in range(iterations): + operation() + elapsed = time.perf_counter() - start + print(f"{label:<30} {elapsed / iterations * 1_000_000:>10.2f} us/call") + + +def main() -> None: + output = StringIO() + rich_configure_console(file=output, width=120) + init_logger("DEBUG", enqueue=False) + logger = get_logger("benchmark") + + logger.setLevel(logging.INFO) + measure("filtered DEBUG", lambda: logger.debug("filtered"), 20_000) + measure("plain INFO", lambda: logger.info("emitted %s", 1), 2_000) + measure( + "INFO with context", + lambda: logger.info("emitted", request_id="req-42", duration_ms=12), + 2_000, + ) + + panel = Panel("ready") + table = Table() + table.add_column("metric") + table.add_row("42") + measure("Panel and Table", lambda: logger.rich("INFO", panel, table), 200) + + def log_exception() -> None: + try: + raise ValueError("benchmark") + except ValueError: + logger.exception("failed") + + measure("plain exception", log_exception, 100) + shutdown_logger() + + output.seek(0) + output.truncate(0) + init_logger("INFO", console="rich", enqueue=False) + measure("Rich exception", log_exception, 100) + shutdown_logger() + + output.seek(0) + output.truncate(0) + init_logger("INFO", enqueue=True) + queued = get_logger("benchmark.queue") + measure("queued INFO submission", lambda: queued.info("queued"), 2_000) + shutdown_logger() + + +if __name__ == "__main__": + main() diff --git a/src/logurich/__init__.py b/src/logurich/__init__.py index 4f2467f..16c0488 100644 --- a/src/logurich/__init__.py +++ b/src/logurich/__init__.py @@ -1,6 +1,6 @@ """Public package exports for logurich.""" -__version__ = "0.9.3" +__version__ = "1.0.0b1" from .console import ( console, @@ -10,18 +10,27 @@ rich_set_console, rich_to_str, ) +from .context import ( + ContextValue, + clear_context, + ctx, + global_context_configure, + global_context_set, + global_context_unset, +) from .core import ( + CONSOLE_MODE_CHOICES, + FILE_MODE_CHOICES, LOG_LEVEL_CHOICES, - BoundLogger, - ContextValue, + ConsoleMode, + FileMode, LogLevel, + LogLevels, LogurichLogger, configure_child_logging, - ctx, + get_log_levels, get_log_queue, get_logger, - global_context_configure, - global_context_set, init_logger, shutdown_logger, ) @@ -30,14 +39,16 @@ __all__ = [ "init_logger", "get_log_queue", + "get_log_levels", "configure_child_logging", "shutdown_logger", "ctx", "ContextValue", - "BoundLogger", "LogurichLogger", "global_context_configure", "global_context_set", + "global_context_unset", + "clear_context", "console", "reset_console_after_fork", "rich_configure_console", @@ -45,7 +56,12 @@ "rich_set_console", "rich_to_str", "LOG_LEVEL_CHOICES", + "CONSOLE_MODE_CHOICES", + "FILE_MODE_CHOICES", "LogLevel", + "LogLevels", + "ConsoleMode", + "FileMode", "get_logger", "timeout", "user_input", diff --git a/src/logurich/context.py b/src/logurich/context.py new file mode 100644 index 0000000..f6dfbe3 --- /dev/null +++ b/src/logurich/context.py @@ -0,0 +1,178 @@ +"""Context primitives used by Logurich loggers and renderers.""" + +from __future__ import annotations + +import contextlib +import contextvars +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from typing import Any, Optional + +from rich.markup import escape +from rich.text import Text + +COLOR_ALIASES = { + "g": "green", + "e": "blue", + "c": "cyan", + "m": "magenta", + "r": "red", + "w": "white", + "y": "yellow", + "b": "bold", + "u": "u", + "bg": " on ", +} + +_context_state: contextvars.ContextVar[Optional[dict[str, ContextValue]]] = ( + contextvars.ContextVar("logurich_context_state", default=None) +) + + +def normalize_style(style: Optional[str]) -> Optional[str]: + """Return the canonical Rich style for a public style value.""" + + if style is None: + return None + style = style.strip() + if not style: + return None + return COLOR_ALIASES.get(style, style) + + +def _wrap_markup(style: Optional[str], text: str) -> str: + normalized = normalize_style(style) + if not normalized: + return text + return f"[{normalized}]{text}[/{normalized}]" + + +@dataclass(frozen=True) +class ContextValue: + """A context value and its optional Rich display metadata.""" + + value: Any + value_style: Optional[str] = None + bracket_style: Optional[str] = None + label: Optional[str] = None + show_key: bool = True + + def _label(self, key: str) -> Optional[str]: + if self.label is not None: + return self.label + if self.show_key: + return key + return None + + def render(self, key: str, *, is_rich_handler: bool) -> str: + """Render this value as Rich markup for a console handler.""" + + label = self._label(key) + value_text = _wrap_markup(self.value_style, escape(str(self.value))) + body = f"{escape(label)}={value_text}" if label else value_text + if is_rich_handler: + return body + if normalize_style(self.bracket_style): + left = _wrap_markup(self.bracket_style, "[") + right = _wrap_markup(self.bracket_style, "]") + else: + left = r"\[" + right = "]" + return f"{left}{body}{right}" + + def render_text(self, key: str) -> str: + """Render this value without ANSI codes or Rich markup.""" + + return Text.from_markup(self.render(key, is_rich_handler=False)).plain + + def render_json(self) -> Any: + """Return the style-free value used in JSON output.""" + + return self.value + + +def ctx( + value: Any, + *, + style: Optional[str] = None, + value_style: Optional[str] = None, + bracket_style: Optional[str] = None, + label: Optional[str] = None, + show_key: Optional[bool] = None, +) -> ContextValue: + """Build a styled context value. + + Keys are displayed by default, exactly as for raw context values. Pass + ``show_key=False`` to hide the key, or ``label=`` to rename it. + """ + + effective_value_style = value_style if value_style is not None else style + return ContextValue( + value=value, + value_style=effective_value_style, + bracket_style=bracket_style, + label=label, + show_key=bool(show_key) if show_key is not None else True, + ) + + +def coerce_context_value(value: Any) -> ContextValue: + """Normalise a raw user value while preserving explicit ``None`` values.""" + + if isinstance(value, ContextValue): + return value + return ContextValue(value=value) + + +def normalize_context(values: Mapping[str, Any]) -> dict[str, ContextValue]: + """Return a detached, string-keyed context mapping.""" + + return {str(key): coerce_context_value(value) for key, value in values.items()} + + +def get_context() -> dict[str, ContextValue]: + """Return a copy of the context local to the current execution.""" + + current = _context_state.get() + return dict(current) if current else {} + + +@contextlib.contextmanager +def global_context_configure(**values: Any) -> Iterator[None]: + """Temporarily extend the context of the current execution. + + ``ContextVar`` propagation follows Python's normal rules: asyncio tasks + inherit the current context, while new threads and processes do not do so + implicitly. + """ + + updated = get_context() + updated.update(normalize_context(values)) + token = _context_state.set(updated) + try: + yield + finally: + _context_state.reset(token) + + +def global_context_set(**values: Any) -> None: + """Update context for subsequent logs in the current execution.""" + + updated = get_context() + updated.update(normalize_context(values)) + _context_state.set(updated) + + +def global_context_unset(*keys: str) -> None: + """Remove context keys from the current execution, ignoring missing keys.""" + + updated = get_context() + for key in keys: + updated.pop(key, None) + _context_state.set(updated) + + +def clear_context() -> None: + """Clear all context in the current execution.""" + + _context_state.set({}) diff --git a/src/logurich/core.py b/src/logurich/core.py index d6220ee..c74f963 100644 --- a/src/logurich/core.py +++ b/src/logurich/core.py @@ -1,257 +1,226 @@ -"""Core logging configuration and helpers for logurich.""" +"""Core logging configuration and the public Logurich logger adapter.""" from __future__ import annotations import atexit import contextlib -import contextvars import copy +import difflib import logging import logging.handlers import multiprocessing as mp import os +import pickle import threading import traceback +import warnings from collections.abc import Mapping from dataclasses import dataclass from datetime import time as datetime_time +from functools import lru_cache from pathlib import Path from typing import Any, Literal, Optional, Union, get_args from rich.console import ConsoleRenderable -from rich.markup import escape from rich.traceback import Traceback +from .console import rich_get_console, rich_to_str +from .context import ( + ContextValue, + clear_context, + ctx, + get_context, + global_context_configure, + normalize_context, +) from .handler import ( CustomHandler, CustomRichHandler, LogurichFileFormatter, LogurichRenderer, + RichLayoutUnavailable, ) from .struct import logger_state -from .utils import parse_bool_env - -_context_state: contextvars.ContextVar[dict[str, ContextValue] | None] = ( - contextvars.ContextVar("logurich_context_state", default=None) -) - -COLOR_ALIASES = { - "g": "green", - "e": "blue", - "c": "cyan", - "m": "magenta", - "r": "red", - "w": "white", - "y": "yellow", - "b": "bold", - "u": "u", - "bg": " on ", -} - - -def _normalize_style(style: Optional[str]) -> Optional[str]: - if style is None: - return None - style = style.strip() - if not style: - return None - return COLOR_ALIASES.get(style, style) +LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +ConsoleMode = Literal["auto", "rich", "plain", "json"] +FileMode = Literal["text", "json"] -def _wrap_markup(style: Optional[str], text: str) -> str: - normalized = _normalize_style(style) - if not normalized: - return text - return f"[{normalized}]{text}[/{normalized}]" +LOG_LEVEL_CHOICES: tuple[str, ...] = get_args(LogLevel) +CONSOLE_MODE_CHOICES: tuple[str, ...] = get_args(ConsoleMode) +FILE_MODE_CHOICES: tuple[str, ...] = get_args(FileMode) +# Logurich reserves no call keywords of its own; rendering options live on rich(). +_STDLIB_CALL_KWARGS = frozenset({"exc_info", "stack_info", "stacklevel", "extra"}) +_METADATA_KWARG = "_logurich_meta" +_LEGACY_EXTRA_KEYS = frozenset({"context", "renderables"}) +_STANDARD_LOG_RECORD_ATTRS = frozenset(logging.makeLogRecord({}).__dict__) +_RESERVED_CALL_KWARGS = tuple(sorted(_STDLIB_CALL_KWARGS)) -def _context_display_name(name: str) -> str: - if name.startswith("context::"): - return name.split("::", 1)[1] - return name +LogLevels = tuple[int, Optional[dict[str, int]]] @dataclass(frozen=True) -class ContextValue: - """Display metadata for contextual log values.""" - - value: Any - value_style: Optional[str] = None - bracket_style: Optional[str] = None - label: Optional[str] = None - show_key: bool = False - - def _label(self, key: str) -> Optional[str]: - if self.label is not None: - return self.label - if self.show_key: - return key - return None +class _OutputModes: + """Resolved and validated output modes used by ``init_logger``.""" - def render(self, key: str, *, is_rich_handler: bool) -> str: - label = self._label(key) - value_text = escape(str(self.value)) - value_text = _wrap_markup(self.value_style, value_text) - body = f"{escape(label)}={value_text}" if label else value_text - if is_rich_handler: - return body - if _normalize_style(self.bracket_style): - left = _wrap_markup(self.bracket_style, "[") - right = _wrap_markup(self.bracket_style, "]") - else: - left = r"\[" - right = "]" - return f"{left}{body}{right}" + console: str + file: str -def _normalize_context_key(key: str) -> str: - if key.startswith("context::"): - return key - return f"context::{key}" +def _normalise_choice(value: Any, choices: tuple[str, ...], name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string, not {type(value).__name__}") + normalised = value.strip().lower() + if normalised not in choices: + allowed = ", ".join(choices) + raise ValueError(f"Invalid {name} mode {value!r}; expected one of: {allowed}") + return normalised -def _coerce_context_value(value: Any) -> Optional[ContextValue]: - if value is None: - return None - if isinstance(value, ContextValue): - return value - return ContextValue(value=value) - - -def _get_context_state() -> dict[str, ContextValue]: - current = _context_state.get() - return dict(current) if current else {} - - -def _merge_context(raw_context: Any) -> dict[str, ContextValue]: - merged = _get_context_state() - if raw_context is None: - return merged +def _resolve_output_modes( + *, + console: str, + file: str, + is_terminal: bool, + env: Optional[Mapping[str, str]] = None, +) -> _OutputModes: + """Validate output modes and deterministically resolve ``console='auto'``.""" + + environment = {} if env is None else env + resolved_console = _normalise_choice(console, CONSOLE_MODE_CHOICES, "console") + env_console = environment.get("LOGURICH_OUTPUT") + if env_console is not None: + try: + resolved_console = _normalise_choice( + env_console, CONSOLE_MODE_CHOICES, "LOGURICH_OUTPUT" + ) + except ValueError as error: + # A bad env var must not take down the application at startup. + warnings.warn( + f"{error}; falling back to console={resolved_console!r}", stacklevel=3 + ) + resolved_file = _normalise_choice(file, FILE_MODE_CHOICES, "file") + if resolved_console == "auto": + resolved_console = "plain" if is_terminal else "json" + return _OutputModes(console=resolved_console, file=resolved_file) - items = ( - raw_context.items() - if isinstance(raw_context, Mapping) - else [("context", raw_context)] - ) - for key, value in items: - normalized_key = _normalize_context_key(str(key)) - normalized_value = _coerce_context_value(value) - if normalized_value is None: - merged.pop(normalized_key, None) - continue - merged[normalized_key] = normalized_value - return merged +@lru_cache(maxsize=512) +def _misspelled_call_kwarg(key: str) -> Optional[str]: + """Return the reserved keyword ``key`` most likely misspells, if any.""" -def _load_env_extra() -> dict[str, str]: - env_extra: dict[str, str] = {} - for name, value in os.environ.items(): - if name.startswith("LOGURICH_EXTRA_"): - env_extra[name.removeprefix("LOGURICH_EXTRA_")] = value - return env_extra + matches = difflib.get_close_matches(key, _RESERVED_CALL_KWARGS, n=1, cutoff=0.85) + return matches[0] if matches else None def _coerce_level(level: Union[str, int]) -> int: + if isinstance(level, bool): + raise TypeError("Log level must be a string or integer") if isinstance(level, int): if level < 0: - raise ValueError("Log level must be a positive integer") + raise ValueError("Log level must be a non-negative integer") return level + if not isinstance(level, str): + raise TypeError("Log level must be a string or integer") normalized = level.upper() if normalized not in logging._nameToLevel or normalized == "NOTSET": raise ValueError(f"Unknown log level: {level}") return logging._nameToLevel[normalized] -_BaseLoggerClass = logging.getLoggerClass() +class LogurichLogger(logging.LoggerAdapter): + """Explicit Logurich wrapper around an unmodified stdlib logger.""" + def __init__( + self, + logger: logging.Logger, + bound_context: Optional[Mapping[str, Any]] = None, + ) -> None: + super().__init__(logger, {}) + self._bound_context = normalize_context(bound_context or {}) -if hasattr(_BaseLoggerClass, "_logurich_logger_class"): - LogurichLogger = _BaseLoggerClass -else: + @property + def name(self) -> str: + return self.logger.name - class LogurichLogger(_BaseLoggerClass): - """Custom logger exposing Logurich convenience methods.""" + @property + def level(self) -> int: + return self.logger.level - _logurich_logger_class = True + @level.setter + def level(self, value: int) -> None: + self.logger.setLevel(value) - def ctx( - self, - value: Any, - *, - style: Optional[str] = None, - value_style: Optional[str] = None, - bracket_style: Optional[str] = None, - label: Optional[str] = None, - show_key: Optional[bool] = None, - ) -> ContextValue: - return ctx( - value, - style=style, - value_style=value_style, - bracket_style=bracket_style, - label=label, - show_key=show_key, - ) + @property + def handlers(self) -> list[logging.Handler]: + return self.logger.handlers - def rich( - self, - log_level: Union[str, int], - *renderables: Union[ConsoleRenderable, str], - title: str = "", - prefix: bool = True, - end: str = "\n", - width: Optional[int] = None, - ) -> None: - self.log( - _coerce_level(log_level), - title, - extra={ - "renderables": renderables, - "render_prefix": prefix, - "render_width": width, - "end": end, - }, - stacklevel=2, - ) + @handlers.setter + def handlers(self, value: list[logging.Handler]) -> None: + self.logger.handlers = value - def bind(self, **kwargs: Any) -> BoundLogger: - """Return a new :class:`BoundLogger` with *kwargs* pre-set as context.""" - bound_context: dict[str, ContextValue] = {} - for key, value in kwargs.items(): - normalized_key = _normalize_context_key(key) - coerced = _coerce_context_value(value) - if coerced is not None: - bound_context[normalized_key] = coerced - return BoundLogger(self, bound_context) + @property + def propagate(self) -> bool: + return self.logger.propagate - def contextualize( - self, **kwargs: Any - ) -> contextlib.AbstractContextManager[None]: - """Temporarily configure scoped context for this execution context.""" - return global_context_configure(**kwargs) + @propagate.setter + def propagate(self, value: bool) -> None: + self.logger.propagate = value + @property + def disabled(self) -> bool: + return self.logger.disabled -class BoundLogger(logging.LoggerAdapter): - """Logger adapter that carries pre-bound context on every log call.""" + @disabled.setter + def disabled(self, value: bool) -> None: + self.logger.disabled = value - def __init__( - self, - logger_: Union[logging.Logger, logging.LoggerAdapter], - bound_context: dict[str, ContextValue], - ) -> None: - # LoggerAdapter expects (logger, extra); we store context separately. - super().__init__( - logger_ if isinstance(logger_, logging.Logger) else logger_.logger, - {}, - ) - self._bound_context = bound_context - # Preserve chained context from a wrapped BoundLogger. - if isinstance(logger_, BoundLogger): - merged = dict(logger_._bound_context) - merged.update(bound_context) - self._bound_context = merged + def setLevel(self, level: Union[int, str]) -> None: + self.logger.setLevel(level) + + def isEnabledFor(self, level: int) -> bool: + return self.logger.isEnabledFor(level) + + def addHandler(self, handler: logging.Handler) -> None: + self.logger.addHandler(handler) - # -- public convenience methods (mirror LogurichLogger) ---------------- + def removeHandler(self, handler: logging.Handler) -> None: + self.logger.removeHandler(handler) + + @staticmethod + def _validate_legacy_extra(extra: Any) -> None: + if not isinstance(extra, Mapping): + return + legacy = sorted(_LEGACY_EXTRA_KEYS.intersection(extra)) + if not legacy: + return + key = legacy[0] + if key == "context": + replacement = "pass context as keyword arguments" + else: + replacement = "use logger.rich(level, *renderables, ...)" + raise TypeError(f"extra={{'{key}': ...}} is no longer supported; {replacement}") + + @staticmethod + def _validate_call_kwargs(kwargs: Mapping[str, Any]) -> None: + if "renderables" in kwargs: + raise TypeError( + "'renderables' is not a logging keyword; " + "use logger.rich(level, *renderables, ...)" + ) + + def log(self, level: int, msg: Any, *args: Any, **kwargs: Any) -> None: + """Validate removed APIs even when a record would be filtered out.""" + + self._validate_legacy_extra(kwargs.get("extra")) + self._validate_call_kwargs(kwargs) + if not self.isEnabledFor(level): + return + # Bypass LoggerAdapter.log: findCaller only skips its frame on 3.11+. + kwargs["stacklevel"] = kwargs.get("stacklevel", 1) + 1 + msg, kwargs = self.process(msg, kwargs) + self.logger.log(level, msg, *args, **kwargs) def ctx( self, @@ -280,82 +249,109 @@ def rich( prefix: bool = True, end: str = "\n", width: Optional[int] = None, + highlight: bool = False, ) -> None: + """Log live Rich renderables without rendering them at the call site.""" + + if width is not None and width < 1: + raise ValueError("width must be >= 1") self.log( _coerce_level(log_level), title, - extra={ - "renderables": renderables, - "render_prefix": prefix, - "render_width": width, - "end": end, - }, stacklevel=2, + **{ + _METADATA_KWARG: { + "renderables": renderables, + "render_prefix": prefix, + "render_width": width, + "end": end, + "rich_highlight": highlight, + } + }, ) - def bind(self, **kwargs: Any) -> BoundLogger: - """Return a new :class:`BoundLogger` adding *kwargs* to the bound context.""" - new_context: dict[str, ContextValue] = {} - for key, value in kwargs.items(): - normalized_key = _normalize_context_key(key) - coerced = _coerce_context_value(value) - if coerced is not None: - new_context[normalized_key] = coerced - return BoundLogger(self, new_context) + def bind(self, **values: Any) -> LogurichLogger: + """Return a new adapter with additional immutable bound context.""" - def contextualize(self, **kwargs: Any) -> contextlib.AbstractContextManager[None]: - """Temporarily configure scoped context for this execution context.""" - return global_context_configure(**kwargs) + merged = dict(self._bound_context) + merged.update(normalize_context(values)) + return type(self)(self.logger, merged) - # -- adapter plumbing -------------------------------------------------- + def new(self, **values: Any) -> LogurichLogger: + """Return a new adapter whose bound context is exactly ``values``.""" - def process(self, msg: Any, kwargs: Any) -> tuple[Any, Any]: - extra = kwargs.get("extra") - merged_extra = {} if extra is None else dict(extra) - # Merge: bound context first, then per-call context overrides. - existing = merged_extra.get("context") - merged: dict[str, Any] = dict(self._bound_context) - if isinstance(existing, Mapping): - merged.update(existing) - elif existing is not None: - merged["context"] = existing - merged_extra["context"] = merged - kwargs["extra"] = merged_extra - return msg, kwargs + return type(self)(self.logger, values) + def unbind(self, *keys: str) -> LogurichLogger: + """Return a new adapter without ``keys``, failing for missing keys.""" -def _install_logger_class() -> None: - logging.setLoggerClass(LogurichLogger) - logging.RootLogger.ctx = LogurichLogger.ctx - logging.RootLogger.rich = LogurichLogger.rich - logging.RootLogger.bind = LogurichLogger.bind - logging.RootLogger.contextualize = LogurichLogger.contextualize + unique_keys = tuple(dict.fromkeys(keys)) + missing = [key for key in unique_keys if key not in self._bound_context] + if missing: + raise KeyError(missing[0]) + remaining = dict(self._bound_context) + for key in unique_keys: + del remaining[key] + return type(self)(self.logger, remaining) - for existing in logging.Logger.manager.loggerDict.values(): - if isinstance(existing, logging.PlaceHolder): - continue - if isinstance(existing, LogurichLogger): - continue - with contextlib.suppress(TypeError): - existing.__class__ = LogurichLogger + def try_unbind(self, *keys: str) -> LogurichLogger: + """Return a new adapter without ``keys``, ignoring missing keys.""" + remaining = dict(self._bound_context) + for key in keys: + remaining.pop(key, None) + return type(self)(self.logger, remaining) -_install_logger_class() + def contextualize(self, **values: Any) -> contextlib.AbstractContextManager[None]: + """Temporarily extend context for the current execution.""" + return global_context_configure(**values) -def get_logger(name: Optional[str] = None) -> LogurichLogger: - """Typed wrapper around :func:`logging.getLogger`. + def process(self, msg: Any, kwargs: Any) -> tuple[Any, Any]: + """Split stdlib options from per-call context.""" + + supplied_extra = kwargs.get("extra") + if supplied_extra is None: + merged_extra: dict[str, Any] = {} + elif isinstance(supplied_extra, Mapping): + merged_extra = dict(supplied_extra) + else: + raise TypeError("extra must be a mapping or None") + + self._validate_legacy_extra(merged_extra) + + metadata: dict[str, Any] = kwargs.pop(_METADATA_KWARG, None) or {} + call_context: dict[str, Any] = { + key: kwargs.pop(key) + for key in tuple(kwargs) + if key not in _STDLIB_CALL_KWARGS + } + + context = dict(self._bound_context) + context.update(normalize_context(call_context)) + + for key in call_context: + suggestion = _misspelled_call_kwarg(key) + if suggestion is not None: + # Frames: process -> log -> info/debug/... -> caller. + warnings.warn( + f"{key!r} is not a logging keyword and was recorded as " + f"context; did you mean {suggestion!r}?", + stacklevel=4, + ) + + merged_extra.update(metadata) + merged_extra["context"] = context + merged_extra["_logurich_record"] = True + merged_extra["_logurich_metadata"] = tuple(metadata) + kwargs["extra"] = merged_extra + return msg, kwargs - Returns the same logger instance but typed as :class:`LogurichLogger` - so that IDEs auto-complete ``ctx``, ``rich``, ``bind``, and - ``contextualize``. - """ - return logging.getLogger(name) # type: ignore[return-value] +def get_logger(name: Optional[str] = None) -> LogurichLogger: + """Return a new Logurich adapter around the named stdlib logger.""" -_internal_logger: LogurichLogger = get_logger("logurich") -_internal_logger.setLevel(logging.NOTSET) -_internal_logger.propagate = True + return LogurichLogger(logging.getLogger(name)) def _configure_level_by_module( @@ -375,7 +371,7 @@ def _configure_level_by_module( def _resolve_level_for_record(name: str) -> int: min_level = logger_state.get("min_level") if min_level is None: - return logging.INFO + return logging.NOTSET level_per_module = logger_state.get("level_by_module") or {} if not level_per_module: @@ -394,23 +390,63 @@ def _resolve_level_for_record(name: str) -> int: return level +def _user_extra(record: logging.LogRecord) -> dict[str, Any]: + return { + key: value + for key, value in record.__dict__.items() + if key not in _STANDARD_LOG_RECORD_ATTRS + and key != "message" + and not key.startswith("_logurich_") + } + + class _ProducerFilter(logging.Filter): - """Enrich log records before direct output or enqueueing.""" + """Normalise a LogRecord once, before direct output or enqueueing.""" def filter(self, record: logging.LogRecord) -> bool: if getattr(record, "_logurich_prepared", False): return True - record._logurich_prepared = True - record.context = _merge_context(getattr(record, "context", None)) + is_logurich_record = bool(getattr(record, "_logurich_record", False)) + metadata = set(getattr(record, "_logurich_metadata", ())) + context = get_context() + extras = _user_extra(record) + + if is_logurich_record: + extras.pop("context", None) + for key in metadata: + extras.pop(key, None) + context.update(normalize_context(extras)) + raw_context = getattr(record, "context", {}) + if isinstance(raw_context, Mapping): + context.update(normalize_context(raw_context)) + else: + context.update(normalize_context(extras)) + + record.context = context record.renderables = self._normalize_renderables( getattr(record, "renderables", ()) + if is_logurich_record and "renderables" in metadata + else () + ) + record.render_prefix = ( + getattr(record, "render_prefix", True) + if "render_prefix" in metadata + else True + ) + record.render_width = ( + getattr(record, "render_width", None) + if "render_width" in metadata + else None + ) + record.end = getattr(record, "end", "\n") if "end" in metadata else "\n" + record.rich_highlight = bool( + getattr(record, "rich_highlight", False) + if "rich_highlight" in metadata + else False ) - record.render_prefix = getattr(record, "render_prefix", True) - record.render_width = getattr(record, "render_width", None) - record.end = getattr(record, "end", "\n") - record.rich_highlight = bool(getattr(record, "rich_highlight", False)) + record.formatted_stack = record.stack_info or "" if record.exc_info: record.formatted_exception = "".join( traceback.format_exception(*record.exc_info) @@ -438,6 +474,7 @@ def filter(self, record: logging.LogRecord) -> bool: record.exception_data = getattr(record, "exception_data", None) record.rich_traceback = getattr(record, "rich_traceback", None) + record._logurich_prepared = True return True @staticmethod @@ -452,14 +489,34 @@ def _normalize_renderables(renderables: Any) -> tuple[Any, ...]: class _OutputFilter(logging.Filter): - """Apply logger-level and per-module level filtering.""" + """Apply the configured global and per-module levels.""" def filter(self, record: logging.LogRecord) -> bool: return record.levelno >= _resolve_level_for_record(record.name) class _LogurichQueueHandler(logging.handlers.QueueHandler): - """Queue handler that preserves enriched log record attributes.""" + """Queue handler preserving serialisable Rich values for the listener.""" + + @staticmethod + def _safe_renderables(record: logging.LogRecord) -> tuple[Any, ...]: + safe: list[Any] = [] + for renderable in getattr(record, "renderables", ()): + try: + pickle.dumps(renderable) + except Exception: + safe.append(rich_to_str(renderable, ansi=False, end="").rstrip("\n")) + else: + safe.append(renderable) + return tuple(safe) + + @staticmethod + def _requires_pickle_validation(record: logging.LogRecord) -> bool: + """Return whether the normalised record retains non-standard values.""" + + return bool( + record.context or record.renderables or record.rich_traceback is not None + ) def prepare(self, record: logging.LogRecord) -> logging.LogRecord: prepared = copy.copy(record) @@ -470,68 +527,38 @@ def prepare(self, record: logging.LogRecord) -> logging.LogRecord: prepared.exc_info = None prepared.exc_text = None prepared.stack_info = None + if not self._requires_pickle_validation(prepared): + return prepared + try: + pickle.dumps(prepared) + except Exception as error: + if not prepared.renderables: + raise TypeError( + "LogRecord cannot be sent through the multiprocessing queue; " + "use serialisable context values" + ) from error + + prepared.renderables = self._safe_renderables(prepared) + try: + pickle.dumps(prepared) + except Exception as fallback_error: + raise TypeError( + "LogRecord cannot be sent through the multiprocessing queue; " + "use serialisable context values" + ) from fallback_error return prepared _PRODUCER_FILTER = _ProducerFilter() _OUTPUT_FILTER = _OutputFilter() -LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] -LOG_LEVEL_CHOICES: tuple[str, ...] = get_args(LogLevel) - -def ctx( - value: Any, - *, - style: Optional[str] = None, - value_style: Optional[str] = None, - bracket_style: Optional[str] = None, - label: Optional[str] = None, - show_key: Optional[bool] = None, -) -> ContextValue: - """Build a ``ContextValue`` helper for structured context logging.""" - - effective_value_style = value_style if value_style is not None else style - return ContextValue( - value=value, - value_style=effective_value_style, - bracket_style=bracket_style, - label=label, - show_key=bool(show_key) if show_key is not None else False, - ) - - -@contextlib.contextmanager -def global_context_configure(**kwargs: Any): - """Temporarily configure scoped context for the current execution context.""" - - updated = _get_context_state() - for key, value in kwargs.items(): - normalized_key = _normalize_context_key(key) - normalized_value = _coerce_context_value(value) - if normalized_value is None: - updated.pop(normalized_key, None) - continue - updated[normalized_key] = normalized_value - token = _context_state.set(updated) - try: - yield - finally: - _context_state.reset(token) - - -def global_context_set(**kwargs: Any) -> None: - """Set scoped context for subsequent log records in the current process.""" - - updated = _get_context_state() - for key, value in kwargs.items(): - normalized_key = _normalize_context_key(key) - normalized_value = _coerce_context_value(value) - if normalized_value is None: - updated.pop(normalized_key, None) - continue - updated[normalized_key] = normalized_value - _context_state.set(updated) +def _load_env_extra() -> dict[str, str]: + env_extra: dict[str, str] = {} + for name, value in os.environ.items(): + if name.startswith("LOGURICH_EXTRA_"): + env_extra[name.removeprefix("LOGURICH_EXTRA_")] = value + return env_extra def _unique_handlers(*groups: list[logging.Handler]) -> list[logging.Handler]: @@ -554,28 +581,37 @@ def _close_handlers(handlers: list[logging.Handler]) -> None: handler.close() -def _remove_handlers(logger_: logging.Logger) -> list[logging.Handler]: - handlers = list(logger_.handlers) +def _remove_installed_handlers() -> list[logging.Handler]: + root = logging.getLogger() + handlers = list(logger_state.get("installed_handlers") or ()) for handler in handlers: - logger_.removeHandler(handler) + root.removeHandler(handler) return handlers -def _build_console_handler( - log_verbose: int, *, rich_handler: bool, serialize: bool -) -> logging.Handler: - renderer = LogurichRenderer(log_verbose) - if serialize or not rich_handler: - handler: logging.Handler = CustomHandler(renderer, serialize=serialize) - else: - handler = CustomRichHandler( - renderer, - rich_tracebacks=True, - markup=True, - tracebacks_show_locals=True, - ) +def _configure_handler(handler: logging.Handler, *, producer: bool) -> None: handler.setLevel(logging.NOTSET) handler.addFilter(_OUTPUT_FILTER) + if producer: + handler.addFilter(_PRODUCER_FILTER) + + +def _build_console_handler(log_verbose: int, *, mode: str) -> logging.Handler: + renderer = LogurichRenderer(log_verbose) + if mode == "rich": + try: + handler: logging.Handler = CustomRichHandler( + renderer, + rich_tracebacks=True, + markup=True, + tracebacks_show_locals=True, + ) + except RichLayoutUnavailable as error: + warnings.warn(f"{error}; falling back to console='plain'", stacklevel=3) + handler = CustomHandler(renderer, serialize=False) + else: + handler = CustomHandler(renderer, serialize=mode == "json") + _configure_handler(handler, producer=False) return handler @@ -598,7 +634,7 @@ def _build_file_handler( log_path: Path, *, log_verbose: int, - serialize: bool, + mode: str, rotation: Optional[Union[str, int]], retention: Optional[int], ) -> logging.Handler: @@ -639,26 +675,25 @@ def _build_file_handler( "rotation must be None, an integer, 'midnight', or a string in HH:MM format" ) - handler.setLevel(logging.NOTSET) handler.setFormatter( - LogurichFileFormatter(LogurichRenderer(log_verbose), serialize=serialize) + LogurichFileFormatter(LogurichRenderer(log_verbose), serialize=mode == "json") ) - handler.addFilter(_OUTPUT_FILTER) + handler.terminator = "" # type: ignore[attr-defined] + _configure_handler(handler, producer=False) return handler def shutdown_logger() -> None: - """Stop queue listeners and close all configured handlers.""" + """Stop Logurich's listener and close only handlers owned by Logurich.""" listener = logger_state.get("listener") if listener is not None: - listener.stop() + with contextlib.suppress(Exception): + listener.stop() - root = logging.getLogger() - root_handlers = _remove_handlers(root) - logger_handlers = _remove_handlers(_internal_logger) + installed_handlers = _remove_installed_handlers() final_handlers = list(logger_state.get("final_handlers") or ()) - _close_handlers(_unique_handlers(root_handlers, logger_handlers, final_handlers)) + _close_handlers(_unique_handlers(installed_handlers, final_handlers)) queue = logger_state.get("queue") if queue is not None: @@ -667,6 +702,10 @@ def shutdown_logger() -> None: with contextlib.suppress(Exception): queue.join_thread() + original_root_level = logger_state.get("original_root_level") + if original_root_level is not None: + logging.getLogger().setLevel(original_root_level) + logger_state.update( { "min_level": None, @@ -675,15 +714,16 @@ def shutdown_logger() -> None: "queue": None, "listener": None, "final_handlers": (), + "installed_handlers": (), "env_extra": {}, + "output_modes": None, + "original_root_level": None, } ) - _context_state.set({}) + clear_context() def _ensure_shutdown_atexit_registered() -> None: - """Register ``shutdown_logger`` once so handlers are flushed on process exit.""" - if logger_state.get("atexit_registered"): return atexit.register(shutdown_logger) @@ -691,21 +731,17 @@ def _ensure_shutdown_atexit_registered() -> None: def _ensure_shutdown_threading_atexit_registered() -> None: - """Register ``shutdown_logger`` before interpreter thread shutdown when possible.""" - if logger_state.get("threading_atexit_registered"): return - register = getattr(threading, "_register_atexit", None) if register is None: return - register(shutdown_logger) logger_state["threading_atexit_registered"] = True def get_log_queue() -> mp.Queue: - """Return the active multiprocessing queue used for logging.""" + """Return the active multiprocessing logging queue.""" queue = logger_state.get("queue") if queue is None: @@ -715,21 +751,59 @@ def get_log_queue() -> mp.Queue: return queue -def configure_child_logging(queue: mp.Queue, logger_name: str = "logurich") -> None: - """Configure a child process to forward logs to the parent logging queue.""" +def get_log_levels() -> LogLevels: + """Return the configured levels, to hand to ``configure_child_logging``.""" + + min_level = logger_state.get("min_level") + if min_level is None: + raise RuntimeError("Logging is not configured. Call init_logger() first.") + level_by_module = logger_state.get("level_by_module") + return (min_level, dict(level_by_module) if level_by_module else None) + + +def _level_floor(min_level: int, level_by_module: Optional[Mapping[str, int]]) -> int: + """Return the lowest level that any configured logger may emit.""" + + return min([min_level, *(level_by_module or {}).values()]) + + +def _apply_child_levels(levels: Optional[LogLevels], root: logging.Logger) -> None: + """Adopt the parent's levels so the worker filters before building records.""" + + if levels is None: + root.setLevel(logging.NOTSET) + return + + min_level, level_by_module = levels + logger_state.update({"min_level": min_level, "level_by_module": level_by_module}) + # Permissive floor only; _OUTPUT_FILTER still applies the per-module levels. + root.setLevel(_level_floor(min_level, level_by_module)) + + +def configure_child_logging( + queue: mp.Queue, + logger_name: str = "logurich", + *, + levels: Optional[LogLevels] = None, +) -> None: + """Forward a worker's stdlib records to the parent's Logurich queue. + + Pass ``levels=get_log_levels()`` from the parent to drop filtered records in + the worker; without it the worker enqueues everything and the parent filters. + """ root = logging.getLogger() - _close_handlers(_remove_handlers(root)) + inherited_handlers = list(root.handlers) + for handler in inherited_handlers: + root.removeHandler(handler) + _close_handlers(inherited_handlers) queue_handler = _LogurichQueueHandler(queue) - queue_handler.setLevel(logging.NOTSET) - queue_handler.addFilter(_PRODUCER_FILTER) - + _configure_handler(queue_handler, producer=True) root.addHandler(queue_handler) - root.setLevel(logging.NOTSET) + _apply_child_levels(levels, root) child_logger = logging.getLogger(logger_name) - _close_handlers(_remove_handlers(child_logger)) child_logger.setLevel(logging.NOTSET) child_logger.propagate = True @@ -738,25 +812,55 @@ def configure_child_logging(queue: mp.Queue, logger_name: str = "logurich") -> N "queue": queue, "listener": None, "final_handlers": (), + "installed_handlers": (queue_handler,), } ) +def _warn_legacy_environment(env: Mapping[str, str]) -> None: + legacy = [name for name in ("LOGURICH_RICH", "LOGURICH_SERIALIZE") if name in env] + if legacy: + names = ", ".join(legacy) + verb = "are" if len(legacy) > 1 else "is" + warnings.warn( + f"{names} {verb} no longer supported and {verb} ignored; use " + "LOGURICH_OUTPUT and the console/file arguments", + stacklevel=3, + ) + + def init_logger( - log_level: LogLevel, + log_level: Union[LogLevel, str, int], log_verbose: int = 0, log_filename: Optional[str] = None, log_folder: str = "logs", level_by_module: Optional[Mapping[str, Union[str, int]]] = None, *, - rich_handler: bool = False, + console: ConsoleMode = "plain", + file: FileMode = "text", enqueue: bool = True, highlight: bool = False, rotation: Optional[Union[str, int]] = "12:00", retention: Optional[int] = 10, force: bool = False, ) -> Optional[str]: - """Initialize stdlib logging with optional Rich rendering and queue support.""" + """Configure stdlib logging with independent console and file formats. + + ``LOGURICH_OUTPUT``, when set, takes precedence over ``console`` and does + not affect ``file``. + """ + + _warn_legacy_environment(os.environ) + modes = _resolve_output_modes( + console=console, + file=file, + is_terminal=rich_get_console().is_terminal, + env=os.environ, + ) + min_level = _coerce_level(log_level) + module_levels = ( + _configure_level_by_module(level_by_module) if level_by_module else None + ) if not force and logger_state.get("min_level") is not None: return None @@ -765,20 +869,11 @@ def init_logger( _ensure_shutdown_atexit_registered() shutdown_logger() - env_rich_handler = parse_bool_env("LOGURICH_RICH") - if env_rich_handler is not None: - rich_handler = env_rich_handler - - serialize = bool(parse_bool_env("LOGURICH_SERIALIZE")) - min_level = _coerce_level(log_level) - module_levels = ( - _configure_level_by_module(level_by_module) if level_by_module else None - ) - root = logging.getLogger() - root.setLevel(logging.NOTSET) - _internal_logger.setLevel(logging.NOTSET) - _internal_logger.propagate = True + original_root_level = root.level + # Reject records below every configured threshold before constructing them. + # _OUTPUT_FILTER still applies the exact per-module level at the handler. + root.setLevel(_level_floor(min_level, module_levels)) logger_state.update( { @@ -786,12 +881,12 @@ def init_logger( "level_by_module": module_levels, "rich_highlight": highlight, "env_extra": _load_env_extra(), + "output_modes": modes, + "original_root_level": original_root_level, } ) - console_handler = _build_console_handler( - log_verbose, rich_handler=rich_handler, serialize=serialize - ) + console_handler = _build_console_handler(log_verbose, mode=modes.console) final_handlers: list[logging.Handler] = [console_handler] log_path: Optional[str] = None @@ -803,19 +898,20 @@ def init_logger( _build_file_handler( file_path, log_verbose=log_verbose, - serialize=serialize, + mode=modes.file, rotation=rotation, retention=retention, ) ) log_path = str(file_path.resolve()) + installed_handlers: list[logging.Handler] if enqueue: queue = mp.Queue() queue_handler = _LogurichQueueHandler(queue) - queue_handler.setLevel(logging.NOTSET) - queue_handler.addFilter(_PRODUCER_FILTER) + _configure_handler(queue_handler, producer=True) root.addHandler(queue_handler) + installed_handlers = [queue_handler] listener = logging.handlers.QueueListener( queue, @@ -823,23 +919,18 @@ def init_logger( respect_handler_level=True, ) listener.start() - logger_state.update( - { - "queue": queue, - "listener": listener, - "final_handlers": tuple(final_handlers), - } - ) + logger_state.update({"queue": queue, "listener": listener}) else: for handler in final_handlers: handler.addFilter(_PRODUCER_FILTER) root.addHandler(handler) - logger_state.update( - { - "queue": None, - "listener": None, - "final_handlers": tuple(final_handlers), - } - ) + installed_handlers = final_handlers + logger_state.update({"queue": None, "listener": None}) + logger_state.update( + { + "final_handlers": tuple(final_handlers), + "installed_handlers": tuple(installed_handlers), + } + ) return log_path diff --git a/src/logurich/handler.py b/src/logurich/handler.py index b6cf8cf..1d3235a 100644 --- a/src/logurich/handler.py +++ b/src/logurich/handler.py @@ -5,12 +5,14 @@ import json import logging from datetime import datetime, timedelta +from importlib.metadata import version as metadata_version from logging import Formatter, Handler, LogRecord from pathlib import Path from time import perf_counter from typing import TYPE_CHECKING, Any, Union -from rich.console import ConsoleRenderable +from rich.console import ConsoleRenderable, Group +from rich.constrain import Constrain from rich.highlighter import ReprHighlighter from rich.logging import RichHandler from rich.pretty import Pretty @@ -33,6 +35,7 @@ "end", "exception_data", "formatted_exception", + "formatted_stack", "message", "render_prefix", "render_width", @@ -50,10 +53,11 @@ def _safe_text_from_markup(value: str) -> Text: return Text(value) -def _context_display_name(name: str) -> str: - if name.startswith("context::"): - return name.split("::", 1)[1] - return name +def _installed_rich_version() -> str: + try: + return metadata_version("rich") + except Exception: + return "(unknown version)" class LogurichRenderer: @@ -75,13 +79,10 @@ def build_context(self, record: LogRecord, *, is_rich_handler: bool) -> list[str list_context: list[str] = [] context = getattr(record, "context", {}) or {} for name, value in context.items(): - display_name = _context_display_name(name) if hasattr(value, "render"): - list_context.append( - value.render(display_name, is_rich_handler=is_rich_handler) - ) + list_context.append(value.render(name, is_rich_handler=is_rich_handler)) else: - list_context.append(f"[{display_name}={value}]") + list_context.append(f"[{name}={value}]") return list_context def build_prefix(self, record: LogRecord) -> str: @@ -109,10 +110,13 @@ def format_file(self, record: LogRecord) -> str: ).plain message_plain = _safe_text_from_markup(record.getMessage()).plain exception_text = getattr(record, "formatted_exception", "").rstrip("\n") + stack_text = getattr(record, "formatted_stack", "").rstrip("\n") parts: list[str] = [] if message_plain or not self._renderables(record): line = f"{prefix_plain}{context_plain}{message_plain}" + if stack_text: + line = f"{line}\n{stack_text}" if line else stack_text if exception_text: line = f"{line}\n{exception_text}" if line else exception_text parts.append(line) @@ -204,10 +208,7 @@ def _serialize_extra(self, record: LogRecord) -> dict[str, Any]: if user_extra: serialized.update(user_extra) serialized.update( - { - _context_display_name(key): getattr(value, "value", value) - for key, value in context.items() - } + {key: getattr(value, "value", value) for key, value in context.items()} ) return serialized @@ -242,8 +243,16 @@ def __init__(self, renderer: LogurichRenderer, *, serialize: bool) -> None: def format(self, record: LogRecord) -> str: if self.serialize: - return self.renderer.format_json(record) - return self.renderer.format_file(record) + formatted = self.renderer.format_json(record) + end = "\n" + else: + formatted = self.renderer.format_file(record) + end = getattr(record, "end", "\n") + return f"{formatted}{end}" + + +class RichLayoutUnavailable(RuntimeError): + """Raised when Rich no longer exposes the log layout ``console='rich'`` needs.""" class CustomRichHandler(RichHandler): @@ -254,6 +263,11 @@ def __init__( ) -> None: self.renderer = renderer super().__init__(*args, console=rich_get_console(), **kwargs) + if not callable(getattr(self, "_log_render", None)): + raise RichLayoutUnavailable( + f"rich {_installed_rich_version()} does not provide " + "RichHandler._log_render, which console='rich' renders through" + ) def build_content(self, record: LogRecord, content: RenderableType) -> Table: row: list[Union[str, RenderableType]] = [] @@ -282,28 +296,80 @@ def render( log_time = datetime.fromtimestamp(record.created) rich_tb = getattr(record, "rich_traceback", None) renderables = list(self.renderer._renderables(record)) - output: list[RenderableType] = [] + message_output: list[RenderableType] = [] + rich_output: list[RenderableType] = [] + diagnostic_output: list[RenderableType] = [] + render_width = getattr(record, "render_width", None) if record.getMessage(): - output.append(self.build_content(record, message_renderable)) + message_output.append(self.build_content(record, message_renderable)) for item in renderables: if isinstance(item, (ConsoleRenderable, str)): - output.append(item) + renderable: RenderableType = item else: - output.append(Pretty(item)) + renderable = Pretty(item) + if render_width is not None: + renderable = Constrain(renderable, width=render_width) + rich_output.append(renderable) + stack_text = getattr(record, "formatted_stack", "").rstrip("\n") + if stack_text and record.stack_info is None: + diagnostic_output.append(Text(stack_text)) if rich_tb is not None: - output.append(rich_tb) - - return self._log_render( - self.console, - output, - log_time=log_time, - time_format=time_format, - level=level, - path=path, - line_no=record.lineno, - link_path=record.pathname if self.enable_link_path else None, - ) + diagnostic_output.append(rich_tb) + + render_prefix = getattr(record, "render_prefix", True) + if render_prefix or not renderables: + return self._log_render( + self.console, + message_output + rich_output + diagnostic_output, + log_time=log_time, + time_format=time_format, + level=level, + path=path, + line_no=record.lineno, + link_path=record.pathname if self.enable_link_path else None, + ) + + result: list[RenderableType] = [] + if message_output or diagnostic_output: + result.append( + self._log_render( + self.console, + message_output + diagnostic_output, + log_time=log_time, + time_format=time_format, + level=level, + path=path, + line_no=record.lineno, + link_path=record.pathname if self.enable_link_path else None, + ) + ) + result.extend(rich_output) + return Group(*result) + + def emit(self, record: LogRecord) -> None: + """Emit with the per-record ``end`` contract used by ``logger.rich``. + + Derived from ``rich.logging.RichHandler.emit``, which hardcodes + ``end="\\n"``, and relies on the private ``_log_render`` attribute. + """ + + try: + message = self.format(record) + if getattr(record, "rich_traceback", None) is not None and record.exc_info: + message = record.getMessage() + if self.formatter is not None: + record.message = message + message = self.formatter.formatMessage(record) + message_renderable = self.render_message(record, message) + log_renderable = self.render( + record=record, + traceback=None, + message_renderable=message_renderable, + ) + self.console.print(log_renderable, end=getattr(record, "end", "\n")) + except Exception: + self.handleError(record) class CustomHandler(Handler): @@ -326,13 +392,14 @@ def emit(self, record: LogRecord) -> None: try: if self.serialize: payload = self.renderer.format_json(record) - self._console.out(payload, highlight=False, end=end) + self._console.out(payload, highlight=False, end="\n") return prefix = self.renderer.build_prefix(record) list_context = self.renderer.build_context(record, is_rich_handler=False) renderables = self.renderer._renderables(record) exception_text = getattr(record, "formatted_exception", "").rstrip("\n") + stack_text = getattr(record, "formatted_stack", "").rstrip("\n") if record.getMessage(): output_text = _safe_text_from_markup(prefix) @@ -344,6 +411,9 @@ def emit(self, record: LogRecord) -> None: if self._should_highlight(record): message_text = self.highlighter(message_text) output_text.append_text(message_text) + if stack_text: + output_text.append("\n") + output_text.append_text(Text(stack_text)) if exception_text: output_text.append("\n") output_text.append_text(Text(exception_text)) @@ -353,8 +423,11 @@ def emit(self, record: LogRecord) -> None: highlight=False, soft_wrap=True, ) - elif exception_text: - self._console.print(Text(exception_text), end=end, highlight=False) + elif stack_text or exception_text: + diagnostic = "\n".join( + part for part in (stack_text, exception_text) if part + ) + self._console.print(Text(diagnostic), end=end, highlight=False) if renderables: rendered = rich_console_renderer( diff --git a/src/logurich/opt_click.py b/src/logurich/opt_click.py index ef27092..dea76ed 100644 --- a/src/logurich/opt_click.py +++ b/src/logurich/opt_click.py @@ -13,14 +13,19 @@ import click -from . import LOG_LEVEL_CHOICES, init_logger, shutdown_logger +from . import ( + CONSOLE_MODE_CHOICES, + LOG_LEVEL_CHOICES, + init_logger, + shutdown_logger, +) LOGGER_PARAM_NAMES = ( "logger_level", "logger_verbose", "logger_filename", "logger_level_by_module", - "logger_rich", + "logger_console", ) _CLICK_SHUTDOWN_META_KEY = "logurich_shutdown_registered" _logger = logging.getLogger(__name__) @@ -36,7 +41,7 @@ def click_logger_params(func: F) -> F: - ``-v, --logger-verbose``: Increase verbosity (can be used multiple times) - ``--logger-filename``: Enable file logging with specified filename - ``--logger-level-by-module``: Set specific log levels per module - - ``--logger-rich``: Enable Rich handler for enhanced console output + - ``--logger-console``: Select plain, Rich, JSON, or automatic console output The decorator initializes the logger before the command function executes. @@ -84,11 +89,11 @@ def click_logger_params(func: F) -> F: type=(str, str), ) @click.option( - "--logger-rich", - is_flag=True, - help="Enable rich handler for enhanced console output", - type=bool, - default=False, + "--logger-console", + help="Console output mode; LOGURICH_OUTPUT takes precedence when set", + type=click.Choice(CONSOLE_MODE_CHOICES, case_sensitive=False), + default="plain", + show_default=True, ) @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: @@ -111,7 +116,7 @@ def click_logger_init( logger_verbose: int, logger_filename: Optional[str], logger_level_by_module: tuple[tuple[str, str], ...], - logger_rich: bool, + logger_console: str, ) -> None: """Initialize the logger with parameters from Click CLI options. @@ -123,7 +128,7 @@ def click_logger_init( logger_verbose: Verbosity level (0-3). logger_filename: Path to log file, or None for console-only logging. logger_level_by_module: Tuple of (module_name, level) pairs for per-module levels. - logger_rich: Whether to use Rich handler for enhanced console output. + logger_console: Console output mode (auto, rich, plain, or json). Example: This function is typically not called directly. It's invoked by the @@ -137,7 +142,7 @@ def click_logger_init( logger_verbose, log_filename=logger_filename, level_by_module=lbm, - rich_handler=logger_rich, + console=logger_console, ) click_ctx = click.get_current_context(silent=True) if click_ctx is not None and not click_ctx.meta.get(_CLICK_SHUTDOWN_META_KEY): @@ -148,4 +153,4 @@ def click_logger_init( _logger.debug("Log filename: %s", logger_filename) _logger.debug("Log path: %s", log_path) _logger.debug("Log level by module: %s", lbm) - _logger.debug("Log rich handler: %s", logger_rich) + _logger.debug("Log console mode: %s", logger_console) diff --git a/src/logurich/struct.py b/src/logurich/struct.py index cf641ea..78d6b3e 100644 --- a/src/logurich/struct.py +++ b/src/logurich/struct.py @@ -9,7 +9,10 @@ "queue": None, "listener": None, "final_handlers": (), + "installed_handlers": (), "env_extra": {}, + "output_modes": None, + "original_root_level": None, "atexit_registered": False, "threading_atexit_registered": False, } diff --git a/src/logurich/user_input.py b/src/logurich/user_input.py index 58ced17..2d69d74 100644 --- a/src/logurich/user_input.py +++ b/src/logurich/user_input.py @@ -3,7 +3,6 @@ from __future__ import annotations import getpass -import logging import platform import signal import sys @@ -13,7 +12,9 @@ from rich.console import ConsoleRenderable -logger = logging.getLogger(__name__) +from .core import get_logger + +_logger = get_logger(__name__) class InputValueError(Exception): @@ -60,9 +61,7 @@ def user_input( hide_input: bool = False, context: Union[str, ConsoleRenderable] = None, ): - use_logger = logger - if custom_logger is not None: - use_logger = custom_logger + use_logger = custom_logger if custom_logger is not None else _logger str_default = "" if default: str_default = f" [default={default}]" @@ -72,10 +71,11 @@ def user_input( use_logger.rich("INFO", context, prefix=False) while True: while True: - use_logger.bind(end="").rich( + use_logger.rich( "INFO", f"{text}{str_default}{prompt_suffix}", prefix=False, + end="", ) value = getpass.getpass("") if hide_input is True else input() if value: @@ -103,7 +103,7 @@ def user_input( def raise_timeout(signum, frame): - logger.error("User input timeout ! Exiting...") + _logger.error("User input timeout ! Exiting...") sys.exit(1) diff --git a/src/logurich/utils.py b/src/logurich/utils.py deleted file mode 100644 index 0b517fb..0000000 --- a/src/logurich/utils.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Utility helpers for logurich.""" - -import os -from typing import Optional - - -def parse_bool_env(name: str) -> Optional[bool]: - value = os.environ.get(name) - if value is None: - return None - normalized = value.strip().lower() - if normalized in {"1", "true", "yes", "on"}: - return True - if normalized in {"0", "false", "no", "off", ""}: - return False - return None diff --git a/tests/conftest.py b/tests/conftest.py index 64468eb..905a50f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,8 @@ -import logging from io import StringIO import pytest -from logurich import init_logger, shutdown_logger +from logurich import get_logger, init_logger, shutdown_logger from logurich.console import rich_configure_console @@ -21,14 +20,14 @@ def logger(request): "level": "INFO", "verbose": 0, "enqueue": False, - "rich_handler": False, + "console": "plain", } cfg = {**default_cfg, **getattr(request, "param", {})} init_logger( cfg["level"], log_verbose=cfg["verbose"], enqueue=cfg["enqueue"], - rich_handler=cfg["rich_handler"], + console=cfg["console"], ) - yield logging.getLogger("tests.fixture") + yield get_logger("tests.fixture") shutdown_logger() diff --git a/tests/test_core.py b/tests/test_core.py index 27dc888..7f2fd69 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,535 +1,608 @@ +import asyncio +import inspect import json import logging +import subprocess +import sys import threading -from types import MappingProxyType +import warnings +from pathlib import Path import pytest -from rich.panel import Panel +import logurich +import logurich.core as core from logurich import ( - BoundLogger, + CONSOLE_MODE_CHOICES, + FILE_MODE_CHOICES, + LogurichLogger, + clear_context, ctx, + get_logger, global_context_configure, global_context_set, + global_context_unset, init_logger, shutdown_logger, ) -from logurich.console import rich_configure_console from logurich.struct import logger_state -@pytest.mark.parametrize( - "logger", - [{"level": "INFO", "enqueue": False}, {"level": "INFO", "enqueue": True}], - indirect=True, -) -def test_level_info(logger, buffer): +@pytest.mark.parametrize("enqueue", [False, True]) +def test_level_and_positional_formatting(enqueue, buffer): + init_logger("INFO", enqueue=enqueue) + logger = get_logger("tests.level") + logger.info("Hello, %s!", "world") - logger.debug("Debug, world!") + logger.debug("filtered") shutdown_logger() + output = buffer.getvalue() assert "Hello, world!" in output - assert "Debug, world!" not in output + assert "filtered" not in output -def test_init_logger_registers_shutdown_hooks_once(monkeypatch): - registered: list[object] = [] - thread_registered: list[object] = [] - register_threading_atexit = getattr(threading, "_register_atexit", None) +@pytest.mark.parametrize( + ("level_by_module", "expected_level"), + [ + (None, logging.WARNING), + ({"noisy": "ERROR"}, logging.WARNING), + ({"noisy": "DEBUG"}, logging.DEBUG), + ], +) +def test_root_level_uses_permissive_floor(level_by_module, expected_level): + root = logging.getLogger() + logger = logging.getLogger("tests.parent-level-floor") + logger.setLevel(logging.NOTSET) - monkeypatch.setitem(logger_state, "atexit_registered", False) - monkeypatch.setitem(logger_state, "threading_atexit_registered", False) - monkeypatch.setattr("logurich.core.atexit.register", registered.append) - if register_threading_atexit is None: - monkeypatch.delattr("logurich.core.threading._register_atexit", raising=False) - else: - monkeypatch.setattr( - "logurich.core.threading._register_atexit", thread_registered.append + try: + init_logger( + "WARNING", + level_by_module=level_by_module, + enqueue=False, ) - init_logger("INFO", enqueue=False) - shutdown_logger() - init_logger("INFO", enqueue=False) - shutdown_logger() + assert root.level == expected_level + assert logger.isEnabledFor(logging.DEBUG) is (expected_level <= logging.DEBUG) + finally: + shutdown_logger() + + +def test_import_does_not_monkeypatch_logging(tmp_path): + script = tmp_path / "check_import.py" + script.write_text( + "import logging\n" + "logger_class = logging.getLoggerClass()\n" + "root_class = type(logging.getLogger())\n" + "import logurich\n" + "assert logging.getLoggerClass() is logger_class\n" + "assert type(logging.getLogger()) is root_class\n" + "assert not hasattr(logging.getLogger('dependency'), 'rich')\n" + ) - assert registered == [shutdown_logger] - if register_threading_atexit is None: - assert thread_registered == [] - else: - assert thread_registered == [shutdown_logger] + result = subprocess.run( + [sys.executable, str(script)], capture_output=True, text=True, timeout=20 + ) + assert result.returncode == 0, result.stderr -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_level_debug(logger, buffer): - logger.info("Hello, %s!", "world") - logger.debug("Debug, world!") - shutdown_logger() - lines = [line for line in buffer.getvalue().splitlines() if line.strip()] - assert "Hello, world!" in lines[0] - assert "Debug, world!" in lines[1] +def test_get_logger_returns_distinct_adapters_for_stdlib_logger(): + first = get_logger("tests.identity") + second = get_logger("tests.identity") + stdlib = logging.getLogger("tests.identity") + assert isinstance(first, LogurichLogger) + assert not isinstance(first, logging.Logger) + assert first is not second + assert first.logger is second.logger is stdlib + assert not hasattr(stdlib, "bind") + assert "BoundLogger" not in logurich.__all__ + assert not hasattr(logurich, "BoundLogger") -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_global_context_configure(logger, buffer): - with global_context_configure(exec_id=ctx("id_123", style="yellow")): - logger.info("Hello, world!") - logger.debug("Debug, world!") + +def test_logger_obtained_before_init_is_silent_at_info(capsys): + shutdown_logger() + get_logger("tests.before-init").info("must stay silent") + captured = capsys.readouterr() + assert "must stay silent" not in captured.out + assert "must stay silent" not in captured.err + + +def test_stdlib_properties_and_methods_are_delegated(): + logger = get_logger("tests.delegate") + handler = logging.NullHandler() + original_level = logger.level + original_propagate = logger.propagate + original_disabled = logger.disabled + try: + logger.setLevel(logging.ERROR) + logger.addHandler(handler) + logger.propagate = False + logger.disabled = True + + assert logger.name == "tests.delegate" + assert logger.level == logging.ERROR + assert handler in logger.handlers + assert logger.propagate is False + assert logger.disabled is True + assert not logger.isEnabledFor(logging.INFO) + finally: + logger.removeHandler(handler) + logger.level = original_level + logger.propagate = original_propagate + logger.disabled = original_disabled + + +def test_raw_context_displays_its_key(logger, buffer): + logger.info("Login", user="alice") shutdown_logger() - assert all("id_123" in line for line in buffer.getvalue().splitlines() if line) + assert "[user=alice] Login" in buffer.getvalue() -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}], - indirect=True, -) -def test_global_context_configure_restores_previous(logger, buffer): - with global_context_configure(exec_id=ctx("outer_ctx", style="yellow")): - logger.info("outer message") - with global_context_configure(exec_id=ctx("inner_ctx", style="cyan")): - logger.info("inner message") - logger.info("outer message again") - logger.info("plain message") +def test_ctx_can_hide_or_rename_key(logger, buffer): + logger.info("hidden", user=ctx("alice", show_key=False)) + logger.info("renamed", user=ctx("bob", label="account")) shutdown_logger() - log_lines = [line for line in buffer.getvalue().splitlines() if line.strip()] - assert "outer_ctx" in log_lines[0] - assert "inner_ctx" not in log_lines[0] - assert "inner_ctx" in log_lines[1] - assert "outer_ctx" not in log_lines[1] - assert "outer_ctx" in log_lines[2] - assert "inner_ctx" not in log_lines[2] - assert "outer_ctx" not in log_lines[3] - assert "inner_ctx" not in log_lines[3] + output = buffer.getvalue() + assert "[alice] hidden" in output + assert "[account=bob] renamed" in output -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_logger_contextualize_alias(logger, buffer): - with logger.contextualize(exec_id=logger.ctx("id_123", style="yellow")): - logger.info("Hello, world!") - logger.debug("Debug, world!") +def test_ctx_displays_its_key_like_raw_values(logger, buffer): + logger.info("Login", user=ctx("alice")) shutdown_logger() - assert all("id_123" in line for line in buffer.getvalue().splitlines() if line) + assert "[user=alice] Login" in buffer.getvalue() -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_per_call_context_via_extra(logger, buffer): - logger.info( - "bound message", - extra={ - "context": { - "session": ctx("sess-42", style="cyan", show_key=True), - } - }, - ) +@pytest.mark.parametrize("enqueue", [False, True]) +def test_context_priority_global_bound_call(enqueue, buffer): + init_logger("INFO", enqueue=enqueue) + logger = get_logger("tests.priority").bind(value="bound") + + with logger.contextualize(value="global", scope="request"): + logger.info("winner", value="call") shutdown_logger() + output = buffer.getvalue() - assert "session=sess-42" in output + assert "value=call" in output + assert "value=bound" not in output + assert "value=global" not in output + assert "scope=request" in output + + +def test_nested_context_restores_outer_value(logger, buffer): + with global_context_configure(request="outer"): + logger.info("first") + with global_context_configure(request="inner"): + logger.info("second") + logger.info("third") + logger.info("fourth") + shutdown_logger() + lines = [line for line in buffer.getvalue().splitlines() if line] + assert "request=outer" in lines[0] + assert "request=inner" in lines[1] + assert "request=outer" in lines[2] + assert "request=" not in lines[3] -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_global_context_set(logger, buffer): - global_context_set(exec_id=ctx("id_123", style="yellow")) - logger.info("Hello, world!") - logger.debug("Debug, world!") + +def test_global_context_set_and_none_are_values(logger, buffer): + global_context_set(result=None) + logger.info("done") shutdown_logger() - assert all("id_123" in line for line in buffer.getvalue().splitlines() if line) + assert "result=None" in buffer.getvalue() -def test_logger_ctx_matches_module_helper(): - named_logger = logging.getLogger("tests.ctx") - assert named_logger.ctx("demo", style="yellow", show_key=True) == ctx( - "demo", style="yellow", show_key=True - ) +def test_context_removal_api_is_public(): + assert logurich.global_context_unset is global_context_unset + assert logurich.clear_context is clear_context + assert {"global_context_unset", "clear_context"} <= set(logurich.__all__) -def test_public_package_does_not_export_logger(): - import logurich +def test_global_context_unset_removes_only_requested_keys(logger, buffer): + global_context_set(request="req-1", tenant="acme", result=None) + global_context_unset("request", "missing") + logger.info("done") + shutdown_logger() - assert "logger" not in logurich.__all__ - assert not hasattr(logurich, "logger") + output = buffer.getvalue() + assert "request=req-1" not in output + assert "tenant=acme" in output + assert "result=None" in output -def test_named_logger_ctx_value_renders(buffer): - init_logger("INFO", enqueue=False) - - named_logger = logging.getLogger("pkg.worker") - named_logger.info( - "named logger context", - extra={ - "context": { - "module": named_logger.ctx("MDI-API", style="yellow", show_key=True), - } - }, - ) +def test_clear_context_removes_all_ambient_context(logger, buffer): + global_context_set(request="req-1", tenant="acme") + clear_context() + logger.info("done") shutdown_logger() output = buffer.getvalue() - assert "module=MDI-API" in output + assert "request=req-1" not in output + assert "tenant=acme" not in output -def test_root_logger_ctx_value_renders(buffer): - init_logger("INFO", enqueue=False) +def test_binding_is_immutable_and_always_returns_same_type(): + logger = get_logger("tests.bind") + first = logger.bind(app="api", result=None) + second = first.bind(request="req-1") - root_logger = logging.getLogger() - root_logger.info( - "root logger context", - extra={ - "context": { - "app": root_logger.ctx("root-app", style="cyan", show_key=True), - } - }, - ) - shutdown_logger() + assert type(first) is LogurichLogger + assert type(second) is LogurichLogger + assert logger._bound_context == {} + assert set(first._bound_context) == {"app", "result"} + assert set(second._bound_context) == {"app", "result", "request"} - output = buffer.getvalue() - assert "app=root-app" in output +def test_new_unbind_and_try_unbind(): + logger = get_logger("tests.binding-ops").bind(app="api", request="req-1") + + assert set(logger.unbind("request")._bound_context) == {"app"} + assert set(logger.try_unbind("missing", "request")._bound_context) == {"app"} + assert set(logger.new(user="alice")._bound_context) == {"user"} + with pytest.raises(KeyError, match="missing"): + logger.unbind("missing") -def test_root_logger_contextualize(buffer): - init_logger("INFO", enqueue=False) - root_logger = logging.getLogger() - with root_logger.contextualize( - app=root_logger.ctx("root-app", style="cyan", show_key=True) - ): - root_logger.info("root logger context") +def test_two_bound_loggers_do_not_share_context(logger, buffer): + logger.bind(worker="one").info("first") + logger.bind(worker="two").info("second") shutdown_logger() + lines = [line for line in buffer.getvalue().splitlines() if line] + assert "worker=one" in lines[0] and "worker=two" not in lines[0] + assert "worker=two" in lines[1] and "worker=one" not in lines[1] + + +def test_context_key_can_match_log_record_attribute(logger, buffer): + logger.info("safe", name="context-name", levelno=999, message="context-message") + shutdown_logger() output = buffer.getvalue() - assert "app=root-app" in output + assert "name=context-name" in output + assert "levelno=999" in output + assert "message=context-message" in output + + +@pytest.mark.parametrize("key", ["context", "renderables"]) +def test_legacy_logurich_extra_is_rejected(key): + logger = get_logger("tests.legacy-extra") + with pytest.raises(TypeError, match="no longer supported"): + logger.info("legacy", extra={key: {"value": 1}}) + + +def test_renderables_keyword_points_at_rich(): + logger = get_logger("tests.renderables-kwarg") + with pytest.raises(TypeError, match="logger.rich"): + logger.info("summary", renderables=("panel",)) @pytest.mark.parametrize( - "enqueue", - [False, True], + "key", ["end", "render_prefix", "render_width", "rich_highlight"] ) -def test_logurich_serialize_env(monkeypatch, enqueue, buffer): - monkeypatch.setenv("LOGURICH_SERIALIZE", "1") - monkeypatch.setenv("LOGURICH_EXTRA_APP", "serialize-test") - init_logger("DEBUG", enqueue=enqueue) - logging.getLogger("serialize.test").info( - "Serialized %s", - "output", - extra={"context": {"request_id": ctx("req-42")}}, - ) +def test_rendering_option_names_are_plain_context(logger, buffer, key): + logger.info("job", **{key: "value"}) shutdown_logger() + output = buffer.getvalue() + assert f"{key}=value" in output + assert output.endswith("\n") - log_lines = [line for line in buffer.getvalue().splitlines() if line.strip()] - assert log_lines, "No serialized output captured" - payload = json.loads(log_lines[0]) - assert "Serialized output" in payload["text"] - assert payload["text"].endswith("\n") - assert payload["record"]["message"] == "Serialized output" - assert payload["record"]["level"]["name"] == "INFO" - assert payload["record"]["level"]["no"] == logging.INFO - assert payload["record"]["name"] == "serialize.test" - assert payload["record"]["extra"]["APP"] == "serialize-test" - assert payload["record"]["extra"]["request_id"] == "req-42" - assert payload["record"]["exception"] is None - - -def test_logurich_serialize_rich_payload_goes_to_text(monkeypatch, buffer): - monkeypatch.setenv("LOGURICH_SERIALIZE", "1") - init_logger("INFO", enqueue=False) - logging.getLogger("serialize.rich").rich( - "INFO", - Panel("Panel content", border_style="green"), - title="Rich payload", - ) +@pytest.mark.parametrize( + ("key", "suggestion"), + [("exc_inf", "exc_info"), ("stack_level", "stacklevel"), ("extra_", "extra")], +) +def test_misspelled_logging_keyword_warns(logger, buffer, key, suggestion): + with pytest.warns(UserWarning, match=f"did you mean {suggestion!r}"): + logger.info("typo", **{key: 1}) shutdown_logger() + assert f"{key}=1" in buffer.getvalue() - payload = json.loads(buffer.getvalue().splitlines()[0]) - assert "Rich payload" in payload["text"] - assert "Panel content" in payload["text"] - assert payload["record"]["message"].startswith("Rich payload\n") - assert "Panel content" in payload["record"]["message"] - assert not payload["record"]["message"].endswith("\n") - assert "rendered" not in payload["record"] +@pytest.mark.parametrize("key", ["user", "request_id", "duration", "endpoint"]) +def test_plain_context_keys_do_not_warn(logger, key): + with warnings.catch_warnings(): + warnings.simplefilter("error") + logger.info("clean", **{key: "value"}) + shutdown_logger() -def test_logurich_serialize_exception_object(monkeypatch, buffer): - monkeypatch.setenv("LOGURICH_SERIALIZE", "1") - init_logger("INFO", enqueue=False) - - try: - raise ValueError("boom") - except ValueError: - logging.getLogger("serialize.exc").exception("Failed") +def test_regular_extra_mapping_is_copied_and_rendered(logger, buffer): + extra = {"request_id": "req-42"} + logger.info("request", extra=extra) shutdown_logger() - - payload = json.loads(buffer.getvalue().splitlines()[0]) - assert payload["record"]["exception"]["type"] == "ValueError" - assert payload["record"]["exception"]["value"] == "boom" - assert "ValueError: boom" in payload["record"]["exception"]["traceback"] - assert "Failed" in payload["text"] + assert extra == {"request_id": "req-42"} + assert "request_id=req-42" in buffer.getvalue() -def test_logurich_serialize_stdlib_extra_payload(monkeypatch, buffer): - monkeypatch.setenv("LOGURICH_SERIALIZE", "1") +def test_third_party_stdlib_extra_is_flat_context(buffer): init_logger("INFO", enqueue=False) - - logging.getLogger("serialize.extra").info( - "Serialized extra", - extra={"user": "alice", "action": "test"}, + logging.getLogger("third.party").info( + "dependency", + extra={"request_id": "req-42", "context": {"legacy": True}}, ) shutdown_logger() - payload = json.loads(buffer.getvalue().splitlines()[0]) - assert payload["record"]["message"] == "Serialized extra" - assert payload["record"]["extra"]["user"] == "alice" - assert payload["record"]["extra"]["action"] == "test" - + output = buffer.getvalue() + assert "request_id=req-42" in output + assert "context={'legacy': True}" in output -def test_single_line_messages_preserve_full_text_with_soft_wrap(buffer): - rich_configure_console(file=buffer, width=65) - init_logger("INFO", enqueue=False) - logging.getLogger("api").info( - "Handling request", - extra={ - "context": {"API": ctx("API"), "request_id": ctx("req-99", show_key=True)} - }, - ) +def test_custom_numeric_level(logger, buffer): + logging.addLevelName(35, "NOTICE") + logger.log(35, "custom") shutdown_logger() + assert "NOTICE" in buffer.getvalue() + assert "custom" in buffer.getvalue() - lines = [line for line in buffer.getvalue().splitlines() if line.strip()] - assert len(lines) == 1 - assert "Handling request" in lines[0] - assert "…" not in lines[0] +def test_standard_stacklevel_points_to_caller(buffer): + init_logger("INFO", console="json", enqueue=False) + logger = get_logger("tests.standard-stacklevel") + expected_line = inspect.currentframe().f_lineno + 1 + logger.info("source") + shutdown_logger() -def test_level_by_module_filters_named_loggers(buffer): - init_logger( - "INFO", - enqueue=False, - level_by_module={"pkg.worker": "DEBUG"}, - ) - worker_logger = logging.getLogger("pkg.worker") - other_logger = logging.getLogger("pkg.other") + payload = json.loads(buffer.getvalue().splitlines()[0]) + assert payload["record"]["line"] == expected_line + assert payload["record"]["function"] == "test_standard_stacklevel_points_to_caller" - worker_logger.debug("worker debug") - other_logger.debug("other debug") - worker_logger.info("worker info") + +@pytest.mark.parametrize("enqueue", [False, True]) +def test_exception_and_stack_info(enqueue, buffer): + init_logger("INFO", enqueue=enqueue) + logger = get_logger("tests.exception") + try: + raise ValueError("boom") + except ValueError: + logger.exception("failed") + logger.info("stack", stack_info=True) shutdown_logger() output = buffer.getvalue() - assert "worker debug" in output - assert "worker info" in output - assert "other debug" not in output + assert "ValueError: boom" in output + assert "Stack (most recent call last)" in output @pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, + ("console", "file_mode", "terminal", "expected"), + [ + ("PLAIN", "JSON", True, ("plain", "json")), + ("auto", "text", True, ("plain", "text")), + ("auto", "text", False, ("json", "text")), + ("rich", "json", False, ("rich", "json")), + ], ) -def test_exception_logging_preserves_traceback(logger, buffer): - try: - raise ZeroDivisionError("boom") - except ZeroDivisionError: - logger.exception("Computation failed") - - shutdown_logger() - output = buffer.getvalue() - assert "Computation failed" in output - assert "ZeroDivisionError" in output +def test_resolve_output_modes(console, file_mode, terminal, expected): + modes = core._resolve_output_modes( + console=console, file=file_mode, is_terminal=terminal, env={} + ) + assert (modes.console, modes.file) == expected -# ── bind() tests ───────────────────────────────────────────────────── +def test_output_mode_environment_override(): + modes = core._resolve_output_modes( + console="plain", + file="json", + is_terminal=False, + env={"LOGURICH_OUTPUT": "RICH"}, + ) + assert (modes.console, modes.file) == ("rich", "json") -def test_bind_returns_bound_logger(buffer): - init_logger("INFO", enqueue=False) - named_logger = logging.getLogger("tests.bind") - bound = named_logger.bind(module=ctx("PM-API", style="magenta")) - assert isinstance(bound, BoundLogger) - # Original logger is not a BoundLogger (not mutated). - assert not isinstance(named_logger, BoundLogger) - shutdown_logger() +def test_output_mode_environment_override_falls_back_when_invalid(): + with pytest.warns(UserWarning, match="LOGURICH_OUTPUT"): + modes = core._resolve_output_modes( + console="plain", + file="text", + is_terminal=True, + env={"LOGURICH_OUTPUT": "nope"}, + ) + assert (modes.console, modes.file) == ("plain", "text") @pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, + "kwargs", + [ + {"console": "invalid", "file": "text"}, + {"console": "plain", "file": "invalid"}, + ], ) -def test_bind_context_appears_in_logs(logger, buffer): - bound = logger.bind(module=ctx("PM-API", style="magenta", show_key=True)) - bound.info("bound message") +def test_resolve_output_modes_rejects_invalid_values(kwargs): + with pytest.raises(ValueError): + core._resolve_output_modes(is_terminal=True, env={}, **kwargs) + + +def test_public_output_choices(): + assert CONSOLE_MODE_CHOICES == ("auto", "rich", "plain", "json") + assert FILE_MODE_CHOICES == ("text", "json") + assert not hasattr(logurich, "OutputModes") + assert not hasattr(logurich, "resolve_output_modes") + + +@pytest.mark.parametrize("legacy", ["LOGURICH_RICH", "LOGURICH_SERIALIZE"]) +def test_init_warns_and_ignores_legacy_environment(monkeypatch, buffer, legacy): + monkeypatch.setenv(legacy, "1") + with pytest.warns(UserWarning, match="LOGURICH_OUTPUT"): + init_logger("INFO", enqueue=False) + logger = get_logger("tests.legacy-env") + logger.info("hello") shutdown_logger() output = buffer.getvalue() - assert "module=PM-API" in output + assert "hello" in output + assert not output.lstrip().startswith("{") -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_bind_chaining(logger, buffer): - bound = logger.bind(app=ctx("myapp", style="green", show_key=True)).bind( - module=ctx("PM-API", style="magenta", show_key=True) - ) - bound.info("chained") +def test_removed_rich_handler_parameter_is_rejected(): + with pytest.raises(TypeError, match="rich_handler"): + init_logger("INFO", rich_handler=True) # type: ignore[call-arg] + + +def test_json_console_contract(buffer): + init_logger("INFO", console="json", enqueue=False) + get_logger("tests.json").info("Login %s", "ok", user=ctx("alice", style="red")) shutdown_logger() - output = buffer.getvalue() - assert "app=myapp" in output - assert "module=PM-API" in output + + payload = json.loads(buffer.getvalue().splitlines()[0]) + assert payload["record"]["message"] == "Login ok" + assert payload["record"]["extra"]["user"] == "alice" + assert payload["record"]["level"] == {"name": "INFO", "no": logging.INFO} + assert "\x1b[" not in json.dumps(payload) @pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, + ("console_mode", "file_mode"), + [("rich", "json"), ("plain", "json"), ("json", "text")], ) -def test_bind_per_call_overrides_bound(logger, buffer): - bound = logger.bind(module=ctx("PM-API", style="magenta", show_key=True)) - bound.info( - "overridden", - extra={"context": {"module": ctx("OVERRIDE", style="cyan", show_key=True)}}, +def test_console_and_file_modes_are_independent( + console_mode, file_mode, tmp_path, buffer +): + init_logger( + "INFO", + console=console_mode, + file=file_mode, + log_filename="app.log", + log_folder=str(tmp_path), + rotation=None, + enqueue=False, ) + get_logger("tests.outputs").info("output", user="alice") shutdown_logger() - output = buffer.getvalue() - assert "module=OVERRIDE" in output - assert "PM-API" not in output + file_text = (tmp_path / "app.log").read_text() + if file_mode == "json": + assert json.loads(file_text)["record"]["extra"]["user"] == "alice" + else: + assert "output" in file_text + assert not file_text.lstrip().startswith("{") + assert "\x1b[" not in file_text -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}], - indirect=True, -) -def test_bind_with_global_context(logger, buffer): - with global_context_configure( - global_key=ctx("global_val", style="yellow", show_key=True) - ): - bound = logger.bind(bound_key=ctx("bound_val", style="cyan", show_key=True)) - bound.info("merged") + +def test_init_is_idempotent_and_force_reconfigures(buffer): + init_logger("ERROR", enqueue=False) + assert init_logger("DEBUG", enqueue=False) is None + get_logger("tests.force").info("still filtered") + + init_logger("DEBUG", enqueue=False, force=True) + get_logger("tests.force").debug("now visible") shutdown_logger() + output = buffer.getvalue() - assert "global_key=global_val" in output - assert "bound_key=bound_val" in output + assert "still filtered" not in output + assert "now visible" in output -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_bound_logger_contextualize_with_bound_context(logger, buffer): - bound = logger.bind(module=ctx("PM-API", style="magenta", show_key=True)) +def test_context_is_isolated_from_new_thread(logger, buffer): + with global_context_configure(request="main"): + thread = threading.Thread(target=lambda: logger.info("thread")) + thread.start() + thread.join() + logger.info("main") + shutdown_logger() - with bound.contextualize( - request_id=bound.ctx("req-42", style="cyan", show_key=True) - ): - bound.info("merged") + lines = [line for line in buffer.getvalue().splitlines() if line] + assert "request=main" not in next(line for line in lines if "thread" in line) + assert "request=main" in next(line for line in lines if " main" in line) + + +def test_context_is_propagated_to_asyncio_tasks(logger, buffer): + async def child() -> None: + logger.info("async child") + + async def run() -> None: + with global_context_configure(request="async"): + await asyncio.create_task(child()) + + asyncio.run(run()) shutdown_logger() + assert "request=async" in buffer.getvalue() - output = buffer.getvalue() - assert "module=PM-API" in output - assert "request_id=req-42" in output +def test_concurrent_bound_loggers_do_not_leak(logger, buffer): + barrier = threading.Barrier(3) -def test_bound_logger_ctx_method(buffer): - init_logger("INFO", enqueue=False) - bound = logging.getLogger("tests.bound.ctx").bind( - module=ctx("PM-API", style="magenta") - ) - assert bound.ctx("demo", style="yellow", show_key=True) == ctx( - "demo", style="yellow", show_key=True - ) + def emit(worker: str) -> None: + bound = logger.bind(worker=worker) + barrier.wait() + bound.info("from-%s", worker) + + threads = [threading.Thread(target=emit, args=(worker,)) for worker in ("a", "b")] + for thread in threads: + thread.start() + barrier.wait() + for thread in threads: + thread.join() shutdown_logger() + lines = [line for line in buffer.getvalue().splitlines() if line] + line_a = next(line for line in lines if "from-a" in line) + line_b = next(line for line in lines if "from-b" in line) + assert "worker=a" in line_a and "worker=b" not in line_a + assert "worker=b" in line_b and "worker=a" not in line_b -def test_bound_logger_rich_method(buffer): - init_logger("INFO", enqueue=False) - bound = logging.getLogger("tests.bound.rich").bind( - module=ctx("PM-API", style="magenta") + +def test_init_logger_registers_shutdown_hooks_once(monkeypatch): + registered: list[object] = [] + thread_registered: list[object] = [] + + monkeypatch.setitem(logger_state, "atexit_registered", False) + monkeypatch.setitem(logger_state, "threading_atexit_registered", False) + monkeypatch.setattr("logurich.core.atexit.register", registered.append) + monkeypatch.setattr( + "logurich.core.threading._register_atexit", thread_registered.append ) - bound.rich("INFO", Panel("Rich from bound logger"), title="bound-rich") + + init_logger("INFO", enqueue=False) + shutdown_logger() + init_logger("INFO", enqueue=False) shutdown_logger() - output = buffer.getvalue() - assert "Rich from bound logger" in output + assert registered == [shutdown_logger] + assert thread_registered == [shutdown_logger] -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_bind_process_copies_reused_extra_mapping(logger): - shared_extra = {"context": {}} - bound_app = logger.bind(app=ctx("myapp", style="green", show_key=True)) - _, first_kwargs = bound_app.process("first", {"extra": shared_extra}) +@pytest.mark.parametrize("enqueue", [False, True]) +def test_caller_attribution_points_at_user_frame(enqueue, buffer): + init_logger("DEBUG", console="json", enqueue=enqueue) + logger = get_logger("tests.caller") + expected: dict[str, int] = {} - bound_module = logger.bind(module=ctx("PM-API", style="magenta", show_key=True)) - _, second_kwargs = bound_module.process("second", {"extra": shared_extra}) + expected["info-call"] = inspect.currentframe().f_lineno + 1 + logger.info("info-call") - assert first_kwargs["extra"] is not shared_extra - assert second_kwargs["extra"] is not shared_extra - assert shared_extra == {"context": {}} - assert set(first_kwargs["extra"]["context"]) == {"context::app"} - assert set(second_kwargs["extra"]["context"]) == {"context::module"} + expected["warning-call"] = inspect.currentframe().f_lineno + 1 + logger.warning("warning-call") + try: + raise ValueError("boom") + except ValueError: + expected["exception-call"] = inspect.currentframe().f_lineno + 1 + logger.exception("exception-call") -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_bind_process_accepts_extra_none(logger): - bound = logger.bind(module=ctx("PM-API", style="magenta", show_key=True)) + expected["rich-call"] = inspect.currentframe().f_lineno + 1 + logger.rich("INFO", "body", title="rich-call") - _, kwargs = bound.process("message", {"extra": None}) + bound = logger.bind(a=1).bind(b=2) + expected["bound-call"] = inspect.currentframe().f_lineno + 1 + bound.info("bound-call") - assert kwargs["extra"]["context"] == { - "context::module": ctx("PM-API", style="magenta", show_key=True) - } + shutdown_logger() + records = {} + for line in buffer.getvalue().splitlines(): + if not line.strip(): + continue + record = json.loads(line)["record"] + records[record["message"].splitlines()[0]] = record -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], - indirect=True, -) -def test_bind_process_copies_read_only_extra_mapping(logger): - bound = logger.bind(module=ctx("PM-API", style="magenta", show_key=True)) - read_only_extra = MappingProxyType( - { - "context": {"request_id": ctx("req-42", style="cyan", show_key=True)}, - "user": "alice", - } - ) + assert set(records) == set(expected) + for message, line_no in expected.items(): + record = records[message] + assert record["function"] == "test_caller_attribution_points_at_user_frame" + assert record["line"] == line_no + assert record["file"]["path"] == str(Path(__file__)) - _, kwargs = bound.process("message", {"extra": read_only_extra}) - assert kwargs["extra"] is not read_only_extra - assert kwargs["extra"]["user"] == "alice" - assert kwargs["extra"]["context"] == { - "context::module": ctx("PM-API", style="magenta", show_key=True), - "request_id": ctx("req-42", style="cyan", show_key=True), - } +def test_version_matches_project_metadata(): + pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml" + assert f'version = "{logurich.__version__}"' in pyproject.read_text() diff --git a/tests/test_mp.py b/tests/test_mp.py index 4010026..b047e9d 100644 --- a/tests/test_mp.py +++ b/tests/test_mp.py @@ -11,15 +11,19 @@ from rich.panel import Panel from rich.table import Table +import logurich.core as core from logurich import ( configure_child_logging, ctx, + get_log_levels, get_log_queue, + get_logger, global_context_configure, init_logger, rich_get_console, shutdown_logger, ) +from logurich.struct import logger_state def worker_process(queue): @@ -40,10 +44,7 @@ def worker_with_rich_logging(queue): table.add_column("Column 1") table.add_column("Column 2") table.add_row("Value 1", "Value 2") - logging.getLogger("workers.rich").info( - "Rich Test", - extra={"renderables": (panel, table)}, - ) + get_logger("workers.rich").rich("INFO", panel, table, title="Rich Test") def test_configure_child_logging_routes_records_to_parent(buffer): @@ -94,6 +95,193 @@ def test_rich_logging_in_child_process(buffer): assert "Rich Test" in output +def test_child_adopts_parent_levels_with_spawn(tmp_path): + repo_root = Path(__file__).resolve().parents[1] + script_path = tmp_path / "spawn_child_levels.py" + script_path.write_text( + textwrap.dedent( + """ + import logging + import multiprocessing as mp + + from logurich import ( + configure_child_logging, + get_log_levels, + get_log_queue, + get_logger, + init_logger, + shutdown_logger, + ) + + + def worker_reports_level(queue, levels): + configure_child_logging(queue, levels=levels) + log = get_logger("workers.level") + log.info( + "effective=%s debug_enabled=%s", + logging.getLogger().getEffectiveLevel(), + log.isEnabledFor(logging.DEBUG), + ) + log.debug("this debug must not reach the parent") + + + def main(): + init_logger("INFO", enqueue=True) + log_queue = get_log_queue() + try: + process = mp.Process( + target=worker_reports_level, + args=(log_queue, get_log_levels()), + ) + process.start() + process.join() + assert process.exitcode == 0 + finally: + shutdown_logger() + + + if __name__ == "__main__": + mp.set_start_method("spawn", force=True) + main() + """ + ) + ) + env = os.environ.copy() + pythonpath = str(repo_root / "src") + if env.get("PYTHONPATH"): + pythonpath = f"{pythonpath}{os.pathsep}{env['PYTHONPATH']}" + env["PYTHONPATH"] = pythonpath + + result = subprocess.run( + [sys.executable, str(script_path)], + cwd=repo_root, + capture_output=True, + text=True, + env=env, + timeout=30, + ) + + assert result.returncode == 0, result.stderr or result.stdout + assert "effective=20 debug_enabled=False" in result.stdout + assert "must not reach the parent" not in result.stdout + + +def test_child_levels_default_to_notset_without_explicit_levels(): + child_root = logging.getLogger("tests.child-levels-fallback") + core._apply_child_levels(None, child_root) + + assert child_root.level == logging.NOTSET + assert logger_state["min_level"] is None + shutdown_logger() + + +def test_child_root_level_is_a_permissive_floor(): + child_root = logging.getLogger("tests.child-levels-floor") + core._apply_child_levels((logging.WARNING, {"noisy": logging.DEBUG}), child_root) + + assert child_root.level == logging.DEBUG + assert logger_state["min_level"] == logging.WARNING + assert logger_state["level_by_module"] == {"noisy": logging.DEBUG} + shutdown_logger() + + +def test_get_log_levels_returns_a_detached_copy(): + init_logger("WARNING", level_by_module={"noisy": "DEBUG"}, enqueue=True) + levels = get_log_levels() + shutdown_logger() + + assert levels == (logging.WARNING, {"noisy": logging.DEBUG}) + assert levels[1] is not logger_state["level_by_module"] + + +def test_get_log_levels_requires_initialization(): + with pytest.raises(RuntimeError, match="init_logger"): + get_log_levels() + + +def test_unpickleable_rich_value_falls_back_to_text(buffer): + init_logger("INFO", enqueue=True) + get_logger("tests.queue-fallback").rich( + "INFO", lambda: "not pickleable", title="Fallback" + ) + shutdown_logger() + + output = buffer.getvalue() + assert "Fallback" in output + assert "lambda" in output + + +def test_plain_record_skips_pickle_validation(monkeypatch): + record = logging.LogRecord( + "tests.queue-plain-fast-path", + logging.INFO, + __file__, + 1, + "Ready", + (), + None, + ) + + def fail_pickle(_value): + raise AssertionError("plain records should not be preflight pickled") + + monkeypatch.setattr(core.pickle, "dumps", fail_pickle) + + prepared = core._LogurichQueueHandler(None).prepare(record) + + assert prepared.message == "Ready" + assert prepared.context == {} + assert prepared.renderables == () + + +def test_pickleable_renderables_use_one_validation_pass(monkeypatch): + panel = Panel("ready") + record = logging.LogRecord( + "tests.queue-fast-path", + logging.INFO, + __file__, + 1, + "Ready", + (), + None, + ) + record._logurich_record = True + record._logurich_metadata = ("renderables",) + record.renderables = (panel,) + + dumped: list[object] = [] + pickle_dumps = core.pickle.dumps + + def track_pickle(value): + dumped.append(value) + return pickle_dumps(value) + + monkeypatch.setattr(core.pickle, "dumps", track_pickle) + + prepared = core._LogurichQueueHandler(None).prepare(record) + + assert dumped == [prepared] + assert prepared.renderables == (panel,) + + +@pytest.mark.parametrize("raise_exceptions", [False, True]) +def test_unpickleable_context_uses_stdlib_error_policy( + monkeypatch, capsys, buffer, raise_exceptions +): + monkeypatch.setattr(logging, "raiseExceptions", raise_exceptions) + init_logger("INFO", enqueue=True) + get_logger("tests.queue-context").info("bad", callback=lambda: None) + shutdown_logger() + + captured = capsys.readouterr() + assert "bad" not in buffer.getvalue() + if raise_exceptions: + assert "--- Logging error ---" in captured.err + assert "serialisable context values" in captured.err + else: + assert captured.err == "" + + def test_interpreter_exit_stops_queue_listener_without_thread_error(tmp_path): repo_root = Path(__file__).resolve().parents[1] script_path = tmp_path / "queue_listener_exit.py" @@ -148,14 +336,15 @@ def test_spawn_pool_initializer_can_configure_child_logging(tmp_path): from logurich import ( configure_child_logging, + get_log_levels, get_log_queue, init_logger, shutdown_logger, ) - def init_worker(log_queue): - configure_child_logging(log_queue) + def init_worker(log_queue, log_levels): + configure_child_logging(log_queue, levels=log_levels) def process_item(item): @@ -170,7 +359,7 @@ def main(): with mp.Pool( processes=2, initializer=init_worker, - initargs=(log_queue,), + initargs=(log_queue, get_log_levels()), ) as pool: results = pool.map(process_item, [1, 2, 3]) assert results == [2, 4, 6] diff --git a/tests/test_opt_click.py b/tests/test_opt_click.py index 3baa2d4..feafd3a 100644 --- a/tests/test_opt_click.py +++ b/tests/test_opt_click.py @@ -14,7 +14,7 @@ def test_click_logger_init_registers_atexit_shutdown(monkeypatch): monkeypatch.setitem(logger_state, "atexit_registered", False) monkeypatch.setattr("logurich.core.atexit.register", registered.append) - click_logger_init("INFO", 0, None, (), False) + click_logger_init("INFO", 0, None, (), "plain") shutdown_logger() assert registered.count(shutdown_logger) == 1 @@ -22,8 +22,12 @@ def test_click_logger_init_registers_atexit_shutdown(monkeypatch): def test_click_logger_params_registers_context_shutdown(monkeypatch): shutdown_calls: list[str] = [] + init_calls: list[dict[str, object]] = [] - monkeypatch.setattr("logurich.opt_click.init_logger", lambda *args, **kwargs: None) + def fake_init(*args, **kwargs): + init_calls.append(kwargs) + + monkeypatch.setattr("logurich.opt_click.init_logger", fake_init) monkeypatch.setattr( "logurich.opt_click.shutdown_logger", lambda: shutdown_calls.append("called") ) @@ -33,7 +37,67 @@ def test_click_logger_params_registers_context_shutdown(monkeypatch): def cli() -> None: return None - result = CliRunner().invoke(cli, []) + result = CliRunner().invoke(cli, ["--logger-console", "RICH"]) assert result.exit_code == 0 + assert init_calls[0]["console"] == "rich" assert shutdown_calls == ["called"] + + +def test_click_console_default_is_plain(monkeypatch): + received: list[str] = [] + + def fake_init(*args, **kwargs): + received.append(kwargs["console"]) + + monkeypatch.setattr("logurich.opt_click.init_logger", fake_init) + + @click.command() + @click_logger_params + def cli() -> None: + return None + + result = CliRunner().invoke(cli, []) + assert result.exit_code == 0 + assert received == ["plain"] + + +def test_click_console_help_documents_environment_precedence(monkeypatch): + monkeypatch.setattr("logurich.opt_click.init_logger", lambda *args, **kwargs: None) + + @click.command() + @click_logger_params + def cli() -> None: + return None + + result = CliRunner().invoke(cli, ["--help"]) + assert result.exit_code == 0 + normalized_help = " ".join(result.output.split()) + assert "LOGURICH_OUTPUT takes precedence" in normalized_help + + +@pytest.mark.parametrize("value", ["auto", "rich", "plain", "json", "RICH"]) +def test_click_console_accepts_public_choices(monkeypatch, value): + monkeypatch.setattr("logurich.opt_click.init_logger", lambda *args, **kwargs: None) + + @click.command() + @click_logger_params + def cli() -> None: + return None + + assert CliRunner().invoke(cli, ["--logger-console", value]).exit_code == 0 + + +def test_click_rejects_invalid_and_removed_options(monkeypatch): + monkeypatch.setattr("logurich.opt_click.init_logger", lambda *args, **kwargs: None) + + @click.command() + @click_logger_params + def cli() -> None: + return None + + runner = CliRunner() + assert runner.invoke(cli, ["--logger-console", "invalid"]).exit_code == 2 + removed = runner.invoke(cli, ["--logger-rich"]) + assert removed.exit_code == 2 + assert "No such option" in removed.output diff --git a/tests/test_rich.py b/tests/test_rich.py index 541c89f..077529c 100644 --- a/tests/test_rich.py +++ b/tests/test_rich.py @@ -1,96 +1,222 @@ +import inspect +import json import logging -import random import re -import string import pytest -from rich.pretty import Pretty - -from logurich import init_logger, shutdown_logger - - -def generate_random_dict(k, depth=3): - if depth <= 1: - return { - "".join(random.choices(string.ascii_letters, k=5)): random.randint(1, 100) - for _ in range(k) - } - return { - "".join(random.choices(string.ascii_letters, k=20)): generate_random_dict( - k, depth - 1 - ) - for _ in range(k) - } - - -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}, {"level": "INFO", "enqueue": True}], - indirect=True, -) -def test_renderables_are_prefixed(logger, buffer): +from rich.logging import RichHandler +from rich.panel import Panel +from rich.table import Table + +from logurich import LogurichLogger, get_logger, init_logger, shutdown_logger + + +def build_table() -> Table: + table = Table(title="Metrics") + table.add_column("Name") + table.add_column("Value") + table.add_row("requests", "42") + return table + + +@pytest.mark.parametrize("enqueue", [False, True]) +def test_rich_accepts_multiple_live_renderables(enqueue, buffer): + init_logger("INFO", enqueue=enqueue) + logger = get_logger("tests.rich") logger.rich( "INFO", - Pretty(generate_random_dict(5, 5), max_length=3, max_depth=3), - title="Rich Test", + Panel("Service ready", border_style="green"), + build_table(), + title="Startup", ) shutdown_logger() - lines = [line for line in buffer.getvalue().splitlines() if line.strip()] - assert lines - for line in lines: - assert re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+ \| INFO", line) + output = buffer.getvalue() + assert "Startup" in output + assert "Service ready" in output + assert "Metrics" in output + assert "requests" in output + + +def test_bound_logger_keeps_rich_method(buffer): + init_logger("INFO", enqueue=False) + logger = get_logger("tests.bound-rich").bind(module="api") + + assert isinstance(logger, LogurichLogger) + logger.rich("INFO", Panel("Ready"), title="Bound") + shutdown_logger() + + output = buffer.getvalue() + assert "module=api" in output + assert "Bound" in output + assert "Ready" in output + + +def test_rich_numeric_custom_level(buffer): + logging.addLevelName(35, "NOTICE") + init_logger("INFO", enqueue=False) + get_logger("tests.rich-level").rich(35, "notice body", title="notice") + shutdown_logger() + + output = buffer.getvalue() + assert "NOTICE" in output + assert "notice body" in output + + +def test_rich_rejects_invalid_level_and_width(): + logger = get_logger("tests.invalid-rich") + with pytest.raises(ValueError, match="Unknown log level"): + logger.rich("UNKNOWN", "body") + with pytest.raises(ValueError, match="width"): + logger.rich("INFO", "body", width=0) -@pytest.mark.parametrize( - "logger", - [{"level": "DEBUG", "enqueue": False}], - indirect=True, -) -def test_render_width_affects_output(logger, buffer): +def test_render_width_affects_output(buffer): + init_logger("INFO", enqueue=False) + logger = get_logger("tests.width") wide_text = " ".join(["wrap"] * 60) logger.rich("INFO", wide_text, title="Wrapped output", width=60) narrow_output = buffer.getvalue() - buffer.truncate(0) buffer.seek(0) - logger.rich("INFO", wide_text, title="Wrapped output", width=110) wide_output = buffer.getvalue() + shutdown_logger() - assert narrow_output - assert wide_output assert narrow_output != wide_output assert "…" in narrow_output assert "…" not in wide_output -def test_named_stdlib_logger_has_rich_method(buffer): +def test_prefix_false_omits_prefix_from_renderable(buffer): + init_logger("INFO", enqueue=False) + get_logger("tests.prefix").rich("INFO", "body-only", prefix=False) + shutdown_logger() + + line = next(line for line in buffer.getvalue().splitlines() if "body-only" in line) + assert line == "body-only" + + +def test_end_is_preserved(buffer): init_logger("INFO", enqueue=False) + logger = get_logger("tests.end") + logger.rich("INFO", "first", prefix=False, end="") + logger.rich("INFO", "second", prefix=False, end="") + shutdown_logger() + assert buffer.getvalue().endswith("firstsecond") + + +def test_end_is_preserved_in_text_file(tmp_path, buffer): + init_logger( + "INFO", + log_filename="end.log", + log_folder=str(tmp_path), + rotation=None, + enqueue=False, + ) + get_logger("tests.file-end").rich("INFO", "tail", prefix=False, end="") + shutdown_logger() + assert (tmp_path / "end.log").read_text().endswith("tail") - named_logger = logging.getLogger("pkg.worker") - assert hasattr(named_logger, "ctx") - assert hasattr(named_logger, "rich") - named_logger.rich("INFO", "named body", title="Named title") +def test_json_console_uses_newline_framing_when_end_is_empty(buffer): + init_logger("INFO", console="json", enqueue=False) + logger = get_logger("tests.json-console-end") + logger.rich("INFO", "first", prefix=False, end="") + logger.rich("INFO", "second", prefix=False, end="") shutdown_logger() output = buffer.getvalue() - assert "Named title" in output - assert "named body" in output + payloads = [json.loads(line) for line in output.splitlines()] + assert output.endswith("\n") + assert len(payloads) == 2 + assert payloads[0]["text"].endswith("first") + assert payloads[1]["text"].endswith("second") + assert not payloads[0]["text"].endswith("\n") + assert not payloads[1]["text"].endswith("\n") -def test_root_logger_has_rich_method(buffer): - init_logger("INFO", enqueue=False) +def test_json_file_uses_newline_framing_when_end_is_empty(tmp_path, buffer): + init_logger( + "INFO", + log_filename="end.jsonl", + log_folder=str(tmp_path), + console="plain", + file="json", + rotation=None, + enqueue=False, + ) + logger = get_logger("tests.json-file-end") + logger.rich("INFO", "first", prefix=False, end="") + logger.rich("INFO", "second", prefix=False, end="") + shutdown_logger() + + output = (tmp_path / "end.jsonl").read_text() + payloads = [json.loads(line) for line in output.splitlines()] + assert output.endswith("\n") + assert len(payloads) == 2 + assert payloads[0]["text"].endswith("first") + assert payloads[1]["text"].endswith("second") + assert not payloads[0]["text"].endswith("\n") + assert not payloads[1]["text"].endswith("\n") + + +def test_rich_stacklevel_points_to_caller(buffer): + init_logger("INFO", console="json", enqueue=False) + logger = get_logger("tests.stacklevel") + expected_line = inspect.currentframe().f_lineno + 1 + logger.rich("INFO", "body", title="source") + shutdown_logger() - root_logger = logging.getLogger() - assert hasattr(root_logger, "ctx") - assert hasattr(root_logger, "rich") + payload = json.loads(buffer.getvalue().splitlines()[0]) + assert payload["record"]["line"] == expected_line + assert payload["record"]["function"] == "test_rich_stacklevel_points_to_caller" - root_logger.rich("INFO", "root body", title="Root title") + +def test_rich_console_mode_uses_rich_handler(buffer): + init_logger("INFO", console="rich", enqueue=False) + get_logger("tests.rich-console").rich( + "INFO", Panel("Live panel", border_style="magenta"), title="Title" + ) shutdown_logger() output = buffer.getvalue() - assert "Root title" in output - assert "root body" in output + assert "Live panel" in output + assert "Title" in output + + +def test_plain_rich_output_has_log_prefix(buffer): + init_logger("INFO", enqueue=False) + get_logger("tests.prefix-format").rich("INFO", "body", title="title") + shutdown_logger() + + lines = [line for line in buffer.getvalue().splitlines() if line] + assert all( + re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+ \| INFO", line) + for line in lines + ) + + +def test_stdlib_loggers_do_not_have_rich_methods(): + assert not hasattr(logging.getLogger("third.party.rich"), "rich") + assert not hasattr(logging.getLogger(), "rich") + + +def test_rich_console_falls_back_to_plain_without_the_rich_layout(monkeypatch, buffer): + """A Rich release dropping ``_log_render`` must not silence logging.""" + + rich_handler_init = RichHandler.__init__ + + def init_without_log_render(self, *args, **kwargs): + rich_handler_init(self, *args, **kwargs) + del self._log_render + + monkeypatch.setattr(RichHandler, "__init__", init_without_log_render) + + with pytest.warns(UserWarning, match="falling back to console='plain'"): + init_logger("INFO", console="rich", enqueue=False) + get_logger("tests.rich-fallback").info("still logging") + shutdown_logger() + + assert "still logging" in buffer.getvalue() diff --git a/tests/test_user_input.py b/tests/test_user_input.py new file mode 100644 index 0000000..139b736 --- /dev/null +++ b/tests/test_user_input.py @@ -0,0 +1,12 @@ +from logurich import init_logger, shutdown_logger, user_input + + +def test_user_input_uses_explicit_logurich_adapter(monkeypatch, buffer): + monkeypatch.setattr("builtins.input", lambda: "alice") + init_logger("INFO", enqueue=False) + + result = user_input("Name") + shutdown_logger() + + assert result == "alice" + assert buffer.getvalue().endswith("Name: ") diff --git a/uv.lock b/uv.lock index dfa8210..1c037a9 100644 --- a/uv.lock +++ b/uv.lock @@ -38,7 +38,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ @@ -47,17 +47,17 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -71,11 +71,11 @@ wheels = [ [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +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/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { 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]] @@ -83,7 +83,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { 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 = [ @@ -104,25 +104,40 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.2" +version = "3.32.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/e0/a75dbe4bca1e7d41307323dad5ea2efdd95408f74ab2de8bd7dba9b51a1a/filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64", size = 19510, upload-time = "2026-01-02T15:33:32.582Z" } +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/9a/30/ab407e2ec752aa541704ed8f93c11e2a5d92c168b8a755d818b74a3c5c2d/filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8", size = 16697, upload-time = "2026-01-02T15:33:31.133Z" }, + { 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" @@ -149,7 +164,7 @@ wheels = [ [[package]] name = "logurich" -version = "0.9.3" +version = "1.0.0b1" source = { editable = "." } dependencies = [ { name = "rich" }, @@ -158,22 +173,22 @@ dependencies = [ [package.optional-dependencies] click = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "click", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] [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.5.1", 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.0.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 = "ruff" }, ] [package.metadata] requires-dist = [ { name = "click", marker = "extra == 'click'", specifier = ">=8.1.8" }, - { name = "rich", specifier = "==14.2.0" }, + { name = "rich", specifier = ">=13.7.0" }, ] provides-extras = ["click"] @@ -192,7 +207,7 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.10'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ @@ -201,17 +216,17 @@ wheels = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.10'" }, + { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -234,11 +249,11 @@ wheels = [ [[package]] name = "packaging" -version = "25.0" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -255,14 +270,14 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, ] [[package]] @@ -282,11 +297,11 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "identify", marker = "python_full_version < '3.10'" }, - { name = "nodeenv", marker = "python_full_version < '3.10'" }, - { name = "pyyaml", marker = "python_full_version < '3.10'" }, - { name = "virtualenv", marker = "python_full_version < '3.10'" }, + { 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 = [ @@ -295,30 +310,30 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.5.1" +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" }, marker = "python_full_version >= '3.10'" }, - { name = "identify", marker = "python_full_version >= '3.10'" }, - { name = "nodeenv", marker = "python_full_version >= '3.10'" }, - { name = "pyyaml", marker = "python_full_version >= '3.10'" }, - { name = "virtualenv", marker = "python_full_version >= '3.10'" }, + { 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/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +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/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { 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.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +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/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { 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]] @@ -329,13 +344,13 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { 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 = [ @@ -344,23 +359,36 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { 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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +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/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { 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 = "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]] @@ -442,7 +470,7 @@ version = "14.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } @@ -452,101 +480,106 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, - { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, - { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +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.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +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/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { 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 = "20.35.4" +version = "21.7.1" 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.20.2", 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.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.11.0", 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/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/18004e5cb15541ad2a68ff219c755233b012b12d4ec8663d06a258082bec/virtualenv-21.7.1.tar.gz", hash = "sha256:d0dbfaa5483487baea28d7210ef8d24c9d1bd0f10f449eeb215568825a9b334e", size = 5525237, upload-time = "2026-07-30T15:40:36.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a7/ded126c19495158a05c7202b3389139839d4cf78d622d453867778e0f7a8/virtualenv-21.7.1-py3-none-any.whl", hash = "sha256:6394973f990536e34c05157179146c020284c42fe01da1dfeb0ba16c345280d9", size = 5504576, upload-time = "2026-07-30T15:40:34.512Z" }, ]