certflow.config.loader 源代码

"""YAML 配置加载器

支持环境变量替换、文件包含和配置验证.
"""

import os
import re
from pathlib import Path
from typing import Any

import yaml
from loguru import logger

from certflow.config.models import RootConfig


# YAML 自定义标签: !include filename.yaml
class _IncludeTag(yaml.YAMLObject):
    """YAML !include 自定义标签,用于包含外部配置文件

    该标签允许在YAML配置文件中直接引用其他YAML文件,
    实现配置的模块化和复用.

    Attributes:
        yaml_tag: YAML标签名称,用于识别!include标记
        file_path: 被包含文件的路径,相对于配置目录
    """

    yaml_tag = "!include"

    def __init__(self, file_path: str):
        """初始化包含标签实例

        Args:
            file_path: 被包含文件的路径,可以是相对路径或绝对路径
        """
        self.file_path = file_path

    @classmethod
    def from_yaml(cls, loader, node):
        """从YAML节点构造_IncludeTag实例

        该方法是PyYAML自定义标签的标准构造器接口.

        Args:
            loader: YAML加载器实例,用于解析YAML数据
            node: YAML节点对象,包含待解析的标量数据

        Returns:
            _IncludeTag: 使用节点中的文件路径初始化后的标签实例
        """
        return cls(loader.construct_scalar(node))

    def __repr__(self) -> str:
        """返回标签的字符串表示

        Returns:
            str: 标签的字符串形式,如 "!include config/database.yaml"
        """
        return f"!include {self.file_path}"


# 自定义 YAML 构造器,处理 !include 裸标签
class _IncludeLoader(yaml.SafeLoader):
    """自定义YAML加载器,支持!include标签语法

    继承自yaml.SafeLoader,添加了对!include自定义标签的支持.
    用于解析包含文件引用语法的YAML配置文件.
    """

    pass


def _include_constructor(loader: yaml.Loader, node: yaml.nodes.ScalarNode) -> _IncludeTag:
    """处理!include标签的YAML构造器函数

    当YAML解析器遇到!include标签时调用此函数,
    创建对应的_IncludeTag对象.

    Args:
        loader: YAML加载器实例
        node: YAML标量节点,包含被引用文件的路径

    Returns:
        _IncludeTag: 包含文件路径信息的标签对象
    """
    return _IncludeTag(loader.construct_scalar(node))


_IncludeLoader.add_constructor("!include", _include_constructor)


