certflow.utils.logger 源代码

"""日志配置工具模块

基于Loguru库提供统一的日志配置,支持控制台输出和文件轮转输出。
使用单例模式确保全局只有一个日志实例。

支持通过 config.yaml 的 logging.debug_modules 配置模块级 DEBUG 屏蔽(黑名单模式)。
"""

from __future__ import annotations

import sys
from collections.abc import Callable
from pathlib import Path

from loguru import logger


class LoggerManager:
    """日志管理器(单例模式)

    管理全局日志实例的配置和生命周期,确保系统中只有一个日志实例。
    支持控制台输出和文件输出,文件输出支持自动轮转和压缩。

    Attributes:
        _instance: 单例实例
        _initialized: 是否已初始化
        _setup_called: 是否已调用setup方法
        _console_level: 控制台日志级别
        _file_level: 文件日志级别
        _log_path: 日志文件路径

    Examples:
        >>> manager = LoggerManager()
        >>> manager.setup(
        ...     log_path="logs/app.log",
        ...     console_level="INFO",
        ...     file_level="DEBUG"
        ... )
        >>> logger = manager.get_logger()
        >>> logger.info("应用启动")
    """

    _instance: LoggerManager | None = None
    _initialized: bool = False
    _setup_called: bool = False

    def __new__(cls) -> LoggerManager:
        """创建单例实例

        Returns:
            LoggerManager: 唯一的日志管理器实例
        """
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self) -> None:
        """初始化日志管理器

        设置默认的日志配置参数,但不立即配置日志系统。
        """
        if self._initialized:
            return

        self._initialized = True
        self._console_level = "INFO"
        self._file_level = "DEBUG"
        self._log_path = "logs/certflow.log"
        self._debug_modules: set[str] = set()

    def setup(
        self,
        log_path: str = "logs/certflow.log",
        console_level: str = "INFO",
        file_level: str = "DEBUG",
        debug_modules: list[str] | None = None,
        force_reinit: bool = False,
    ) -> None:
        """配置日志系统

        配置日志输出目标、格式、级别和轮转策略。
        如果已经配置过且未强制重新初始化,则跳过重复配置。

        Args:
            log_path: 日志文件路径,默认为"logs/certflow.log"
            console_level: 控制台输出级别,可选: DEBUG/INFO/WARNING/ERROR/CRITICAL
            file_level: 文件输出级别,可选: DEBUG/INFO/WARNING/ERROR/CRITICAL
            debug_modules: 屏蔽 DEBUG 输出的模块黑名单列表。
                - ["*"] 表示屏蔽所有模块的 DEBUG
                - [] 或 None 表示所有模块都输出 DEBUG
                - ["module"] 表示屏蔽整个模块的 DEBUG
                - ["module:function"] 表示仅屏蔽该模块指定函数的 DEBUG
            force_reinit: 是否强制重新初始化,默认为False

        Examples:
            >>> manager = LoggerManager()
            >>> # 基本配置
            >>> manager.setup()
            >>>
            >>> # 自定义配置
            >>> manager.setup(
            ...     log_path="logs/myapp.log",
            ...     console_level="DEBUG",
            ...     file_level="INFO"
            ... )
        """
        if self._setup_called and not force_reinit:
            logger.info("日志系统已初始化,跳过重复配置")
            return

        self._log_path = log_path
        self._console_level = console_level
        self._file_level = file_level
        self._debug_modules = set(debug_modules or [])

        # 确保日志目录存在
        Path(log_path).parent.mkdir(parents=True, exist_ok=True)

        # 移除所有已有的handler
        logger.remove()

        # 添加控制台输出(打包后的 GUI 应用无控制台,此时 sys.stdout 为 None)
        # 注意:不能直接把 sys.stdout 对象作为 loguru sink 绑定——pytest 在采集阶段会
        # 替换并关闭 sys.stdout,导致 loguru 仍持有已关闭的 stdout 引用,在 pytest
        # 收尾(stop_global_capturing)时报 'ValueError: I/O operation on closed file.'。
        # 改用动态 callable sink,每次写入时解析当前 sys.stdout,兼容 pytest 的 capture 替换。
        if sys.stdout is not None:
            logger.add(
                lambda msg: sys.stdout.write(msg),
                format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
                level=console_level,
            )

        # 构建文件 handler 的 DEBUG 过滤器
        debug_filter = self._build_debug_filter()

        # 添加文件输出
        logger.add(
            log_path,
            rotation="1 day",  # 每天轮转一次
            retention="30 days",  # 保留30天日志
            compression="zip",  # 轮转后压缩为zip格式
            format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
            level=file_level,
            filter=debug_filter,
        )

        self._setup_called = True

        if debug_filter is None:
            logger.info("DEBUG 过滤器: 未启用(全部 DEBUG 写入文件)")
        else:
            logger.info(f"DEBUG 过滤器: 已启用,屏蔽 {len(self._debug_modules)} 个规则")

        logger.info(f"日志系统初始化完成 - 控制台: {console_level}, 文件: {file_level}")
        logger.info(f"日志文件路径: {log_path}")
        if self._debug_modules:
            logger.info(f"DEBUG 模块黑名单(已屏蔽): {sorted(self._debug_modules)}")

    def _parse_block_rules(self) -> tuple[set[str], list[tuple[str, str]]]:
        """解析黑名单规则为模块级和函数级两个集合

        Returns:
            tuple[set[str], list[tuple[str, str]]]: (模块屏蔽集合, 函数屏蔽列表)
        """
        module_blocks: set[str] = set()
        func_blocks: list[tuple[str, str]] = []

        for entry in self._debug_modules:
            if ":" in entry:
                mod, func = entry.split(":", 1)
                func_blocks.append((mod, func))
            else:
                module_blocks.add(entry)

        return module_blocks, func_blocks

    def _is_blocked(
        self,
        record_module: str,
        record_function: str,
        module_blocks: set[str],
        func_blocks: list[tuple[str, str]],
    ) -> bool:
        """判断记录是否命中黑名单"""
        for m in module_blocks:
            if record_module.startswith(m):
                return True
        for mod, func in func_blocks:
            if record_module.startswith(mod) and record_function == func:
                return True
        return False

    def _build_debug_filter(self) -> Callable[[dict], bool] | None:
        """构建 DEBUG 日志过滤器(黑名单模式)

        屏蔽黑名单中的模块/函数的 DEBUG 日志,其余模块正常输出。
        INFO 及以上级别的日志始终通过。

        配置格式:
          - "certflow.handlers.sorter"            → 屏蔽整个模块
          - "certflow.handlers.sorter:_process"   → 仅屏蔽该函数
          - "*"                                   → 屏蔽所有模块

        Returns:
            callable: filter 函数,返回 True 表示通过
        """
        blocked_modules = self._debug_modules

        if "*" in blocked_modules:

            def _filter(record: dict) -> bool:
                return record["level"].name != "DEBUG"

            return _filter

        if not blocked_modules:
            return None

        module_blocks, func_blocks = self._parse_block_rules()

        def _filter(record: dict) -> bool:
            if record["level"].name != "DEBUG":
                return True
            return not self._is_blocked(
                record["name"], record["function"], module_blocks, func_blocks
            )

        return _filter

    def get_logger(self) -> logger.__class__:
        """获取日志实例

        Returns:
            loguru.Logger: 配置好的日志实例
        """
        return logger

    def set_console_level(self, level: str) -> None:
        """动态设置控制台日志级别

        运行时修改控制台输出级别,不影响文件输出。
        保留当前 debug_modules 配置。

        Args:
            level: 新的日志级别,可选: DEBUG/INFO/WARNING/ERROR/CRITICAL

        Examples:
            >>> manager = LoggerManager()
            >>> manager.set_console_level("DEBUG")  # 临时启用调试输出
        """
        self._console_level = level
        self.setup(
            log_path=self._log_path,
            console_level=level,
            file_level=self._file_level,
            debug_modules=list(self._debug_modules),
            force_reinit=True,
        )

    def set_file_level(self, level: str) -> None:
        """动态设置文件日志级别

        运行时修改文件输出级别,不影响控制台输出。
        保留当前 debug_modules 配置。

        Args:
            level: 新的日志级别,可选: DEBUG/INFO/WARNING/ERROR/CRITICAL

        Examples:
            >>> manager = LoggerManager()
            >>> manager.set_file_level("WARNING")  # 只记录警告及以上级别
        """
        self._file_level = level
        self.setup(
            log_path=self._log_path,
            console_level=self._console_level,
            file_level=level,
            debug_modules=list(self._debug_modules),
            force_reinit=True,
        )


