Coverage for src / utils / config.py: 88%
43 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-13 20:29 +0800
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-13 20:29 +0800
1"""配置管理模块.
3使用 Pydantic v2 Settings 管理应用配置,
4支持 YAML 配置文件和环境变量覆盖。
5"""
7import os
8from pathlib import Path
9from typing import Any
11import yaml # type: ignore[import-untyped]
12from pydantic import Field
13from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore[import-untyped]
16class Settings(BaseSettings):
17 """应用配置类.
19 支持从 config/settings.yaml 和 .env 文件加载配置。
20 环境变量优先级高于配置文件。
22 Attributes:
23 google_ads_api_key: Google Ads API 密钥
24 gsc_credentials_path: Google Search Console 凭证路径
25 rate_limit_requests: 限流请求数(默认 100)
26 rate_limit_window: 限流时间窗口秒数(默认 60)
27 cache_ttl_hours: 缓存 TTL 小时数(默认 24)
28 cache_dir: 缓存目录(默认 .cache)
29 log_level: 日志级别(默认 INFO)
30 log_file: 日志文件路径(可选)
31 """
33 model_config = SettingsConfigDict(
34 env_file=".env",
35 env_file_encoding="utf-8",
36 extra="ignore",
37 case_sensitive=False,
38 )
40 # API Keys
41 google_ads_api_key: str | None = Field(default=None, description="Google Ads API 密钥")
42 gsc_credentials_path: str | None = Field(
43 default=None, description="Google Search Console 凭证路径"
44 )
46 # 限流设置
47 rate_limit_requests: int = Field(default=100, ge=1, description="限流请求数")
48 rate_limit_window: int = Field(default=60, ge=1, description="限流时间窗口秒数")
50 # 缓存设置
51 cache_ttl_hours: int = Field(default=24, ge=1, description="缓存 TTL 小时数")
52 cache_dir: str = Field(default=".cache", description="缓存目录")
54 # 日志设置
55 log_level: str = Field(default="INFO", pattern=r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")
56 log_file: str | None = Field(default=None, description="日志文件路径")
58 # 数据库设置
59 database_url: str = Field(default="sqlite:///data/geo_seo.db", description="数据库 URL")
61 @classmethod
62 def from_yaml(cls, path: str) -> "Settings":
63 """从 YAML 文件加载配置.
65 Args:
66 path: YAML 配置文件路径
68 Returns:
69 Settings 实例
71 Raises:
72 FileNotFoundError: 配置文件不存在
73 yaml.YAMLError: YAML 解析错误
74 """
75 path_obj = Path(path)
76 if not path_obj.exists():
77 raise FileNotFoundError(f"配置文件不存在: {path}")
79 with open(path_obj, encoding="utf-8") as f:
80 config = yaml.safe_load(f)
82 if not isinstance(config, dict):
83 raise ValueError("配置文件必须是 YAML 对象")
85 # 展平嵌套配置
86 flattened = _flatten_config(config)
87 return cls(**flattened)
90def _flatten_config(config: dict[str, Any], prefix: str = "") -> dict[str, Any]:
91 """展平嵌套配置字典.
93 Args:
94 config: 嵌套配置字典
95 prefix: 键前缀
97 Returns:
98 展平后的字典
99 """
100 result: dict[str, Any] = {}
101 for key, value in config.items():
102 new_key = f"{prefix}{key}" if not prefix else f"{prefix}_{key}"
103 if isinstance(value, dict):
104 result.update(_flatten_config(value, new_key))
105 else:
106 result[new_key] = value
107 return result
110def load_settings(config_path: str | None = None) -> Settings:
111 """加载应用配置.
113 加载优先级(从高到低):
114 1. 环境变量
115 2. .env 文件
116 3. config/settings.yaml
117 4. 默认值
119 Args:
120 config_path: 配置文件路径(可选)
122 Returns:
123 Settings 实例
124 """
125 # 默认配置文件路径
126 if config_path is None:
127 config_path = os.environ.get("GEO_SEO_CONFIG", "config/settings.yaml")
129 path_obj = Path(config_path)
131 # 如果配置文件存在,从 YAML 加载
132 if path_obj.exists():
133 return Settings.from_yaml(str(path_obj))
135 # 否则使用默认设置(从环境变量/.env 加载)
136 return Settings()