Source code for tiered_debug._base

"""Base implementation for tiered debug logging.

The `TieredDebug` class provides multi-level debug logging with
configurable stack tracing for accurate caller reporting. It supports
logging at levels 1-5, with level 1 always logged and levels 2-5
conditional on the configured debug level. Designed for projects like
ElasticKeeper and ElasticCheckpoint, it allows flexible logger
configuration and stack level adjustments.

Examples:
    >>> from tiered_debug._base import TieredDebug
    >>> debug = TieredDebug(level=2)
    >>> debug.level
    2
    >>> import logging
    >>> handler = logging.StreamHandler()
    >>> debug.add_handler(
    ...     handler, logging.Formatter("%(message)s")
    ... )
    >>> debug.lv1("Always logged")
    >>> debug.lv3("Not logged")  # Ignored (level 3 > 2)
"""

# pylint: disable=R0913,R0917,W0212
import logging
import platform
import sys
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from types import TracebackType
from typing import TYPE_CHECKING, Any, Literal, TypeAlias

_SysExcInfoType: TypeAlias = tuple[type[BaseException], BaseException, TracebackType | None] | tuple[None, None, None]
_ExcInfoType: TypeAlias = bool | _SysExcInfoType | BaseException | None

DebugLevel = Literal[1, 2, 3, 4, 5]
"""Type hint for debug level (1-5)."""

DEFAULTS = {"debug": 1, "stack": 3}
"""Default values for debug level (1) and stack level (3)."""