[文档] class ConfigLoader: """配置加载器,负责加载和处理YAML配置文件 提供完整的配置文件加载功能,包括环境变量替换、配置文件包含和配置验证. 支持从指定目录加载YAML格式的配置文件,并自动处理配置中的动态内容. Attributes: config_dir: 配置文件所在目录的路径,默认为项目根目录下的config文件夹 _config: 内部存储的配置对象,仅在调用load()后有效 Examples: >>> from pathlib import Path >>> loader = ConfigLoader(Path("/path/to/config")) >>> config = loader.load("app_config.yaml") >>> print(config.database.host) """
[文档] def __init__(self, config_dir: Path | None = None): """初始化配置加载器实例 如果不指定配置目录,会自动定位到项目根目录下的config文件夹. 开发环境: 通过当前文件向上查找4级父目录确定. 打包环境: 使用可执行文件所在目录 (sys.frozen). Args: config_dir: 配置文件所在目录路径,默认为None时自动使用项目config目录 """ if config_dir is None: import sys if getattr(sys, "frozen", False): # PyInstaller 打包环境: config 目录在 exe 同级 project_root = Path(sys.executable).parent else: # 开发环境: 从当前文件定位项目根目录 current_file = Path(__file__).resolve() project_root = current_file.parent.parent.parent.parent config_dir = project_root / "config" self.config_dir = Path(config_dir) self._config: RootConfig | None = None
def _preprocess_top_level_includes(self, content: str) -> str: """预处理顶层!include指令,展开被引用文件内容 由于YAML规范不允许文档顶层直接使用裸标签!include, 该方法在YAML解析前将顶层!include行替换为对应文件的文本内容. 仅处理行首的!include指令,不影响嵌套在数据结构中的标签. Args: content: 原始的YAML文本内容 Returns: str: 展开顶层!include后的YAML文本内容 Example: 输入: "!include database.yaml" 输出: "host: localhost\nport: 5432" """ lines = content.splitlines(keepends=True) result_lines: list[str] = [] include_pattern = re.compile(r"^\s*!include\s+(.+\.ya?ml)\s*$") for line in lines: m = include_pattern.match(line) if m: file_path = m.group(1).strip() full_path = self.config_dir / file_path if full_path.exists(): logger.debug(f"Including (top-level): {file_path}") with open(full_path, encoding="utf-8") as f: result_lines.append(f.read()) else: logger.warning(f"Included file not found: {full_path}") else: result_lines.append(line) return "".join(result_lines)
[文档] def load(self, config_file: str = "config.yaml") -> RootConfig: """加载配置文件并进行处理 执行完整的配置加载流程: 读取文件 -> 展开顶层包含 -> 解析YAML -> 处理嵌套包含 -> 替换环境变量 -> 验证配置结构. Args: config_file: 配置文件名,默认为"config.yaml" Returns: RootConfig: 经过验证的配置对象,包含所有处理后的配置数据 Raises: FileNotFoundError: 指定的配置文件不存在 yaml.YAMLError: YAML语法错误或格式不正确 ValidationError: 配置内容不符合定义的数据模型 """ config_path = self.config_dir / config_file if not config_path.exists(): raise FileNotFoundError(f"Configuration file not found: {config_path}") logger.info(f"Loading configuration from: {config_path}") # 读取原始内容 with open(config_path, encoding="utf-8") as f: raw_content = f.read() # 预处理: 展开顶层的 !include 裸标签 raw_content = self._preprocess_top_level_includes(raw_content) # 使用自定义 Loader 解析 YAML (处理映射值中的 !include 标签) raw_config = yaml.load(raw_content, Loader=_IncludeLoader) or {} # 递归处理 !include 标签对象,加载外部文件并合并 raw_config = self._process_includes(raw_config) # 替换环境变量 raw_config = self._resolve_env_vars(raw_config) # 验证并转换为 Pydantic 模型 self._config = RootConfig(**raw_config) logger.info("Configuration loaded successfully") return self._config
def _process_includes(self, data: Any) -> Any: """递归处理配置数据中的!include指令 支持两种形式的!include语法: 1. 裸标签形式: !include file.yaml (解析为_IncludeTag对象) 2. 字典键形式: {"!include": "file.yaml"} (向后兼容) Args: data: 待处理的配置数据,可以是字典、列表或基本类型 Returns: Any: 处理后的配置数据,所有!include标签已被替换为实际内容 """ if isinstance(data, _IncludeTag): # 裸标签形式: !include file.yaml included = self._load_included_file(data.file_path) return self._process_includes(included) if isinstance(data, dict): result = {} for key, value in data.items(): if key == "!include": # 字典键形式(向后兼容): some_key: !include file.yaml included = self._load_included_file(value) result.update(self._process_includes(included)) elif isinstance(value, _IncludeTag): # 字典值形式: some_key: !include file.yaml included = self._load_included_file(value.file_path) result[key] = self._process_includes(included) else: result[key] = self._process_includes(value) return result if isinstance(data, list): return [self._process_includes(item) for item in data] return data def _load_included_file(self, file_path: str) -> dict[str, Any]: """加载被包含的配置文件 从配置目录读取指定的YAML文件并解析为字典对象. 如果文件不存在,记录警告并返回空字典. Args: file_path: 文件路径,相对于配置目录(config_dir) Returns: Dict[str, Any]: 解析后的配置字典,文件不存在时返回空字典 """ full_path = self.config_dir / file_path if not full_path.exists(): logger.warning(f"Included file not found: {full_path}") return {} with open(full_path, encoding="utf-8") as f: return yaml.safe_load(f) def _resolve_env_vars(self, data: Any) -> Any: """递归替换配置中的环境变量 支持的环境变量语法: - ${ENV_VAR}: 直接替换为环境变量的值 - ${ENV_VAR:default_value}: 环境变量不存在时使用默认值 Args: data: 待处理的配置数据,可以是任意嵌套结构 Returns: Any: 替换环境变量后的配置数据 Example: 输入: {"host": "${DB_HOST:localhost}", "port": "${DB_PORT:5432}"} 输出: {"host": "192.168.1.100", "port": "5432"} """ pattern = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}") def replace_value(value: Any) -> Any: if isinstance(value, str): def replacer(match): var_name = match.group(1) default_value = match.group(2) or "" return os.environ.get(var_name, default_value) return pattern.sub(replacer, value) if isinstance(value, dict): return {k: replace_value(v) for k, v in value.items()} if isinstance(value, list): return [replace_value(item) for item in value] return value return replace_value(data) @property def config(self) -> RootConfig: """获取已加载的配置对象 该属性提供对加载后的配置对象的只读访问. 必须先调用load()方法,否则会抛出运行时错误. Returns: RootConfig: 已加载的配置对象 Raises: RuntimeError: 配置尚未加载,需要先调用load()方法 """ if self._config is None: raise RuntimeError("Configuration not loaded. Call load() first.") return self._config
# 全局配置实例 _global_loader: ConfigLoader | None = None _global_config: RootConfig | None = None
[文档] def get_config(config_dir: Path | None = None, reload: bool = False) -> RootConfig: """获取全局配置实例(单例模式) 提供便捷的全局配置访问接口,避免在多处重复创建ConfigLoader实例. 首次调用时会自动加载配置,后续调用返回缓存的配置对象. Args: config_dir: 配置文件所在目录路径,首次调用时可指定,后续调用此参数无效 reload: 是否强制重新加载配置,设为True时会重新读取配置文件 Returns: RootConfig: 全局配置对象实例 Example: >>> from certflow.config.loader import get_config >>> config = get_config() # 首次调用,自动加载配置 >>> db_config = get_config().database # 使用缓存配置 >>> new_config = get_config(reload=True) # 强制重新加载 """ global _global_loader, _global_config if _global_loader is None or reload: _global_loader = ConfigLoader(config_dir) _global_config = _global_loader.load() return _global_config