From 4b5c172f9acc04d62628a52673e32d7057089dca Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Tue, 30 Dec 2025 10:19:33 +0100 Subject: [PATCH 1/6] feat: Update Python version requirement to 3.10 and enhance environment variable parsing - Changed the minimum required Python version from 3.9 to 3.10 in pyproject.toml. - Added a new function `_parse_bool_env` in struct.py to parse boolean values from environment variables. - Introduced new keywords for package metadata in pyproject.toml. - Added tests for logger context management and log level filtering in test_core.py. - Updated imports in test_rich.py for consistency. - Cleaned up dependency specifications in uv.lock to remove version markers for compatibility. --- .github/workflows/ci.yml | 4 +- AGENTS.md | 2 +- README.md | 3 +- examples/base.py | 7 +- logurich/__init__.py | 11 +- logurich/__init__.pyi | 12 +- logurich/console.py | 12 +- logurich/core.py | 309 +++++++++++++++++---------------------- logurich/core.pyi | 14 +- logurich/handler.py | 10 +- logurich/opt_click.py | 92 ++++++++++-- logurich/struct.py | 12 ++ pyproject.toml | 36 ++++- tests/test_core.py | 68 ++++++++- tests/test_rich.py | 3 +- uv.lock | 203 +++---------------------- 16 files changed, 408 insertions(+), 390 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36d33a4..9055a92 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 logurich/ - name: Run tests run: uv run -m pytest -q 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..afef092 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. diff --git a/examples/base.py b/examples/base.py index 15a1679..c2653ce 100644 --- a/examples/base.py +++ b/examples/base.py @@ -42,9 +42,14 @@ def create_rich_table(): "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" + ) + # 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/logurich/__init__.py b/logurich/__init__.py index 3e0f8b4..82d3579 100644 --- a/logurich/__init__.py +++ b/logurich/__init__.py @@ -1,15 +1,18 @@ __version__ = "0.4.0" -from .console import configure_console, get_console, rich_to_str, set_console +from .console import configure_console, console, get_console, rich_to_str, set_console from .core import ( + LOG_LEVEL_CHOICES, ContextValue, ctx, global_configure, global_set_context, init_logger, logger, - LOG_LEVEL_CHOICES, mp_configure, + propagate_loguru_to_std_logger, + restore_level, + set_level, ) init_logger("INFO") @@ -23,7 +26,11 @@ "ContextValue", "ctx", "LOG_LEVEL_CHOICES", + "propagate_loguru_to_std_logger", + "restore_level", + "set_level", "configure_console", + "console", "get_console", "set_console", "rich_to_str", diff --git a/logurich/__init__.pyi b/logurich/__init__.pyi index 0641775..a6816a1 100644 --- a/logurich/__init__.pyi +++ b/logurich/__init__.pyi @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import ContextManager, Final, Mapping, Protocol +from collections.abc import Mapping +from contextlib import AbstractContextManager +from typing import Final, Protocol from rich.console import Console @@ -17,6 +19,7 @@ ContextBinding = ContextValue | _SupportsStr | None __version__: Final[str] logger: LoguRich +console: Console LOG_LEVEL_CHOICES: Final[tuple[str, ...]] def ctx( @@ -39,10 +42,15 @@ def init_logger( diagnose: bool = False, enqueue: bool = True, highlight: bool = False, + rotation: str | int | None = "12:00", + retention: str | int | None = "10 days", ) -> str | None: ... def mp_configure(logger_: LoguRich) -> None: ... -def global_configure(**kwargs: ContextBinding) -> ContextManager[None]: ... +def global_configure(**kwargs: ContextBinding) -> AbstractContextManager[None]: ... def global_set_context(**kwargs: ContextBinding) -> None: ... +def propagate_loguru_to_std_logger() -> None: ... +def restore_level() -> None: ... +def set_level(level: str) -> None: ... def configure_console(*args: object, **kwargs: object) -> Console: ... def get_console() -> Console: ... def set_console(console: Console) -> None: ... diff --git a/logurich/console.py b/logurich/console.py index 5e6620a..cf98347 100644 --- a/logurich/console.py +++ b/logurich/console.py @@ -45,7 +45,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,9 +65,12 @@ def rich_console_renderer( return renderable -def set_console(console: Console): +def set_console(console: Console) -> None: global _console - _console = console + if _console is None: + _console = console + return + _console.__dict__ = console.__dict__ def get_console() -> Console: @@ -91,3 +94,6 @@ def configure_console(*args: Any, **kwargs: Any) -> Console: _console = get_console() _console.__dict__ = new_console.__dict__ return _console + + +console = get_console() diff --git a/logurich/core.py b/logurich/core.py index c084cdc..402bb83 100644 --- a/logurich/core.py +++ b/logurich/core.py @@ -4,10 +4,10 @@ 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 @@ -18,13 +18,13 @@ from .console import rich_console_renderer, rich_to_str from .handler import CustomHandler, CustomRichHandler -from .struct import extra_logger +from .struct import _parse_bool_env, extra_logger -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,7 +34,7 @@ def rich_logger( ) -_Logger.rich = partialmethod(rich_logger) +_Logger.rich = partialmethod(_rich_logger) logger = _logger @@ -93,10 +93,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,73 +105,6 @@ 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 @@ -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,106 @@ 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", {})) +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_configure(**kwargs): + global_set_context(**kwargs) + try: + yield + finally: + global_set_context(**dict.fromkeys(kwargs)) + + +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 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 propagate_loguru_to_std_logger(): + logger.remove() + logger.add(_PropagateHandler(), format="{message}") + + def mp_configure(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: @@ -495,32 +452,22 @@ def mp_configure(logger_): >>> p = Process(target=worker, args=(logger,)) >>> p.start() """ - 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) + logging.basicConfig(handlers=[_InterceptHandler()], level=0, force=True) + _reinstall_loguru(logger, 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, + rotation: str | int | None = "12:00", + retention: str | int | None = "10 days", ) -> str: """Initialize and configure the logger with rich formatting and customized handlers. @@ -548,6 +495,12 @@ 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. @@ -557,18 +510,19 @@ def init_logger( >>> 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 +531,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 +542,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/logurich/core.pyi b/logurich/core.pyi index b87277c..be7a09d 100644 --- a/logurich/core.pyi +++ b/logurich/core.pyi @@ -1,11 +1,23 @@ from __future__ import annotations -from typing import Literal +from typing import Any, Literal from loguru._logger import Logger as _Logger from rich.console import ConsoleRenderable +from .core import ContextValue + 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: ... def rich( self, log_level: str, diff --git a/logurich/handler.py b/logurich/handler.py index 4053479..0c8ba29 100644 --- a/logurich/handler.py +++ b/logurich/handler.py @@ -1,4 +1,3 @@ -import os from datetime import datetime from logging import Handler, LogRecord from pathlib import Path @@ -10,9 +9,8 @@ from rich.table import Table from rich.text import Text -from .struct import extra_logger - -from .console import rich_console_renderer, get_console +from .console import get_console, rich_console_renderer +from .struct import _parse_bool_env, extra_logger class CustomRichHandler(RichHandler): @@ -75,7 +73,7 @@ class CustomHandler(Handler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.highlighter = ReprHighlighter() - self.serialize = os.environ.get("LOGURU_SERIALIZE") + self.serialize = _parse_bool_env("LOGURU_SERIALIZE") def emit(self, record): console = get_console() @@ -88,7 +86,7 @@ def emit(self, record): 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") + conf_rich_highlight = extra_logger.get("__rich_highlight") try: if record.msg: p = Text.from_markup(prefix) diff --git a/logurich/opt_click.py b/logurich/opt_click.py index ab1c5b2..30066c7 100644 --- a/logurich/opt_click.py +++ b/logurich/opt_click.py @@ -1,20 +1,65 @@ +"""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. -def click_logger_params(func): + 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", @@ -41,8 +86,15 @@ def click_logger_params(func): 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, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: missing = [name for name in LOGGER_PARAM_NAMES if name not in kwargs] if missing: raise RuntimeError( @@ -54,16 +106,34 @@ def wrapper(*args, **kwargs): click_logger_init(**logger_kwargs) return func(*args, **kwargs) - return wrapper + return wrapper # type: ignore[return-value] def click_logger_init( - logger_level, - logger_verbose, - logger_filename, - logger_level_by_module, - logger_diagnose, -): + 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 @@ -73,10 +143,12 @@ def click_logger_init( 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/struct.py b/logurich/struct.py index 31506af..f9fd0f0 100644 --- a/logurich/struct.py +++ b/logurich/struct.py @@ -1,6 +1,18 @@ +import os + extra_logger = { "__min_level": None, "__level_upper_only": None, "__level_per_module": None, "__rich_highlight": False, } + + +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/pyproject.toml b/pyproject.toml index 570f24f..3f6d446 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,17 @@ authors = [ license = "MIT" license-files = ["LICENSE"] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" +keywords = [ + "logging", + "loguru", + "rich", + "console", + "terminal", + "colored-logging", + "structured-logging", + "pretty-logging", +] dependencies = [ "loguru", "rich" @@ -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", @@ -52,3 +61,26 @@ include-package-data = true [build-system] requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[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 +] + +[tool.ruff.lint.isort] +known-first-party = ["logurich"] diff --git a/tests/test_core.py b/tests/test_core.py index f318fac..0f94df6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,7 +2,16 @@ import pytest -from logurich import ctx, global_configure, global_set_context, init_logger +from logurich import ( + ContextValue, + ctx, + global_configure, + global_set_context, + init_logger, + logger, + restore_level, + set_level, +) @pytest.mark.parametrize( @@ -103,3 +112,60 @@ 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" + + +def test_logger_ctx_returns_context_value(): + """logger.ctx() should return a ContextValue identical to standalone ctx().""" + result = logger.ctx("value", style="cyan", label="lbl", show_key=True) + expected = ctx("value", style="cyan", label="lbl", show_key=True) + assert isinstance(result, ContextValue) + assert result == expected + + +@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): + """set_level() should temporarily raise the minimum log level.""" + logger.debug("before set_level") + set_level("WARNING") + logger.debug("should be filtered") + logger.info("also filtered") + logger.warning("should appear") + logger.complete() + output = buffer.getvalue() + assert "before set_level" 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): + """restore_level() should reset the log level to the original.""" + set_level("ERROR") + logger.warning("filtered warning") + restore_level() + 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_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..b9016ef 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,16 +91,13 @@ 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" }, ] @@ -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" } From 99e83881dc3c92a26e8c8f075e1f304d62c0768d Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Tue, 30 Dec 2025 10:25:42 +0100 Subject: [PATCH 2/6] feat: Enhance global configuration to restore previous context and improve logger behavior --- logurich/__init__.pyi | 11 +++-------- logurich/core.py | 29 ++++++++++++++++++++++++++--- logurich/opt_click.py | 1 + tests/test_core.py | 24 ++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/logurich/__init__.pyi b/logurich/__init__.pyi index a6816a1..8045d98 100644 --- a/logurich/__init__.pyi +++ b/logurich/__init__.pyi @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Mapping from contextlib import AbstractContextManager -from typing import Final, Protocol +from typing import Any, Final from rich.console import Console @@ -11,11 +11,6 @@ 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 @@ -46,8 +41,8 @@ def init_logger( retention: str | int | None = "10 days", ) -> str | None: ... def mp_configure(logger_: LoguRich) -> None: ... -def global_configure(**kwargs: ContextBinding) -> AbstractContextManager[None]: ... -def global_set_context(**kwargs: ContextBinding) -> None: ... +def global_configure(**kwargs: Any) -> AbstractContextManager[None]: ... +def global_set_context(**kwargs: Any) -> None: ... def propagate_loguru_to_std_logger() -> None: ... def restore_level() -> None: ... def set_level(level: str) -> None: ... diff --git a/logurich/core.py b/logurich/core.py index 402bb83..3fb5518 100644 --- a/logurich/core.py +++ b/logurich/core.py @@ -386,11 +386,34 @@ def ctx( @contextlib.contextmanager def global_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_set_context(**kwargs) try: yield finally: - global_set_context(**dict.fromkeys(kwargs)) + 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_set_context(**kwargs): @@ -468,7 +491,7 @@ def init_logger( highlight: bool = False, rotation: str | int | None = "12:00", retention: str | int | None = "10 days", -) -> str: +) -> 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. @@ -503,7 +526,7 @@ def init_logger( 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") diff --git a/logurich/opt_click.py b/logurich/opt_click.py index 30066c7..26ce659 100644 --- a/logurich/opt_click.py +++ b/logurich/opt_click.py @@ -60,6 +60,7 @@ def click_logger_params(func: F) -> F: Raises: RuntimeError: If logger parameters are missing from the function invocation. """ + @click.option( "-l", "--logger-level", diff --git a/tests/test_core.py b/tests/test_core.py index 0f94df6..faae465 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -69,6 +69,30 @@ def test_global_configure(logger, buffer): 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_configure(exec_id=ctx("outer_ctx", style="yellow")): + logger.info("outer message") + with global_configure(exec_id=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}], From 691fd0d9d0b399b32025624aa4b8722209926675 Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Tue, 30 Dec 2025 11:07:08 +0100 Subject: [PATCH 3/6] feat: Refactor console and logging functions for improved clarity and consistency --- logurich/__init__.py | 28 ++++---- logurich/__init__.pyi | 14 ++-- logurich/console.py | 14 ++-- logurich/core.py | 13 ++-- logurich/handler.py | 161 +++++++++++++++++++++++++++++++++--------- logurich/struct.py | 12 ---- logurich/utils.py | 11 +++ tests/conftest.py | 4 +- tests/test_core.py | 18 ++--- 9 files changed, 186 insertions(+), 89 deletions(-) create mode 100644 logurich/utils.py diff --git a/logurich/__init__.py b/logurich/__init__.py index 82d3579..ba59316 100644 --- a/logurich/__init__.py +++ b/logurich/__init__.py @@ -1,37 +1,41 @@ __version__ = "0.4.0" -from .console import configure_console, console, get_console, rich_to_str, set_console +from .console import ( + console, + rich_configure_console, + rich_get_console, + rich_set_console, + rich_to_str, +) from .core import ( LOG_LEVEL_CHOICES, - ContextValue, ctx, global_configure, global_set_context, init_logger, + level_restore, + level_set, logger, mp_configure, propagate_loguru_to_std_logger, - restore_level, - set_level, ) init_logger("INFO") __all__ = [ "logger", + "ctx", "init_logger", "mp_configure", "global_configure", "global_set_context", - "ContextValue", - "ctx", - "LOG_LEVEL_CHOICES", "propagate_loguru_to_std_logger", - "restore_level", - "set_level", - "configure_console", + "level_restore", + "level_set", "console", - "get_console", - "set_console", + "rich_configure_console", + "rich_get_console", + "rich_set_console", "rich_to_str", + "LOG_LEVEL_CHOICES", ] diff --git a/logurich/__init__.pyi b/logurich/__init__.pyi index 8045d98..208205e 100644 --- a/logurich/__init__.pyi +++ b/logurich/__init__.pyi @@ -6,7 +6,7 @@ from typing import Any, Final from rich.console import Console -from .core import ContextValue, LogLevel, LoguRich +from .core import LogLevel, LoguRich LevelByModuleValue = str | int | bool LevelByModuleMapping = Mapping[str | None, LevelByModuleValue] @@ -25,7 +25,7 @@ def ctx( bracket_style: str | None = None, label: str | None = None, show_key: bool | None = None, -) -> ContextValue: ... +) -> object: ... def init_logger( log_level: LogLevel, log_verbose: int = 0, @@ -44,11 +44,11 @@ def mp_configure(logger_: LoguRich) -> None: ... def global_configure(**kwargs: Any) -> AbstractContextManager[None]: ... def global_set_context(**kwargs: Any) -> None: ... def propagate_loguru_to_std_logger() -> None: ... -def restore_level() -> None: ... -def set_level(level: str) -> None: ... -def configure_console(*args: object, **kwargs: object) -> Console: ... -def get_console() -> Console: ... -def set_console(console: Console) -> None: ... +def level_restore() -> None: ... +def level_set(level: str) -> 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/logurich/console.py index cf98347..4610da4 100644 --- a/logurich/console.py +++ b/logurich/console.py @@ -11,7 +11,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 +34,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) @@ -65,7 +65,7 @@ def rich_console_renderer( return renderable -def set_console(console: Console) -> None: +def rich_set_console(console: Console) -> None: global _console if _console is None: _console = console @@ -73,14 +73,14 @@ def set_console(console: Console) -> None: _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: @@ -91,9 +91,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 = get_console() +console = rich_get_console() diff --git a/logurich/core.py b/logurich/core.py index 3fb5518..1feafc5 100644 --- a/logurich/core.py +++ b/logurich/core.py @@ -18,7 +18,8 @@ from .console import rich_console_renderer, rich_to_str from .handler import CustomHandler, CustomRichHandler -from .struct import _parse_bool_env, extra_logger +from .struct import extra_logger +from .utils import parse_bool_env def _rich_logger( @@ -175,7 +176,7 @@ class _Formatter: } def __init__(self, log_level, verbose: int, is_rich_handler: bool = False): - self.serialize = _parse_bool_env("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 @@ -437,12 +438,12 @@ def global_set_context(**kwargs): logger.configure(extra=extra_logger) -def set_level(level: str): +def level_set(level: str): extra_logger.update({"__level_upper_only": level}) logger.configure(extra=extra_logger) -def restore_level(): +def level_restore(): extra_logger.update({"__level_upper_only": None}) logger.configure(extra=extra_logger) @@ -534,7 +535,7 @@ def init_logger( >>> logger.debug("Debug information") # Won't be displayed with INFO level """ if rich_handler is False: - env_rich_handler = _parse_bool_env("LOGURU_RICH") + 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) @@ -565,7 +566,7 @@ def init_logger( else: handler = CustomHandler() # Add handler with common configuration - serialize = bool(_parse_bool_env("LOGURU_SERIALIZE")) + serialize = bool(parse_bool_env("LOGURU_SERIALIZE")) logger.add( handler, level=0, diff --git a/logurich/handler.py b/logurich/handler.py index 0c8ba29..b8805e3 100644 --- a/logurich/handler.py +++ b/logurich/handler.py @@ -1,6 +1,16 @@ +"""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 @@ -9,21 +19,54 @@ from rich.table import Table from rich.text import Text -from .console import get_console, rich_console_renderer -from .struct import _parse_bool_env, extra_logger +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): - def __init__(self, *args, **kwargs): - super().__init__(*args, console=get_console(), **kwargs) - self._padding = 10 + """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 emit(self, record): + 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): - row = [] - list_context = record.extra.get("_build_list_context", []) + 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") @@ -36,22 +79,38 @@ def build_content(self, record: LogRecord, content): grid.add_row(*row) return grid - def render(self, *, record: LogRecord, traceback, message_renderable): + 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 = [] + renderables: list[RenderableType] = [] 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) + for item in rich_console: + if isinstance(item, (ConsoleRenderable, str)): + renderables.append(item) else: - renderables.append(Pretty(a)) + renderables.append(Pretty(item)) else: renderables.append(self.build_content(record, message_renderable)) if traceback and rich_tb: @@ -70,36 +129,70 @@ def render(self, *, record: LogRecord, traceback, message_renderable): class CustomHandler(Handler): - def __init__(self, *args, **kwargs): + """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 = _parse_bool_env("LOGURU_SERIALIZE") + 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): - console = get_console() - end = record.extra.get("end", "\n") + 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: - console.out(record.msg, highlight=False, end="") + self._console.out(record.msg, highlight=False, end="") return - prefix = record.extra["_prefix"] - list_context = record.extra.get("_build_list_context", []) + + 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") - rich_highlight = record.extra.get("rich_highlight") - conf_rich_highlight = extra_logger.get("__rich_highlight") + try: if record.msg: - p = Text.from_markup(prefix) - t = p.copy() + prefix_text = Text.from_markup(prefix) + output_text = prefix_text.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) + 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) - console.print(*renderable, end=end, highlight=False) + self._console.print(*renderable, end=end, highlight=False) except Exception: self.handleError(record) diff --git a/logurich/struct.py b/logurich/struct.py index f9fd0f0..31506af 100644 --- a/logurich/struct.py +++ b/logurich/struct.py @@ -1,18 +1,6 @@ -import os - extra_logger = { "__min_level": None, "__level_upper_only": None, "__level_per_module": None, "__rich_highlight": False, } - - -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/logurich/utils.py b/logurich/utils.py new file mode 100644 index 0000000..5d0466a --- /dev/null +++ b/logurich/utils.py @@ -0,0 +1,11 @@ +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 faae465..128f24f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,8 +9,8 @@ global_set_context, init_logger, logger, - restore_level, - set_level, + level_restore, + level_set, ) @@ -164,15 +164,15 @@ def test_logger_ctx_in_bind(logger, buffer): indirect=True, ) def test_set_level_filters_messages(logger, buffer): - """set_level() should temporarily raise the minimum log level.""" - logger.debug("before set_level") - set_level("WARNING") + """level_set() should temporarily raise the minimum log level.""" + logger.debug("before level_set") + level_set("WARNING") logger.debug("should be filtered") logger.info("also filtered") logger.warning("should appear") logger.complete() output = buffer.getvalue() - assert "before set_level" in output + assert "before level_set" in output assert "should be filtered" not in output assert "also filtered" not in output assert "should appear" in output @@ -184,10 +184,10 @@ def test_set_level_filters_messages(logger, buffer): indirect=True, ) def test_restore_level_resets_filtering(logger, buffer): - """restore_level() should reset the log level to the original.""" - set_level("ERROR") + """level_restore() should reset the log level to the original.""" + level_set("ERROR") logger.warning("filtered warning") - restore_level() + level_restore() logger.debug("after restore") logger.complete() output = buffer.getvalue() From f54fdeb8fcc22a58f3180be887c9adcc29f1bb2e Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Tue, 30 Dec 2025 11:30:46 +0100 Subject: [PATCH 4/6] feat: Refactor logging context management and enhance logger functionality --- README.md | 6 ++++++ examples/base.py | 14 ++++++++++---- examples/mp_adv_data_processing.py | 20 +++++++++++--------- examples/mp_example.py | 12 ++++++++---- logurich/__init__.py | 20 +++++++------------- logurich/__init__.pyi | 16 ++-------------- logurich/console.py | 2 ++ logurich/core.py | 27 ++++++++++++++++----------- logurich/core.pyi | 6 ++++++ logurich/struct.py | 2 ++ logurich/utils.py | 2 ++ tests/test_core.py | 26 ++++++++++++-------------- tests/test_mp.py | 19 +++++++++---------- 13 files changed, 93 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index afef092..b13b9f4 100644 --- a/README.md +++ b/README.md @@ -37,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 c2653ce..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,14 +31,14 @@ 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" ) @@ -47,6 +47,12 @@ def create_rich_table(): "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 # noqa: B018 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 index ba59316..3671be0 100644 --- a/logurich/__init__.py +++ b/logurich/__init__.py @@ -1,3 +1,5 @@ +"""Public package exports for logurich.""" + __version__ = "0.4.0" from .console import ( @@ -9,29 +11,21 @@ ) from .core import ( LOG_LEVEL_CHOICES, - ctx, - global_configure, - global_set_context, + global_context_configure, + global_context_set, init_logger, - level_restore, - level_set, logger, - mp_configure, propagate_loguru_to_std_logger, ) init_logger("INFO") __all__ = [ - "logger", - "ctx", "init_logger", - "mp_configure", - "global_configure", - "global_set_context", + "logger", + "global_context_configure", + "global_context_set", "propagate_loguru_to_std_logger", - "level_restore", - "level_set", "console", "rich_configure_console", "rich_get_console", diff --git a/logurich/__init__.pyi b/logurich/__init__.pyi index 208205e..19f378e 100644 --- a/logurich/__init__.pyi +++ b/logurich/__init__.pyi @@ -17,15 +17,6 @@ logger: LoguRich console: Console 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, -) -> object: ... def init_logger( log_level: LogLevel, log_verbose: int = 0, @@ -40,12 +31,9 @@ def init_logger( rotation: str | int | None = "12:00", retention: str | int | None = "10 days", ) -> str | None: ... -def mp_configure(logger_: LoguRich) -> None: ... -def global_configure(**kwargs: Any) -> AbstractContextManager[None]: ... -def global_set_context(**kwargs: Any) -> None: ... +def global_context_configure(**kwargs: Any) -> AbstractContextManager[None]: ... +def global_context_set(**kwargs: Any) -> None: ... def propagate_loguru_to_std_logger() -> None: ... -def level_restore() -> None: ... -def level_set(level: str) -> None: ... def rich_configure_console(*args: object, **kwargs: object) -> Console: ... def rich_get_console() -> Console: ... def rich_set_console(console: Console) -> None: ... diff --git a/logurich/console.py b/logurich/console.py index 4610da4..6f431f7 100644 --- a/logurich/console.py +++ b/logurich/console.py @@ -1,3 +1,5 @@ +"""Rich console helpers for logurich rendering.""" + from __future__ import annotations from typing import Any diff --git a/logurich/core.py b/logurich/core.py index 1feafc5..2c86dbc 100644 --- a/logurich/core.py +++ b/logurich/core.py @@ -1,3 +1,5 @@ +"""Core logging configuration and helpers for logurich.""" + from __future__ import annotations import contextlib @@ -109,10 +111,6 @@ def render(self, key: str, *, is_rich_handler: bool) -> str: 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}" @@ -386,7 +384,7 @@ def ctx( @contextlib.contextmanager -def global_configure(**kwargs): +def global_context_configure(**kwargs): previous = {} for key in kwargs: normalized_key = _normalize_context_key(key) @@ -398,7 +396,7 @@ def global_configure(**kwargs): for existing in matching_keys: if existing not in previous: previous[existing] = extra_logger[existing] - global_set_context(**kwargs) + global_context_set(**kwargs) try: yield finally: @@ -417,7 +415,7 @@ def global_configure(**kwargs): logger.configure(extra=extra_logger) -def global_set_context(**kwargs): +def global_context_set(**kwargs): for key, value in kwargs.items(): normalized_key = _normalize_context_key(key) normalized_value = _coerce_context_value(value) @@ -438,7 +436,7 @@ def global_set_context(**kwargs): logger.configure(extra=extra_logger) -def level_set(level: str): +def level_set(level: LogLevel): extra_logger.update({"__level_upper_only": level}) logger.configure(extra=extra_logger) @@ -448,12 +446,16 @@ def level_restore(): 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 mp_configure(logger_): +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. @@ -469,8 +471,8 @@ 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,)) @@ -480,6 +482,9 @@ def mp_configure(logger_): _reinstall_loguru(logger, logger_) +_Logger.configure_child_logger = staticmethod(configure_child_logger) + + def init_logger( log_level: LogLevel, log_verbose: int = 0, diff --git a/logurich/core.pyi b/logurich/core.pyi index be7a09d..ac35049 100644 --- a/logurich/core.pyi +++ b/logurich/core.pyi @@ -18,6 +18,12 @@ class LoguRich(_Logger): 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, diff --git a/logurich/struct.py b/logurich/struct.py index 31506af..665e3db 100644 --- a/logurich/struct.py +++ b/logurich/struct.py @@ -1,3 +1,5 @@ +"""Shared logger configuration state.""" + extra_logger = { "__min_level": None, "__level_upper_only": None, diff --git a/logurich/utils.py b/logurich/utils.py index 5d0466a..2dd17ed 100644 --- a/logurich/utils.py +++ b/logurich/utils.py @@ -1,3 +1,5 @@ +"""Utility helpers for logurich.""" + import os diff --git a/tests/test_core.py b/tests/test_core.py index 128f24f..8af2d33 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,14 +4,12 @@ from logurich import ( ContextValue, - ctx, - global_configure, - global_set_context, + global_context_configure, + global_context_set, init_logger, logger, - level_restore, - level_set, ) +from logurich.core import ctx @pytest.mark.parametrize( @@ -62,7 +60,7 @@ 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() @@ -75,9 +73,9 @@ def test_global_configure(logger, buffer): indirect=True, ) def test_global_configure_restores_previous(logger, buffer): - with global_configure(exec_id=ctx("outer_ctx", style="yellow")): + with global_context_configure(exec_id=logger.ctx("outer_ctx", style="yellow")): logger.info("outer message") - with global_configure(exec_id=ctx("inner_ctx", style="cyan")): + 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") @@ -99,7 +97,7 @@ def test_global_configure_restores_previous(logger, buffer): 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() @@ -112,12 +110,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( @@ -166,7 +164,7 @@ def test_logger_ctx_in_bind(logger, buffer): def test_set_level_filters_messages(logger, buffer): """level_set() should temporarily raise the minimum log level.""" logger.debug("before level_set") - level_set("WARNING") + logger.level_set("WARNING") logger.debug("should be filtered") logger.info("also filtered") logger.warning("should appear") @@ -185,9 +183,9 @@ def test_set_level_filters_messages(logger, buffer): ) def test_restore_level_resets_filtering(logger, buffer): """level_restore() should reset the log level to the original.""" - level_set("ERROR") + logger.level_set("ERROR") logger.warning("filtered warning") - level_restore() + logger.level_restore() logger.debug("after restore") logger.complete() output = buffer.getvalue() 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() From 67e40c8179891edf948c81cf32d78ff0bb2f8465 Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Tue, 30 Dec 2025 11:50:41 +0100 Subject: [PATCH 5/6] refactor(src): added src folder - Updated `pyproject.toml` to use `uv_build` as the build backend. - Added core logging functionalities in `core.py` and `core.pyi`. - Introduced custom logging handlers in `handler.py` for enhanced console output. - Implemented Click integration for logging in `opt_click.py`. - Created utility functions in `utils.py` for environment variable parsing. - Established shared logger configuration state in `struct.py`. - Added type hinting support with `py.typed` file. - Enhanced public API with context management features. - Removed unused imports and tests related to `ContextValue`. --- pyproject.toml | 10 ++-------- {logurich => src/logurich}/__init__.py | 4 ++++ {logurich => src/logurich}/__init__.pyi | 0 {logurich => src/logurich}/console.py | 0 {logurich => src/logurich}/core.py | 1 + {logurich => src/logurich}/core.pyi | 18 +++++++++++++++++- {logurich => src/logurich}/handler.py | 0 {logurich => src/logurich}/opt_click.py | 0 {logurich => src/logurich}/py.typed | 0 {logurich => src/logurich}/struct.py | 0 {logurich => src/logurich}/utils.py | 0 tests/test_core.py | 11 ----------- 12 files changed, 24 insertions(+), 20 deletions(-) rename {logurich => src/logurich}/__init__.py (91%) rename {logurich => src/logurich}/__init__.pyi (100%) rename {logurich => src/logurich}/console.py (100%) rename {logurich => src/logurich}/core.py (99%) rename {logurich => src/logurich}/core.pyi (68%) rename {logurich => src/logurich}/handler.py (100%) rename {logurich => src/logurich}/opt_click.py (100%) rename {logurich => src/logurich}/py.typed (100%) rename {logurich => src/logurich}/struct.py (100%) rename {logurich => src/logurich}/utils.py (100%) diff --git a/pyproject.toml b/pyproject.toml index 3f6d446..ebfd111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,15 +52,9 @@ dev = [ "ruff", ] -[tool.setuptools] -include-package-data = true - -[tool.setuptools.package-data] -"logurich" = ["py.typed", "*.pyi"] - [build-system] -requires = ["setuptools>=77", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["uv_build>=0.9.20,<0.10.0"] +build-backend = "uv_build" [tool.ruff] line-length = 88 diff --git a/logurich/__init__.py b/src/logurich/__init__.py similarity index 91% rename from logurich/__init__.py rename to src/logurich/__init__.py index 3671be0..4ac3aca 100644 --- a/logurich/__init__.py +++ b/src/logurich/__init__.py @@ -11,6 +11,8 @@ ) from .core import ( LOG_LEVEL_CHOICES, + LogLevel, + LoguRich, global_context_configure, global_context_set, init_logger, @@ -32,4 +34,6 @@ "rich_set_console", "rich_to_str", "LOG_LEVEL_CHOICES", + "LogLevel", + "LoguRich", ] diff --git a/logurich/__init__.pyi b/src/logurich/__init__.pyi similarity index 100% rename from logurich/__init__.pyi rename to src/logurich/__init__.pyi diff --git a/logurich/console.py b/src/logurich/console.py similarity index 100% rename from logurich/console.py rename to src/logurich/console.py diff --git a/logurich/core.py b/src/logurich/core.py similarity index 99% rename from logurich/core.py rename to src/logurich/core.py index 2c86dbc..0217705 100644 --- a/logurich/core.py +++ b/src/logurich/core.py @@ -39,6 +39,7 @@ def _rich_logger( _Logger.rich = partialmethod(_rich_logger) logger = _logger +LoguRich = _Logger COLOR_ALIASES = { diff --git a/logurich/core.pyi b/src/logurich/core.pyi similarity index 68% rename from logurich/core.pyi rename to src/logurich/core.pyi index ac35049..e47c95d 100644 --- a/logurich/core.pyi +++ b/src/logurich/core.pyi @@ -5,7 +5,23 @@ from typing import Any, Literal from loguru._logger import Logger as _Logger from rich.console import ConsoleRenderable -from .core import ContextValue +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 diff --git a/logurich/handler.py b/src/logurich/handler.py similarity index 100% rename from logurich/handler.py rename to src/logurich/handler.py diff --git a/logurich/opt_click.py b/src/logurich/opt_click.py similarity index 100% rename from logurich/opt_click.py rename to src/logurich/opt_click.py 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 100% rename from logurich/struct.py rename to src/logurich/struct.py diff --git a/logurich/utils.py b/src/logurich/utils.py similarity index 100% rename from logurich/utils.py rename to src/logurich/utils.py diff --git a/tests/test_core.py b/tests/test_core.py index 8af2d33..ece1df9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,13 +3,10 @@ import pytest from logurich import ( - ContextValue, global_context_configure, global_context_set, init_logger, - logger, ) -from logurich.core import ctx @pytest.mark.parametrize( @@ -136,14 +133,6 @@ def test_loguru_serialize_env(monkeypatch, logger, level, enqueue, buffer): assert payload["record"]["message"] == "Serialized output" -def test_logger_ctx_returns_context_value(): - """logger.ctx() should return a ContextValue identical to standalone ctx().""" - result = logger.ctx("value", style="cyan", label="lbl", show_key=True) - expected = ctx("value", style="cyan", label="lbl", show_key=True) - assert isinstance(result, ContextValue) - assert result == expected - - @pytest.mark.parametrize( "logger", [{"level": "DEBUG", "enqueue": False}], From 067ccd00fb283e6ca0e771b7bb1abfa8411ccca2 Mon Sep 17 00:00:00 2001 From: PakitoSec Date: Tue, 30 Dec 2025 11:53:58 +0100 Subject: [PATCH 6/6] feat: Update CI configuration and dependencies for improved consistency and performance --- .github/workflows/ci.yml | 2 +- .pre-commit-config.yaml | 8 -------- pyproject.toml | 4 ++-- requirements.txt | 12 ------------ uv.lock | 4 ++-- 5 files changed, 5 insertions(+), 25 deletions(-) delete mode 100644 requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9055a92..3ec3a07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: Install deps run: uv sync --group dev - name: Run ruff - run: uv run ruff check logurich/ + 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/pyproject.toml b/pyproject.toml index ebfd111..5850481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,8 @@ keywords = [ "pretty-logging", ] dependencies = [ - "loguru", - "rich" + "loguru==0.7.3", + "rich==14.2.0" ] classifiers = [ "Development Status :: 4 - Beta", 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/uv.lock b/uv.lock index b9016ef..54b18e8 100644 --- a/uv.lock +++ b/uv.lock @@ -104,8 +104,8 @@ dev = [ [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"]