diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36d33a4..3ec3a07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: contents: read strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up uv @@ -23,6 +23,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install deps run: uv sync --group dev + - name: Run ruff + run: uv run ruff check src/logurich - name: Run tests run: uv run -m pytest -q diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f28150..bfe56ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,11 +10,3 @@ repos: hooks: - id: ruff - id: ruff-format - - repo: local - hooks: - - id: uv-pip-compile - name: uv pip compile (requirements.txt) - entry: uv - language: system - files: ^pyproject\.toml$ - args: ["pip", "compile", "pyproject.toml", "-o", "requirements.txt"] diff --git a/AGENTS.md b/AGENTS.md index a5a26eb..c64b40e 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.9–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.10–3.13; wait for green builds before merging. diff --git a/README.md b/README.md index 85f60cf..b13b9f4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # logurich -[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-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/) A Python library combining Loguru and Rich for beautiful logging. @@ -36,6 +37,12 @@ logger.rich( title="Rich Panel", prefix=False, ) + +# Temporarily raise the minimum level +logger.level_set("WARNING") +logger.info("filtered") +logger.warning("visible") +logger.level_restore() ``` ## Click CLI helper diff --git a/examples/base.py b/examples/base.py index 15a1679..dc6b8c4 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, global_configure, init_logger, logger +from logurich import global_context_configure, init_logger, logger logger.info("This is a basic log message") @@ -31,20 +31,31 @@ def create_rich_table(): logger.rich("INFO", "[bold blue]This is a rich formatted log message[/bold blue]") # Use context in logging - with global_configure(app=ctx("example", style="yellow")): + with global_context_configure(app=logger.ctx("example", style="yellow")): logger.info("This log has app context") - with logger.contextualize(user=ctx("test", style="cyan", show_key=True)): + with logger.contextualize(user=logger.ctx("test", style="cyan", show_key=True)): logger.info("ok") # Log with additional context - logger.bind(environment=ctx("demo", style="yellow")).info( + logger.bind(environment=logger.ctx("demo", style="yellow")).info( "This log has module context" ) + # Use logger.ctx() directly (no separate import needed) + logger.bind(session=logger.ctx("sess-42", style="cyan")).info( + "Using logger.ctx() instead of importing ctx" + ) + + # Temporarily raise the minimum level + logger.level_set("WARNING") + logger.info("filtered") + logger.warning("visible") + logger.level_restore() + # Log an exception try: - 1 / 0 + 1 / 0 # noqa: B018 except Exception as e: logger.error("{}", e) # logger.exception("An error occurred: {}", e) diff --git a/examples/mp_adv_data_processing.py b/examples/mp_adv_data_processing.py index 0266fe2..feacefd 100644 --- a/examples/mp_adv_data_processing.py +++ b/examples/mp_adv_data_processing.py @@ -9,12 +9,10 @@ from rich.text import Text from logurich import ( - ctx, - global_configure, - global_set_context, + global_context_configure, + global_context_set, init_logger, logger, - mp_configure, ) @@ -42,10 +40,10 @@ def process_item(item): dict: The processed item result """ # Configure logurich for this process - mp_configure(logger) + logger.configure_child_logger(logger) # Set context variables for this item - global_set_context(item=ctx(str(item["id"]), label="item", style="cyan")) + global_context_set(item=logger.ctx(str(item["id"]), label="item", style="cyan")) try: # Log the start of processing @@ -105,11 +103,13 @@ def process_item(item): def init_worker(): """Initialize each worker process in the pool.""" # Configure logurich for this process - mp_configure(logger) + logger.configure_child_logger(logger) # Add process-specific context pid = os.getpid() - global_set_context(worker=ctx(f"Worker-{pid}", style="magenta", show_key=True)) + global_context_set( + worker=logger.ctx(f"Worker-{pid}", style="magenta", show_key=True) + ) logger.info(f"Worker process {pid} initialized") @@ -118,7 +118,9 @@ def main(): # Initialize the logger with rich handler init_logger("INFO", log_verbose=2, rich_handler=False) - with global_configure(group=ctx("DataProcessor", style="green", show_key=True)): + with global_context_configure( + group=logger.ctx("DataProcessor", style="green", show_key=True) + ): logger.rich( "INFO", Panel("Starting parallel data processing example", border_style="blue"), diff --git a/examples/mp_example.py b/examples/mp_example.py index d844899..50326d0 100644 --- a/examples/mp_example.py +++ b/examples/mp_example.py @@ -5,7 +5,7 @@ from rich.panel import Panel from rich.table import Table -from logurich import ctx, global_configure, init_logger, logger, mp_configure +from logurich import global_context_configure, init_logger, logger def worker_function(worker_id): @@ -14,10 +14,12 @@ def worker_function(worker_id): Shows how to configure logurich in a child process. """ # Configure the logger in this process - mp_configure(logger) + logger.configure_child_logger(logger) # Set a context variable for this worker - with global_configure(worker=ctx(f"Worker-{worker_id}", show_key=True)): + with global_context_configure( + worker=logger.ctx(f"Worker-{worker_id}", show_key=True) + ): # Log some basic messages logger.info(f"Worker {worker_id} starting") logger.debug(f"Worker {worker_id} debug message") @@ -60,7 +62,9 @@ def main(): logger.info("Multiprocessing example starting") # Create context for the main process - with global_configure(process=ctx("Main-Process", style="magenta", show_key=True)): + with global_context_configure( + process=logger.ctx("Main-Process", style="magenta", show_key=True) + ): # Create and start multiple worker processes num_workers = 3 processes = [] diff --git a/logurich/__init__.py b/logurich/__init__.py deleted file mode 100644 index 3e0f8b4..0000000 --- a/logurich/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -__version__ = "0.4.0" - -from .console import configure_console, get_console, rich_to_str, set_console -from .core import ( - ContextValue, - ctx, - global_configure, - global_set_context, - init_logger, - logger, - LOG_LEVEL_CHOICES, - mp_configure, -) - -init_logger("INFO") - -__all__ = [ - "logger", - "init_logger", - "mp_configure", - "global_configure", - "global_set_context", - "ContextValue", - "ctx", - "LOG_LEVEL_CHOICES", - "configure_console", - "get_console", - "set_console", - "rich_to_str", -] diff --git a/logurich/__init__.pyi b/logurich/__init__.pyi deleted file mode 100644 index 0641775..0000000 --- a/logurich/__init__.pyi +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from typing import ContextManager, Final, Mapping, Protocol - -from rich.console import Console - -from .core import ContextValue, LogLevel, LoguRich - -LevelByModuleValue = str | int | bool -LevelByModuleMapping = Mapping[str | None, LevelByModuleValue] - -class _SupportsStr(Protocol): - def __str__(self) -> str: ... - -ContextBinding = ContextValue | _SupportsStr | None - -__version__: Final[str] - -logger: LoguRich -LOG_LEVEL_CHOICES: Final[tuple[str, ...]] - -def ctx( - value: object, - *, - style: str | None = None, - value_style: str | None = None, - bracket_style: str | None = None, - label: str | None = None, - show_key: bool | None = None, -) -> ContextValue: ... -def init_logger( - log_level: LogLevel, - log_verbose: int = 0, - log_filename: str | None = None, - log_folder: str = "logs", - level_by_module: LevelByModuleMapping | None = None, - *, - rich_handler: bool = False, - diagnose: bool = False, - enqueue: bool = True, - highlight: bool = False, -) -> str | None: ... -def mp_configure(logger_: LoguRich) -> None: ... -def global_configure(**kwargs: ContextBinding) -> ContextManager[None]: ... -def global_set_context(**kwargs: ContextBinding) -> None: ... -def configure_console(*args: object, **kwargs: object) -> Console: ... -def get_console() -> Console: ... -def set_console(console: Console) -> None: ... -def rich_to_str(*objects: object, ansi: bool = True, **kwargs: object) -> str: ... - -__all__: Final[list[str]] diff --git a/logurich/core.pyi b/logurich/core.pyi deleted file mode 100644 index b87277c..0000000 --- a/logurich/core.pyi +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from loguru._logger import Logger as _Logger -from rich.console import ConsoleRenderable - -class LoguRich(_Logger): - def rich( - self, - log_level: str, - *renderables: ConsoleRenderable | str, - title: str = "", - prefix: bool = True, - end: str = "\n", - ) -> None: ... - -logger: LoguRich -LogLevel = Literal["TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL"] -LOG_LEVEL_CHOICES: tuple[str, ...] diff --git a/logurich/handler.py b/logurich/handler.py deleted file mode 100644 index 4053479..0000000 --- a/logurich/handler.py +++ /dev/null @@ -1,107 +0,0 @@ -import os -from datetime import datetime -from logging import Handler, LogRecord -from pathlib import Path - -from rich.console import ConsoleRenderable -from rich.highlighter import ReprHighlighter -from rich.logging import RichHandler -from rich.pretty import Pretty -from rich.table import Table -from rich.text import Text - -from .struct import extra_logger - -from .console import rich_console_renderer, get_console - - -class CustomRichHandler(RichHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, console=get_console(), **kwargs) - self._padding = 10 - - def emit(self, record): - super().emit(record) - - def build_content(self, record: LogRecord, content): - row = [] - list_context = record.extra.get("_build_list_context", []) - grid = Table.grid(expand=True) - if list_context: - grid.add_column(justify="left", style="bold", vertical="middle") - str_context = ".".join(list_context) - row.append(str_context + " :arrow_forward: ") - grid.add_column( - ratio=1, style="log.message", overflow="fold", vertical="middle" - ) - row.append(content) - grid.add_row(*row) - return grid - - def render(self, *, record: LogRecord, traceback, message_renderable): - path = Path(record.pathname).name - level = self.get_level_text(record) - time_format = None if self.formatter is None else self.formatter.datefmt - log_time = datetime.fromtimestamp(record.created) - rich_tb = record.extra.get("rich_traceback") - rich_console = record.extra.get("rich_console") - renderables = [] - if rich_console: - if record.msg: - renderables.append(self.build_content(record, message_renderable)) - for a in rich_console: - if isinstance(a, (ConsoleRenderable, str)): - renderables.append(a) - else: - renderables.append(Pretty(a)) - else: - renderables.append(self.build_content(record, message_renderable)) - if traceback and rich_tb: - renderables.append(rich_tb) - log_renderable = self._log_render( - self.console, - renderables, - 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, - ) - return log_renderable - - -class CustomHandler(Handler): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.highlighter = ReprHighlighter() - self.serialize = os.environ.get("LOGURU_SERIALIZE") - - def emit(self, record): - console = get_console() - end = record.extra.get("end", "\n") - if self.serialize: - console.out(record.msg, highlight=False, end="") - return - prefix = record.extra["_prefix"] - list_context = record.extra.get("_build_list_context", []) - rich_console = record.extra.get("rich_console") - rich_format = record.extra.get("rich_format") - rich_highlight = record.extra.get("rich_highlight") - conf_rich_highlight = extra_logger.get("conf_rich_highlight") - try: - if record.msg: - p = Text.from_markup(prefix) - t = p.copy() - if list_context: - t.append_text(Text.from_markup("".join(list_context)) + " ") - m = Text.from_markup(record.msg) - if rich_highlight is True or conf_rich_highlight is True: - m = self.highlighter(m) - t.append_text(m) - console.print(t, end=end, highlight=False) - if rich_console: - renderable = rich_console_renderer(prefix, rich_format, rich_console) - console.print(*renderable, end=end, highlight=False) - except Exception: - self.handleError(record) diff --git a/logurich/opt_click.py b/logurich/opt_click.py deleted file mode 100644 index ab1c5b2..0000000 --- a/logurich/opt_click.py +++ /dev/null @@ -1,82 +0,0 @@ -import functools - -import click - -from . import LOG_LEVEL_CHOICES, init_logger, logger - - -LOGGER_PARAM_NAMES = ( - "logger_level", - "logger_verbose", - "logger_filename", - "logger_level_by_module", - "logger_diagnose", -) - - -def click_logger_params(func): - @click.option( - "-l", - "--logger-level", - default="INFO", - help="Logger level", - type=click.Choice(LOG_LEVEL_CHOICES, case_sensitive=False), - ) - @click.option("-v", "--logger-verbose", help="Logger increase verbose", count=True) - @click.option( - "--logger-filename", - help="Logger log filename", - type=str, - ) - @click.option( - "--logger-level-by-module", - multiple=True, - help="Logger level by module", - type=(str, str), - ) - @click.option( - "--logger-diagnose", - is_flag=True, - help="Logger activate loguru diagnose", - type=bool, - default=False, - ) - @functools.wraps(func) - def wrapper(*args, **kwargs): - missing = [name for name in LOGGER_PARAM_NAMES if name not in kwargs] - if missing: - raise RuntimeError( - "Logger CLI parameters missing from invocation: {}".format( - ", ".join(missing) - ) - ) - logger_kwargs = {name: kwargs.pop(name) for name in LOGGER_PARAM_NAMES} - click_logger_init(**logger_kwargs) - return func(*args, **kwargs) - - return wrapper - - -def click_logger_init( - logger_level, - logger_verbose, - logger_filename, - logger_level_by_module, - logger_diagnose, -): - lbm = {} - for mod, level in logger_level_by_module: - lbm[mod] = level - log_path = init_logger( - logger_level, - logger_verbose, - log_filename=logger_filename, - level_by_module=lbm, - diagnose=logger_diagnose, - ) - logger.debug("Log level: {}", logger_level) - logger.debug("Log verbose: {}", logger_verbose) - logger.debug("Log filename: {}", logger_filename) - logger.debug("Log path: {}", log_path) - logger.debug("Log level by module: {}", lbm) - logger.debug("Log diagnose: {}", logger_diagnose) diff --git a/pyproject.toml b/pyproject.toml index 570f24f..5850481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,20 @@ authors = [ license = "MIT" license-files = ["LICENSE"] readme = "README.md" -requires-python = ">=3.9" -dependencies = [ +requires-python = ">=3.10" +keywords = [ + "logging", "loguru", - "rich" + "rich", + "console", + "terminal", + "colored-logging", + "structured-logging", + "pretty-logging", +] +dependencies = [ + "loguru==0.7.3", + "rich==14.2.0" ] classifiers = [ "Development Status :: 4 - Beta", @@ -19,7 +29,6 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Logging", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -43,12 +52,29 @@ dev = [ "ruff", ] -[tool.setuptools] -include-package-data = true +[build-system] +requires = ["uv_build>=0.9.20,<0.10.0"] +build-backend = "uv_build" + +[tool.ruff] +line-length = 88 +target-version = "py310" -[tool.setuptools.package-data] -"logurich" = ["py.typed", "*.pyi"] +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults +] -[build-system] -requires = ["setuptools>=77", "wheel"] -build-backend = "setuptools.build_meta" +[tool.ruff.lint.isort] +known-first-party = ["logurich"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 629430a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml -o requirements.txt pyproject.toml -loguru==0.7.3 - # via logurich (pyproject.toml) -markdown-it-py==4.0.0 - # via rich -mdurl==0.1.2 - # via markdown-it-py -pygments==2.19.2 - # via rich -rich==14.2.0 - # via logurich (pyproject.toml) diff --git a/src/logurich/__init__.py b/src/logurich/__init__.py new file mode 100644 index 0000000..4ac3aca --- /dev/null +++ b/src/logurich/__init__.py @@ -0,0 +1,39 @@ +"""Public package exports for logurich.""" + +__version__ = "0.4.0" + +from .console import ( + console, + rich_configure_console, + rich_get_console, + rich_set_console, + rich_to_str, +) +from .core import ( + LOG_LEVEL_CHOICES, + LogLevel, + LoguRich, + global_context_configure, + global_context_set, + init_logger, + logger, + propagate_loguru_to_std_logger, +) + +init_logger("INFO") + +__all__ = [ + "init_logger", + "logger", + "global_context_configure", + "global_context_set", + "propagate_loguru_to_std_logger", + "console", + "rich_configure_console", + "rich_get_console", + "rich_set_console", + "rich_to_str", + "LOG_LEVEL_CHOICES", + "LogLevel", + "LoguRich", +] diff --git a/src/logurich/__init__.pyi b/src/logurich/__init__.pyi new file mode 100644 index 0000000..19f378e --- /dev/null +++ b/src/logurich/__init__.pyi @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections.abc import Mapping +from contextlib import AbstractContextManager +from typing import Any, Final + +from rich.console import Console + +from .core import LogLevel, LoguRich + +LevelByModuleValue = str | int | bool +LevelByModuleMapping = Mapping[str | None, LevelByModuleValue] + +__version__: Final[str] + +logger: LoguRich +console: Console +LOG_LEVEL_CHOICES: Final[tuple[str, ...]] + +def init_logger( + log_level: LogLevel, + log_verbose: int = 0, + log_filename: str | None = None, + log_folder: str = "logs", + level_by_module: LevelByModuleMapping | None = None, + *, + rich_handler: bool = False, + diagnose: bool = False, + enqueue: bool = True, + highlight: bool = False, + rotation: str | int | None = "12:00", + retention: str | int | None = "10 days", +) -> str | None: ... +def global_context_configure(**kwargs: Any) -> AbstractContextManager[None]: ... +def global_context_set(**kwargs: Any) -> None: ... +def propagate_loguru_to_std_logger() -> None: ... +def rich_configure_console(*args: object, **kwargs: object) -> Console: ... +def rich_get_console() -> Console: ... +def rich_set_console(console: Console) -> None: ... +def rich_to_str(*objects: object, ansi: bool = True, **kwargs: object) -> str: ... + +__all__: Final[list[str]] diff --git a/logurich/console.py b/src/logurich/console.py similarity index 83% rename from logurich/console.py rename to src/logurich/console.py index 5e6620a..6f431f7 100644 --- a/logurich/console.py +++ b/src/logurich/console.py @@ -1,3 +1,5 @@ +"""Rich console helpers for logurich rendering.""" + from __future__ import annotations from typing import Any @@ -11,7 +13,7 @@ def rich_to_str(*objects, ansi: bool = True, **kwargs) -> str: - console = get_console() + console = rich_get_console() with console.capture() as capture: console.print(*objects, **kwargs) if ansi is True: @@ -34,7 +36,7 @@ def rich_format_grid(prefix: Text, data: ConsoleRenderable, real_width: int) -> def rich_console_renderer( prefix: str, rich_format: bool, data: Any ) -> list[ConsoleRenderable]: - console = get_console() + console = rich_get_console() rich_prefix = prefix[:-2] + "# " pp = Text.from_markup(rich_prefix) real_width = console.width - len(pp) @@ -45,7 +47,7 @@ def rich_console_renderer( item = pp.copy() item = item.append(Text.from_markup(r)) renderable.append(item) - elif isinstance(r, Text) and r.split() == 1: + elif isinstance(r, Text) and len(r.split()) == 1: item = pp.copy() item = item.append_text(r) renderable.append(item) @@ -65,19 +67,22 @@ def rich_console_renderer( return renderable -def set_console(console: Console): +def rich_set_console(console: Console) -> None: global _console - _console = console + if _console is None: + _console = console + return + _console.__dict__ = console.__dict__ -def get_console() -> Console: +def rich_get_console() -> Console: global _console if _console is None: _console = Console(markup=True) return _console -def configure_console(*args: Any, **kwargs: Any) -> Console: +def rich_configure_console(*args: Any, **kwargs: Any) -> Console: """Reconfigures the logurich console by replacing it with another. Args: @@ -88,6 +93,9 @@ def configure_console(*args: Any, **kwargs: Any) -> Console: Return the logurich console """ new_console = Console(*args, **kwargs) - _console = get_console() + _console = rich_get_console() _console.__dict__ = new_console.__dict__ return _console + + +console = rich_get_console() diff --git a/logurich/core.py b/src/logurich/core.py similarity index 79% rename from logurich/core.py rename to src/logurich/core.py index c084cdc..0217705 100644 --- a/logurich/core.py +++ b/src/logurich/core.py @@ -1,13 +1,15 @@ +"""Core logging configuration and helpers for logurich.""" + from __future__ import annotations import contextlib import logging import os import sys -import time from dataclasses import dataclass -from functools import partialmethod, wraps -from typing import Any, Literal, Union, get_args +from functools import partialmethod +from pathlib import Path +from typing import Any, Literal, get_args from loguru import logger as _logger from loguru._logger import Logger as _Logger @@ -19,12 +21,13 @@ from .console import rich_console_renderer, rich_to_str from .handler import CustomHandler, CustomRichHandler from .struct import extra_logger +from .utils import parse_bool_env -def rich_logger( +def _rich_logger( self: _Logger, log_level: str, - *renderables: Union[ConsoleRenderable, str], + *renderables: ConsoleRenderable | str, title: str = "", prefix: bool = True, end: str = "\n", @@ -34,8 +37,9 @@ def rich_logger( ) -_Logger.rich = partialmethod(rich_logger) +_Logger.rich = partialmethod(_rich_logger) logger = _logger +LoguRich = _Logger COLOR_ALIASES = { @@ -93,10 +97,7 @@ 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) - if label: - body = f"{escape(label)}={value_text}" - else: - body = 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): @@ -108,80 +109,9 @@ def render(self, key: str, *, is_rich_handler: bool) -> str: return f"{left}{body}{right}" -def ctx( - value: Any, - *, - style: str | None = None, - value_style: str | None = None, - bracket_style: str | None = None, - label: str | None = None, - show_key: bool | None = 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, - ) - - -def _logger_add_ctx_timestamp(kwargs: dict, stack: bool = True): - new_kwargs = {} - for k in list(kwargs.keys()): - if k.startswith("context") and stack is True and "#" not in k: - v = kwargs.pop(k) - new_k = k + "#" + str(time.time_ns()) - new_kwargs[new_k] = v - kwargs.update(new_kwargs) - - -def logger_patch(record): - context = {} - for name in record["extra"]: - if name.startswith("context") is False: - continue - splitted_name = name.split("#") - if len(splitted_name) == 2: - real_name, timer = splitted_name - else: - real_name, timer = name, 0 - context[name] = real_name, float(timer) - sorted_context_name = sorted(context, key=lambda d: context[d][1]) - for name in sorted_context_name: - value = record["extra"].pop(name) - real_name, _ = context[name] - record["extra"][real_name] = value - - -def logger_bind(args, kwargs): - _logger_add_ctx_timestamp(kwargs, stack=False) - - -def logger_ctx(args, kwargs): - _ = kwargs.get("stack", False) - _logger_add_ctx_timestamp(kwargs, stack=False) - - -@contextlib.contextmanager -def global_configure(**kwargs): - global_set_context(**kwargs) - try: - yield - finally: - global_set_context(**{k: None for k in kwargs}) - - def _normalize_context_key(key: str) -> str: if key.startswith("context::"): return key - if key.startswith("context"): - raise ValueError( - "Legacy context keys using the 'context__' pattern are no longer supported." - ) return f"context::{key}" @@ -193,49 +123,7 @@ def _coerce_context_value(value: Any) -> ContextValue | None: return ContextValue(value=value) -def global_set_context(**kwargs): - for key, value in kwargs.items(): - normalized_key = _normalize_context_key(key) - normalized_value = _coerce_context_value(value) - - matching_keys = [ - existing - for existing in list(extra_logger.keys()) - if existing == normalized_key or existing.startswith(normalized_key + "#") - ] - for existing in matching_keys: - extra_logger.pop(existing, None) - - if normalized_value is None: - continue - - extra_logger[normalized_key] = normalized_value - - logger.configure(extra=extra_logger) - - -def logger_wraps(*, entry=True, exit=True, level="DEBUG"): - def wrapper(func): - name = func.__name__ - - @wraps(func) - def wrapped(*args, **kwargs): - logger_ = logger.opt(depth=1) - if entry: - logger_.log( - level, "Entering '{}' (args={}, kwargs={})", name, args, kwargs - ) - result = func(*args, **kwargs) - if exit: - logger_.log(level, "Exiting '{}' (result={})", name, result) - return result - - return wrapped - - return wrapper - - -class InterceptHandler(logging.Handler): +class _InterceptHandler(logging.Handler): def emit(self, record): # Get corresponding Loguru level if it exists. try: @@ -254,7 +142,7 @@ def emit(self, record): ) -class Formatter: +class _Formatter: ALL_PADDING_FMT = [ (0, ""), (10, "{process.name}"), @@ -287,14 +175,14 @@ class Formatter: } def __init__(self, log_level, verbose: int, is_rich_handler: bool = False): - self.serialize = os.environ.get("LOGURU_SERIALIZE") + self.serialize = parse_bool_env("LOGURU_SERIALIZE") self.is_rich_handler = is_rich_handler if self.is_rich_handler is True: self._padding = 0 self.fmt_format = "{process.name}.{name}:{line}" - self.prefix = Formatter.FMT_RICH + self.prefix = _Formatter.FMT_RICH else: - self._padding, self.fmt_format = Formatter.ALL_PADDING_FMT[verbose] + self._padding, self.fmt_format = _Formatter.ALL_PADDING_FMT[verbose] self.prefix = self.ALL_FMT[verbose] self.verbose = verbose self.log_level = log_level @@ -341,13 +229,13 @@ def add_rich_tb(self, record: dict): def init_record(self, record: dict): length = len(self.fmt_format.format(**record)) self._padding = min(max(self._padding, length), 50) - list_context = Formatter.build_context( + list_context = _Formatter.build_context( record, is_rich_handler=self.is_rich_handler ) record["extra"]["_build_list_context"] = list_context record["extra"]["_padding"] = " " * (self._padding - length) record["extra"].update(self.extra_from_envs) - lvl_color = Formatter.LEVEL_COLOR_MAP.get(record["level"].name, "cyan") + lvl_color = _Formatter.LEVEL_COLOR_MAP.get(record["level"].name, "cyan") prefix = self.prefix.format(**record) prefix = prefix.replace("", f"[{lvl_color}]") prefix = prefix.replace("", f"[/{lvl_color}]") @@ -384,17 +272,7 @@ def format(self, record: dict): return "{message}{exception}" -def set_level(level: str): - extra_logger.update({"__level_upper_only": level}) - logger.configure(extra=extra_logger) - - -def restore_level(): - extra_logger.update({"__level_upper_only": None}) - logger.configure(extra=extra_logger) - - -def filter_records(record): +def _filter_records(record): min_level = record["extra"].get("__min_level") level_per_module = record["extra"].get("__level_per_module") if level_per_module: @@ -420,7 +298,7 @@ def filter_records(record): return record["level"].no >= min_level -def conf_level_by_module(conf: dict): +def _conf_level_by_module(conf: dict): level_per_module = {} for module, level_ in conf.items(): if module is not None and not isinstance(module, str): @@ -456,27 +334,133 @@ def conf_level_by_module(conf: dict): return level_per_module -class PropagateHandler(logging.Handler): +class _PropagateHandler(logging.Handler): def emit(self, record): logging.getLogger(record.name).handle(record) -def propagate_loguru_to_std_logger(): - logger.remove() - logger.add(PropagateHandler(), format="{message}") - - -def reinstall_loguru(from_logger, target_logger): +def _reinstall_loguru(from_logger, target_logger): from_logger._core.__dict__ = target_logger._core.__dict__.copy() from_logger._options = target_logger._options extra_logger.update(target_logger._core.__dict__.get("extra", {})) -def mp_configure(logger_): +LogLevel = Literal[ + "TRACE", + "DEBUG", + "INFO", + "SUCCESS", + "WARNING", + "ERROR", + "CRITICAL", +] +LOG_LEVEL_CHOICES: tuple[str, ...] = get_args(LogLevel) + + +# Public API Functions + + +def ctx( + value: Any, + *, + style: str | None = None, + value_style: str | None = None, + bracket_style: str | None = None, + label: str | None = None, + show_key: bool | None = 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, + ) + + +_Logger.ctx = staticmethod(ctx) + + +@contextlib.contextmanager +def global_context_configure(**kwargs): + previous = {} + for key in kwargs: + normalized_key = _normalize_context_key(key) + matching_keys = [ + existing + for existing in list(extra_logger.keys()) + if existing == normalized_key or existing.startswith(normalized_key + "#") + ] + for existing in matching_keys: + if existing not in previous: + previous[existing] = extra_logger[existing] + global_context_set(**kwargs) + try: + yield + finally: + for key in kwargs: + normalized_key = _normalize_context_key(key) + matching_keys = [ + existing + for existing in list(extra_logger.keys()) + if existing == normalized_key + or existing.startswith(normalized_key + "#") + ] + for existing in matching_keys: + extra_logger.pop(existing, None) + if previous: + extra_logger.update(previous) + logger.configure(extra=extra_logger) + + +def global_context_set(**kwargs): + for key, value in kwargs.items(): + normalized_key = _normalize_context_key(key) + normalized_value = _coerce_context_value(value) + + matching_keys = [ + existing + for existing in list(extra_logger.keys()) + if existing == normalized_key or existing.startswith(normalized_key + "#") + ] + for existing in matching_keys: + extra_logger.pop(existing, None) + + if normalized_value is None: + continue + + extra_logger[normalized_key] = normalized_value + + logger.configure(extra=extra_logger) + + +def level_set(level: LogLevel): + extra_logger.update({"__level_upper_only": level}) + logger.configure(extra=extra_logger) + + +def level_restore(): + extra_logger.update({"__level_upper_only": None}) + logger.configure(extra=extra_logger) + + +_Logger.level_set = staticmethod(level_set) +_Logger.level_restore = staticmethod(level_restore) + + +def propagate_loguru_to_std_logger(): + logger.remove() + logger.add(_PropagateHandler(), format="{message}") + + +def configure_child_logger(logger_): """Configure a logger in a child process from a parent process logger. This function sets up the logger to work properly in multiprocessing contexts. - It configures the basic logging system to use Loguru's InterceptHandler and + It configures the basic logging system to use the internal intercept handler and reinstalls the logger with the configuration from the parent process. Args: @@ -488,40 +472,33 @@ def mp_configure(logger_): >>> init_logger("INFO") >>> from multiprocessing import Process >>> def worker(logger_instance): - >>> from logurich import mp_configure - >>> mp_configure(logger_instance) + >>> from logurich import logger + >>> logger.configure_child_logger(logger_instance) >>> # Now the logger in this process has the same configuration >>> >>> p = Process(target=worker, args=(logger,)) >>> p.start() """ - logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) - reinstall_loguru(logger, logger_) + logging.basicConfig(handlers=[_InterceptHandler()], level=0, force=True) + _reinstall_loguru(logger, logger_) -LogLevel = Literal[ - "TRACE", - "DEBUG", - "INFO", - "SUCCESS", - "WARNING", - "ERROR", - "CRITICAL", -] -LOG_LEVEL_CHOICES: tuple[str, ...] = get_args(LogLevel) +_Logger.configure_child_logger = staticmethod(configure_child_logger) def init_logger( log_level: LogLevel, log_verbose: int = 0, - log_filename: str = None, - log_folder="logs", + log_filename: str | None = None, + log_folder: str = "logs", level_by_module=None, rich_handler: bool = False, diagnose: bool = False, enqueue: bool = True, highlight: bool = False, -) -> str: + rotation: str | int | None = "12:00", + retention: str | int | None = "10 days", +) -> str | None: """Initialize and configure the logger with rich formatting and customized handlers. This function sets up a logging system using Loguru with optional Rich integration. @@ -548,27 +525,34 @@ def init_logger( enqueue (bool, optional): Whether to use a queue for thread-safe logging. Defaults to True. highlight (bool, optional): Whether to highlight log messages. Defaults to False. + rotation (str | int | None, optional): When to rotate log files. Can be a time string + (e.g. "12:00", "1 week"), size (e.g. "500 MB"), or None to disable rotation. + Defaults to "12:00". + retention (str | int | None, optional): How long to keep rotated log files. Can be a time + string (e.g. "10 days", "1 month"), count, or None to keep all files. + Defaults to "10 days". Returns: - str: The absolute path to the log file if file logging is enabled, None otherwise. + str | None: The absolute path to the log file if file logging is enabled, None otherwise. Example: >>> init_logger("INFO", log_verbose=2, log_filename="app.log") >>> logger.info("Application started") >>> logger.debug("Debug information") # Won't be displayed with INFO level """ - rich_handler = ( - os.environ.get("LOGURU_RICH") if rich_handler is False else rich_handler - ) - logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) + if rich_handler is False: + env_rich_handler = parse_bool_env("LOGURU_RICH") + if env_rich_handler is not None: + rich_handler = env_rich_handler + logging.basicConfig(handlers=[_InterceptHandler()], level=0, force=True) logger.remove() if log_verbose > 3: log_verbose = 3 elif log_verbose < 0: log_verbose = 0 - formatter = Formatter(log_level, log_verbose, is_rich_handler=rich_handler) + formatter = _Formatter(log_level, log_verbose, is_rich_handler=rich_handler) level_per_module = ( - conf_level_by_module(level_by_module) if level_by_module else None + _conf_level_by_module(level_by_module) if level_by_module else None ) extra_logger.update( { @@ -577,7 +561,7 @@ def init_logger( "__rich_highlight": highlight, } ) - logger.configure(extra=extra_logger, patcher=logger_patch) + logger.configure(extra=extra_logger) # Create appropriate handler based on rich_handler flag if rich_handler is True: handler = CustomRichHandler( @@ -588,26 +572,29 @@ def init_logger( else: handler = CustomHandler() # Add handler with common configuration + serialize = bool(parse_bool_env("LOGURU_SERIALIZE")) logger.add( handler, level=0, format=formatter.format, - filter=filter_records, + filter=_filter_records, enqueue=enqueue, diagnose=diagnose, colorize=False, - serialize=os.environ.get("LOGURU_SERIALIZE"), + serialize=serialize, ) log_path = None if log_filename is not None: - log_path = os.path.join(log_folder, log_filename) + log_dir = Path(log_folder) + log_dir.mkdir(parents=True, exist_ok=True) + log_path = str(log_dir / log_filename) logger.add( log_path, level=0, - rotation="12:00", - retention="10 days", + rotation=rotation, + retention=retention, format=formatter.format_file, - filter=filter_records, + filter=_filter_records, enqueue=True, serialize=False, diagnose=False, diff --git a/src/logurich/core.pyi b/src/logurich/core.pyi new file mode 100644 index 0000000..e47c95d --- /dev/null +++ b/src/logurich/core.pyi @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Any, Literal + +from loguru._logger import Logger as _Logger +from rich.console import ConsoleRenderable + +class ContextValue: + value: Any + value_style: str | None + bracket_style: str | None + label: str | None + show_key: bool + + def __init__( + self, + value: Any, + value_style: str | None = ..., + bracket_style: str | None = ..., + label: str | None = ..., + show_key: bool = ..., + ) -> None: ... + def _label(self, key: str) -> str | None: ... + def render(self, key: str, *, is_rich_handler: bool) -> str: ... + +class LoguRich(_Logger): + @staticmethod + def ctx( + value: Any, + *, + style: str | None = None, + value_style: str | None = None, + bracket_style: str | None = None, + label: str | None = None, + show_key: bool | None = None, + ) -> ContextValue: ... + @staticmethod + def level_set(level: LogLevel) -> None: ... + @staticmethod + def level_restore() -> None: ... + @staticmethod + def configure_child_logger(logger_: LoguRich) -> None: ... + def rich( + self, + log_level: str, + *renderables: ConsoleRenderable | str, + title: str = "", + prefix: bool = True, + end: str = "\n", + ) -> None: ... + +logger: LoguRich +LogLevel = Literal["TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL"] +LOG_LEVEL_CHOICES: tuple[str, ...] diff --git a/src/logurich/handler.py b/src/logurich/handler.py new file mode 100644 index 0000000..b8805e3 --- /dev/null +++ b/src/logurich/handler.py @@ -0,0 +1,198 @@ +"""Custom logging handlers for logurich. + +Provides two main handler classes: +- CustomRichHandler: Rich-formatted handler using RichHandler as base +- CustomHandler: Lightweight handler for console output without rich tables +""" + +from __future__ import annotations + +from datetime import datetime +from logging import Handler, LogRecord +from pathlib import Path +from typing import TYPE_CHECKING + +from rich.console import ConsoleRenderable +from rich.highlighter import ReprHighlighter +from rich.logging import RichHandler +from rich.pretty import Pretty +from rich.table import Table +from rich.text import Text + +from .console import rich_console_renderer, rich_get_console +from .struct import extra_logger +from .utils import parse_bool_env + +if TYPE_CHECKING: + from rich.console import Console, RenderableType + +# Default padding for handler content alignment +# This value represents the initial column width for context prefixes +DEFAULT_CONTENT_PADDING = 10 + + +class CustomRichHandler(RichHandler): + """Custom Rich handler with enhanced context rendering. + + Extends RichHandler to support logurich's context system and custom formatting. + Renders log records using Rich's table-based layout with support for nested + context breadcrumbs and traceback rendering. + + Args: + *args: Positional arguments passed to RichHandler + **kwargs: Keyword arguments passed to RichHandler + """ + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, console=rich_get_console(), **kwargs) + self._padding: int = DEFAULT_CONTENT_PADDING + + def emit(self, record: LogRecord) -> None: + """Emit a log record using the parent RichHandler. + + Args: + record: The log record to emit + """ + super().emit(record) + + def build_content(self, record: LogRecord, content: RenderableType) -> Table: + """Build a Rich table grid with optional context breadcrumbs. + + Args: + record: The log record containing extra context data + content: The renderable message content + + Returns: + A Rich Table grid with context prefix and message content + """ + row: list[str | RenderableType] = [] + list_context: list[str] = record.extra.get("_build_list_context", []) + grid = Table.grid(expand=True) + if list_context: + grid.add_column(justify="left", style="bold", vertical="middle") + str_context = ".".join(list_context) + row.append(str_context + " :arrow_forward: ") + grid.add_column( + ratio=1, style="log.message", overflow="fold", vertical="middle" + ) + row.append(content) + grid.add_row(*row) + return grid + + def render( + self, + *, + record: LogRecord, + traceback: object, + message_renderable: RenderableType, + ) -> RenderableType: + """Render a log record as a Rich renderable. + + Args: + record: The log record to render + traceback: Optional traceback object + message_renderable: The rendered message content + + Returns: + A Rich renderable representing the complete log entry + """ + path = Path(record.pathname).name + level = self.get_level_text(record) + time_format = None if self.formatter is None else self.formatter.datefmt + log_time = datetime.fromtimestamp(record.created) + rich_tb = record.extra.get("rich_traceback") + rich_console = record.extra.get("rich_console") + renderables: list[RenderableType] = [] + if rich_console: + if record.msg: + renderables.append(self.build_content(record, message_renderable)) + for item in rich_console: + if isinstance(item, (ConsoleRenderable, str)): + renderables.append(item) + else: + renderables.append(Pretty(item)) + else: + renderables.append(self.build_content(record, message_renderable)) + if traceback and rich_tb: + renderables.append(rich_tb) + log_renderable = self._log_render( + self.console, + renderables, + 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, + ) + return log_renderable + + +class CustomHandler(Handler): + """Lightweight custom handler for console output. + + Handles log formatting without Rich tables, using text-based markup rendering. + Supports serialization mode for structured output and rich highlighting for + enhanced readability. + + Args: + *args: Positional arguments passed to Handler + **kwargs: Keyword arguments passed to Handler + """ + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.highlighter = ReprHighlighter() + self.serialize: bool | None = parse_bool_env("LOGURU_SERIALIZE") + self._console: Console = rich_get_console() + + def _should_highlight(self, record: LogRecord) -> bool: + """Determine if syntax highlighting should be applied. + + Args: + record: The log record to check + + Returns: + True if highlighting should be enabled + """ + rich_highlight = record.extra.get("rich_highlight") + conf_rich_highlight = extra_logger.get("__rich_highlight") + return rich_highlight is True or conf_rich_highlight is True + + def emit(self, record: LogRecord) -> None: + """Emit a log record to the console. + + Handles both serialized output mode and rich markup rendering mode. + Applies syntax highlighting based on configuration and formats context + breadcrumbs when present. + + Args: + record: The log record to emit + """ + end: str = record.extra.get("end", "\n") + if self.serialize: + self._console.out(record.msg, highlight=False, end="") + return + + prefix: str = record.extra["_prefix"] + list_context: list[str] = record.extra.get("_build_list_context", []) + rich_console = record.extra.get("rich_console") + rich_format = record.extra.get("rich_format") + + try: + if record.msg: + prefix_text = Text.from_markup(prefix) + output_text = prefix_text.copy() + if list_context: + context_text = Text.from_markup("".join(list_context)) + " " + output_text.append_text(context_text) + message_text = Text.from_markup(record.msg) + if self._should_highlight(record): + message_text = self.highlighter(message_text) + output_text.append_text(message_text) + self._console.print(output_text, end=end, highlight=False) + if rich_console: + renderable = rich_console_renderer(prefix, rich_format, rich_console) + self._console.print(*renderable, end=end, highlight=False) + except Exception: + self.handleError(record) diff --git a/src/logurich/opt_click.py b/src/logurich/opt_click.py new file mode 100644 index 0000000..26ce659 --- /dev/null +++ b/src/logurich/opt_click.py @@ -0,0 +1,155 @@ +"""Click integration module for logurich. + +Provides decorators and utilities to seamlessly integrate logurich logging +with Click CLI applications. +""" + +from __future__ import annotations + +import functools +from collections.abc import Callable +from typing import Any, TypeVar + +import click + +from . import LOG_LEVEL_CHOICES, init_logger, logger + +LOGGER_PARAM_NAMES = ( + "logger_level", + "logger_verbose", + "logger_filename", + "logger_level_by_module", + "logger_diagnose", + "logger_rich", +) + +F = TypeVar("F", bound=Callable[..., Any]) + + +def click_logger_params(func: F) -> F: + """Decorator to add logger configuration options to Click commands. + + This decorator automatically adds the following CLI options: + - ``-l, --logger-level``: Set the logging level (DEBUG, INFO, WARNING, etc.) + - ``-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-diagnose``: Enable Loguru diagnostic mode + - ``--logger-rich``: Enable Rich handler for enhanced console output + + The decorator initializes the logger before the command function executes. + + Args: + func: The Click command function to decorate. + + Returns: + The decorated function with logger CLI options. + + Example: + >>> import click + >>> from logurich.opt_click import click_logger_params + >>> + >>> @click.command() + >>> @click_logger_params + >>> def my_cli(): + >>> logger.info("Application started") + >>> + >>> if __name__ == "__main__": + >>> my_cli() + + Raises: + RuntimeError: If logger parameters are missing from the function invocation. + """ + + @click.option( + "-l", + "--logger-level", + default="INFO", + help="Logger level", + type=click.Choice(LOG_LEVEL_CHOICES, case_sensitive=False), + ) + @click.option("-v", "--logger-verbose", help="Logger increase verbose", count=True) + @click.option( + "--logger-filename", + help="Logger log filename", + type=str, + ) + @click.option( + "--logger-level-by-module", + multiple=True, + help="Logger level by module", + type=(str, str), + ) + @click.option( + "--logger-diagnose", + is_flag=True, + help="Logger activate loguru diagnose", + type=bool, + default=False, + ) + @click.option( + "--logger-rich", + is_flag=True, + help="Enable rich handler for enhanced console output", + type=bool, + default=False, + ) + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + missing = [name for name in LOGGER_PARAM_NAMES if name not in kwargs] + if missing: + raise RuntimeError( + "Logger CLI parameters missing from invocation: {}".format( + ", ".join(missing) + ) + ) + logger_kwargs = {name: kwargs.pop(name) for name in LOGGER_PARAM_NAMES} + click_logger_init(**logger_kwargs) + return func(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + +def click_logger_init( + logger_level: str, + logger_verbose: int, + logger_filename: str | None, + logger_level_by_module: tuple[tuple[str, str], ...], + logger_diagnose: bool, + logger_rich: bool, +) -> None: + """Initialize the logger with parameters from Click CLI options. + + This function is called internally by the click_logger_params decorator + to configure the logger based on CLI arguments. + + Args: + logger_level: The minimum logging level (e.g., "DEBUG", "INFO"). + 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_diagnose: Whether to enable Loguru diagnostic mode. + logger_rich: Whether to use Rich handler for enhanced console output. + + Example: + This function is typically not called directly. It's invoked by the + click_logger_params decorator. + """ + lbm = {} + for mod, level in logger_level_by_module: + lbm[mod] = level + log_path = init_logger( + logger_level, + logger_verbose, + log_filename=logger_filename, + level_by_module=lbm, + diagnose=logger_diagnose, + rich_handler=logger_rich, + ) + logger.debug("Log level: {}", logger_level) + logger.debug("Log verbose: {}", logger_verbose) + logger.debug("Log filename: {}", logger_filename) + logger.debug("Log path: {}", log_path) + logger.debug("Log level by module: {}", lbm) + logger.debug("Log rich handler: {}", logger_rich) + logger.debug("Log diagnose: {}", logger_diagnose) diff --git a/logurich/py.typed b/src/logurich/py.typed similarity index 100% rename from logurich/py.typed rename to src/logurich/py.typed diff --git a/logurich/struct.py b/src/logurich/struct.py similarity index 76% rename from logurich/struct.py rename to src/logurich/struct.py index 31506af..665e3db 100644 --- a/logurich/struct.py +++ b/src/logurich/struct.py @@ -1,3 +1,5 @@ +"""Shared logger configuration state.""" + extra_logger = { "__min_level": None, "__level_upper_only": None, diff --git a/src/logurich/utils.py b/src/logurich/utils.py new file mode 100644 index 0000000..2dd17ed --- /dev/null +++ b/src/logurich/utils.py @@ -0,0 +1,13 @@ +"""Utility helpers for logurich.""" + +import os + + +def parse_bool_env(name: str) -> bool | None: + value = os.environ.get(name) + if value is None: + return None + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + return normalized not in {"0", "false", "no", "off", ""} diff --git a/tests/conftest.py b/tests/conftest.py index b8eef06..177b48a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,13 +4,13 @@ from logurich import init_logger from logurich import logger as _logger -from logurich.console import configure_console +from logurich.console import rich_configure_console @pytest.fixture def buffer(): buffer = StringIO() - configure_console(file=buffer, width=120) + rich_configure_console(file=buffer, width=120) yield buffer diff --git a/tests/test_core.py b/tests/test_core.py index f318fac..ece1df9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,7 +2,11 @@ import pytest -from logurich import ctx, global_configure, global_set_context, init_logger +from logurich import ( + global_context_configure, + global_context_set, + init_logger, +) @pytest.mark.parametrize( @@ -53,20 +57,44 @@ def test_level_debug_verbose(logger, buffer): indirect=True, ) def test_global_configure(logger, buffer): - with global_configure(exec_id=ctx("id_123", style="yellow")): + with global_context_configure(exec_id=logger.ctx("id_123", style="yellow")): logger.info("Hello, world!") logger.debug("Debug, world!") logger.complete() assert all("id_123" in log for log in buffer.getvalue().splitlines()) +@pytest.mark.parametrize( + "logger", + [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], + indirect=True, +) +def test_global_configure_restores_previous(logger, buffer): + with global_context_configure(exec_id=logger.ctx("outer_ctx", style="yellow")): + logger.info("outer message") + with global_context_configure(exec_id=logger.ctx("inner_ctx", style="cyan")): + logger.info("inner message") + logger.info("outer message again") + logger.info("plain message") + logger.complete() + 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] + + @pytest.mark.parametrize( "logger", [{"level": "DEBUG", "enqueue": False}, {"level": "DEBUG", "enqueue": True}], indirect=True, ) def test_with_configure(logger, buffer): - with logger.contextualize(exec_id=ctx("task-id", style="yellow")): + with logger.contextualize(exec_id=logger.ctx("task-id", style="yellow")): logger.info("Hello, world!") logger.debug("Debug, world!") logger.complete() @@ -79,12 +107,12 @@ def test_with_configure(logger, buffer): indirect=True, ) def test_set_context(logger, buffer): - global_set_context(exec_id=ctx("id_123", style="yellow")) + global_context_set(exec_id=logger.ctx("id_123", style="yellow")) logger.info("Hello, world!") logger.debug("Debug, world!") logger.complete() assert all("id_123" in log for log in buffer.getvalue().splitlines()) - global_set_context(exec_id=None) + global_context_set(exec_id=None) @pytest.mark.parametrize( @@ -103,3 +131,52 @@ def test_loguru_serialize_env(monkeypatch, logger, level, enqueue, buffer): assert log_lines, "No serialized output captured" payload = json.loads(log_lines[0]) assert payload["record"]["message"] == "Serialized output" + + +@pytest.mark.parametrize( + "logger", + [{"level": "DEBUG", "enqueue": False}], + indirect=True, +) +def test_logger_ctx_in_bind(logger, buffer): + """logger.ctx() should work seamlessly with logger.bind().""" + logger.bind(session=logger.ctx("sess-42", style="cyan")).info("bound message") + logger.complete() + assert "sess-42" in buffer.getvalue() + + +@pytest.mark.parametrize( + "logger", + [{"level": "DEBUG", "enqueue": False}], + indirect=True, +) +def test_set_level_filters_messages(logger, buffer): + """level_set() should temporarily raise the minimum log level.""" + logger.debug("before level_set") + logger.level_set("WARNING") + logger.debug("should be filtered") + logger.info("also filtered") + logger.warning("should appear") + logger.complete() + output = buffer.getvalue() + assert "before level_set" in output + assert "should be filtered" not in output + assert "also filtered" not in output + assert "should appear" in output + + +@pytest.mark.parametrize( + "logger", + [{"level": "DEBUG", "enqueue": False}], + indirect=True, +) +def test_restore_level_resets_filtering(logger, buffer): + """level_restore() should reset the log level to the original.""" + logger.level_set("ERROR") + logger.warning("filtered warning") + logger.level_restore() + logger.debug("after restore") + logger.complete() + output = buffer.getvalue() + assert "filtered warning" not in output + assert "after restore" in output diff --git a/tests/test_mp.py b/tests/test_mp.py index ccf74f6..5fd3d42 100644 --- a/tests/test_mp.py +++ b/tests/test_mp.py @@ -4,36 +4,35 @@ from rich.panel import Panel from rich.table import Table -from logurich import ctx -from logurich.core import global_configure, mp_configure +from logurich import global_context_configure def worker_process(logger_): from logurich.core import logger - mp_configure(logger_) + logger.configure_child_logger(logger_) logger.debug("Test message from child process") def worker_with_context(logger_): from logurich.core import logger - mp_configure(logger_) + logger.configure_child_logger(logger_) logger.info("Message with worker context") def worker_process_context(logger_): from logurich import logger - mp_configure(logger_) - with logger.contextualize(task_id=ctx("task-id")): + logger.configure_child_logger(logger_) + with logger.contextualize(task_id=logger.ctx("task-id")): logger.info("Message with context") def worker_with_rich_logging(logger_): from logurich.core import logger - mp_configure(logger_) + logger.configure_child_logger(logger_) panel = Panel("Test rich panel") table = Table(title="Test table") table.add_column("Column 1") @@ -47,7 +46,7 @@ def worker_with_rich_logging(logger_): [{"level": "DEBUG", "enqueue": True}], indirect=True, ) -def test_mp_configure(logger, buffer): +def test_configure_child_logger(logger, buffer): process = mp.Process(target=worker_process, args=(logger,)) process.start() process.join() @@ -63,7 +62,7 @@ def test_mp_configure(logger, buffer): [{"level": "DEBUG", "enqueue": True}], indirect=True, ) -def test_mp_configure_context(logger, buffer): +def test_configure_child_logger_context(logger, buffer): process = mp.Process(target=worker_process_context, args=(logger,)) process.start() process.join() @@ -77,7 +76,7 @@ def test_mp_configure_context(logger, buffer): indirect=True, ) def test_global_configure_in_mp(logger, buffer): - with global_configure(worker=ctx("TestWorker")): + with global_context_configure(worker=logger.ctx("TestWorker")): process = mp.Process(target=worker_with_context, args=(logger,)) process.start() process.join() diff --git a/tests/test_rich.py b/tests/test_rich.py index 74d100d..266616b 100644 --- a/tests/test_rich.py +++ b/tests/test_rich.py @@ -1,6 +1,7 @@ import random -import string import re +import string + import pytest from rich.pretty import Pretty diff --git a/uv.lock b/uv.lock index c4f2187..54b18e8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,59 +1,22 @@ version = 1 revision = 3 -requires-python = ">=3.9" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version < '3.10'", -] - -[[package]] -name = "cfgv" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, -] +requires-python = ">=3.10" [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] -[[package]] -name = "click" -version = "8.1.8" -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'" }, -] -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 = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, -] - [[package]] name = "click" version = "8.3.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 = "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" } wheels = [ @@ -90,25 +53,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "filelock" -version = "3.19.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, -] - [[package]] name = "filelock" version = "3.20.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, @@ -123,25 +71,10 @@ 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 = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -158,24 +91,21 @@ 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" }, ] [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 = "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 = "pre-commit" }, + { name = "pytest" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "click", marker = "extra == 'click'", specifier = ">=8.1.8" }, - { name = "loguru" }, - { name = "rich" }, + { name = "loguru", specifier = "==0.7.3" }, + { name = "rich", specifier = "==14.2.0" }, ] provides-extras = ["click"] @@ -199,30 +129,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.10'" }, -] -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 = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.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" } wheels = [ @@ -256,25 +168,10 @@ 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" }, ] -[[package]] -name = "platformdirs" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, -] - [[package]] name = "platformdirs" version = "4.5.1" 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" } 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" }, @@ -289,38 +186,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "pre-commit" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -dependencies = [ - { name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, 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'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, -] - [[package]] name = "pre-commit" version = "4.5.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" }, + { name = "identify" }, + { 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" } wheels = [ @@ -336,42 +211,18 @@ 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" }, ] -[[package]] -name = "pytest" -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 = "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'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - [[package]] name = "pytest" version = "9.0.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 = "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" }, + { 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" } wheels = [ @@ -440,15 +291,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, - { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, - { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, - { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] [[package]] @@ -456,8 +298,7 @@ name = "rich" 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" }, { 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" } @@ -555,10 +396,8 @@ version = "20.35.4" 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.1", 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 = "filelock" }, + { name = "platformdirs" }, { 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" }