[docs] class TieredDebug: """Tiered debug logging with configurable levels and stack tracing. Supports debug logging at levels 1-5, with level 1 always logged and levels 2-5 conditional on the configured debug level. Allows custom stack levels for accurate caller reporting and flexible logger configuration via handlers. Args: level: Debug level (1-5, default 1). (int) stacklevel: Stack level for caller reporting (1-9, default 3). (int) logger_name: Name for the logger (default "tiered_debug._base"). (str) Attributes: level: Current debug level (1-5). (int) stacklevel: Current stack level for caller reporting (1-9). (int) logger: Configured logger instance. (logging.Logger) Examples: >>> debug = TieredDebug(level=2) >>> debug.level 2 >>> import logging >>> handler = logging.StreamHandler() >>> debug.add_handler( ... handler, logging.Formatter("%(message)s") ... ) >>> debug.lv1("Level 1 message") >>> debug.lv3("Level 3 message") # Not logged """ # Type stubs for dynamic methods (visible to type checkers) if TYPE_CHECKING: lv1: Callable[..., None] # pyright: ignore[reportUninitializedInstanceVariable] lv2: Callable[..., None] # pyright: ignore[reportUninitializedInstanceVariable] lv3: Callable[..., None] # pyright: ignore[reportUninitializedInstanceVariable] lv4: Callable[..., None] # pyright: ignore[reportUninitializedInstanceVariable] lv5: Callable[..., None] # pyright: ignore[reportUninitializedInstanceVariable] def __init__( self, level: int = DEFAULTS["debug"], stacklevel: int = DEFAULTS["stack"], logger_name: str = "tiered_debug._base", ) -> None: """Initialize a TieredDebug instance with specified settings.""" self.logger: logging.Logger = logging.getLogger(logger_name) self._level: int = self.check_val(level, "debug") self._stacklevel: int = self.check_val(stacklevel, "stack") @property def level(self) -> int: """Get the current debug level (1-5). Returns: int: Current debug level. Examples: >>> debug = TieredDebug(level=3) >>> debug.level 3 """ return self._level @level.setter def level(self, value: int) -> None: """Set the debug level, validating it is between 1 and 5. Args: value: Debug level to set (1-5). (int) """ self._level = self.check_val(value, "debug") @property def stacklevel(self) -> int: """Get the current stack level for caller reporting (1-9). Returns: int: Current stack level. Examples: >>> debug = TieredDebug(stacklevel=4) >>> debug.stacklevel 4 """ return self._stacklevel @stacklevel.setter def stacklevel(self, value: int) -> None: """Set the stack level, validating it is between 1 and 9. Args: value: Stack level to set (1-9). (int) """ self._stacklevel = self.check_val(value, "stack")
[docs] def check_val(self, val: int, kind: str) -> int: """Validate and return a debug or stack level, or default if invalid. Args: val: Value to validate. (int) kind: Type of value ("debug" or "stack"). (str) Returns: int: Validated value or default if invalid. Raises: ValueError: If kind is neither "debug" nor "stack". Examples: >>> debug = TieredDebug() >>> debug.check_val(3, "debug") 3 >>> debug.check_val(0, "debug") # Invalid, returns default 1 """ if kind == "debug": valid = 1 <= val <= 5 elif kind == "stack": valid = 1 <= val <= 9 else: raise ValueError(f"Invalid kind: {kind}. Must be 'debug' or 'stack'") if not valid: self.logger.warning( f"Invalid {kind} level: {val}. Using default: {DEFAULTS[kind]}" ) return DEFAULTS[kind] return val
[docs] def add_handler( self, handler: logging.Handler, formatter: logging.Formatter | None = None, ) -> None: """Add a handler to the logger if not already present. Args: handler: Handler to add to the logger. (logging.Handler) formatter: Optional formatter for the handler. (logging.Formatter) Examples: >>> debug = TieredDebug() >>> import logging >>> handler = logging.StreamHandler() >>> debug.add_handler(handler) >>> handler in debug.logger.handlers True """ if handler not in self.logger.handlers: if formatter: handler.setFormatter(formatter) handler.setLevel(logging.DEBUG) self.logger.addHandler(handler) self.logger.info("Handler added to logger") else: self.logger.info("Handler already attached to logger, skipping")
def _select_frame_getter(self) -> Any: """Select the appropriate frame getter based on Python implementation. Returns: Callable: sys._getframe for CPython, inspect.currentframe otherwise. Examples: >>> debug = TieredDebug() >>> import platform >>> if platform.python_implementation() == "CPython": ... assert debug._select_frame_getter() is sys._getframe """ return ( sys._getframe # pyright: ignore[reportPrivateUsage] if platform.python_implementation() == "CPython" else sys.modules["inspect"].currentframe ) def _get_logger_name(self, stack_level: int) -> str: """Get the module name from the call stack at the specified level. Args: stack_level: Stack level to inspect (1-9). (int) Returns: str: Module name or "unknown" if not found. Examples: >>> debug = TieredDebug() >>> debug._get_logger_name(1) '__main__' """ try: frame = self._select_frame_getter()(stack_level) return frame.f_globals.get("__name__", "unknown") except (ValueError, AttributeError) as e: self.logger.debug(f"Failed to access frame at level {stack_level}: {e}") return "unknown"
[docs] @contextmanager def change_level(self, level: int) -> Generator[None, None, None]: """Temporarily change the debug level within a context. Args: level: Debug level to set temporarily (1-5). (int) Examples: >>> debug = TieredDebug(level=2) >>> with debug.change_level(4): ... assert debug.level == 4 >>> debug.level 2 """ original_level = self.level self.level = self.check_val(level, "debug") try: yield finally: self.level = original_level
[docs] def log( self, level: DebugLevel, msg: Any, *args: Any, exc_info: _ExcInfoType = None, stack_info: bool | None = False, stacklevel: int | None = None, extra: Mapping[str, object] | None = None, ) -> None: """Log a message at the specified debug level. Args: level: Debug level for the message (1-5). (DebugLevel) msg: Message to log, optionally with format specifiers. (str) *args: Arguments for message formatting. exc_info: Include exception info if True. (bool) stack_info: Include stack trace if True. (bool) stacklevel: Stack level for caller reporting (1-9). (int) extra: Extra metadata dictionary. (Dict[str, Any]) Raises: ValueError: If level is not between 1 and 5. TypeError: If extra is not a dictionary or None. Examples: >>> debug = TieredDebug(level=2) >>> import logging >>> debug.add_handler(logging.StreamHandler()) >>> debug.log(1, "Level 1 message: %s", "test") >>> debug.log(3, "Level 3 message") # Not logged """ if not 1 <= level <= 5: raise ValueError("Debug level must be 1-5") if level > self.level: return if stack_info is None: stack_info = False if extra is None: extra = {} effective_stacklevel = self.stacklevel if stacklevel is None else stacklevel effective_stacklevel = self.check_val(effective_stacklevel, "stack") logger_name = self._get_logger_name(effective_stacklevel) logger = logging.getLogger(logger_name) logger.debug( f"DEBUG{level} {msg}", *args, exc_info=exc_info, stack_info=stack_info, stacklevel=effective_stacklevel, extra=extra, )
def _make_lv_method(level: int) -> Any: """Create a level-specific logging method. Args: level: Debug level (1-5). Returns: Callable: Method bound to the given level. """ doc_template = """Log a message at debug level {level}. Args: msg: Message to log, optionally with format specifiers. (str) *args: Arguments for message formatting. exc_info: Include exception info if True. (bool) stack_info: Include stack trace if True. (bool) stacklevel: Stack level for caller reporting (1-9). (int) extra: Extra metadata dictionary. (Dict[str, Any]) """ def lv_method( # We ignore these types because we're dynamically adding these methods self, # pyright: ignore[reportMissingParameterType,reportUnknownParameterType] msg: Any, *args: Any, exc_info: _ExcInfoType = None, stack_info: bool | None = False, stacklevel: int | None = None, extra: Mapping[str, object] | None = None, ) -> None: """Log a message at debug level {level}.""" # UnknownMemberType because it's dynamically added self.log( # pyright: ignore[reportUnknownMemberType] level, msg, *args, exc_info=exc_info, stack_info=stack_info, stacklevel=stacklevel, extra=extra, ) lv_method.__name__ = f"lv{level}" lv_method.__qualname__ = f"TieredDebug.lv{level}" lv_method.__doc__ = doc_template.format(level=level) # Unknown because we're dynamically addding methods to the class. return lv_method # pyright: ignore[reportUnknownVariableType] # Dynamically generate lv1 through lv5 methods for _lvl in range(1, 6): setattr(TieredDebug, f"lv{_lvl}", _make_lv_method(_lvl))