# 全局单例实例
_logger_manager = LoggerManager()


[文档] def setup_logger( log_path: str = "logs/certflow.log", console_level: str = "INFO", file_level: str = "DEBUG", debug_modules: list[str] | None = None, ) -> logger.__class__: """配置日志系统(兼容旧接口) 提供简化的函数式接口,用于快速配置日志系统。 Args: log_path: 日志文件路径,默认为"logs/certflow.log" console_level: 控制台输出级别,默认为"INFO" file_level: 文件输出级别,默认为"DEBUG" debug_modules: DEBUG 模块黑名单,["*"] 屏蔽全部,[] 全部输出 Returns: loguru.Logger: 配置好的日志实例 Examples: >>> from certflow.utils.logger import setup_logger >>> >>> # 使用默认配置 >>> logger = setup_logger() >>> >>> # 自定义配置 >>> logger = setup_logger( ... log_path="logs/app.log", ... console_level="DEBUG", ... file_level="INFO" ... ) >>> logger.info("应用启动成功") """ _logger_manager.setup(log_path, console_level, file_level, debug_modules) return _logger_manager.get_logger()
[文档] def get_logger(): """获取全局日志实例 获取已配置的全局日志实例,如果尚未配置则使用默认配置。 Returns: loguru.Logger: 全局日志实例 Examples: >>> from certflow.utils.logger import get_logger >>> >>> logger = get_logger() >>> logger.info("这是一条信息日志") >>> logger.error("这是一条错误日志") """ return _logger_manager.get_logger()
# 为了向后兼容,直接导出 logger __all__ = ["setup_logger", "get_logger", "logger"]