Coverage for src/utils/config.py: 88%

1"""配置管理模块.

2

3使用 Pydantic v2 Settings 管理应用配置，

4支持 YAML 配置文件和环境变量覆盖。

5"""

6

7 import os

8 from pathlib import Path

9 from typing import Any

10

11 import yaml # type: ignore[import-untyped]

12 from pydantic import Field

13 from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore[import-untyped]

14

15

16 class Settings(BaseSettings):

17 """应用配置类.

18

19 支持从 config/settings.yaml 和 .env 文件加载配置。

20 环境变量优先级高于配置文件。

21

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 """

32

33 model_config = SettingsConfigDict(

34 env_file=".env",

35 env_file_encoding="utf-8",

36 extra="ignore",

37 case_sensitive=False,

38 )

39

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 )

45

46 # 限流设置

47 rate_limit_requests: int = Field(default=100, ge=1, description="限流请求数")

48 rate_limit_window: int = Field(default=60, ge=1, description="限流时间窗口秒数")

49

50 # 缓存设置

51 cache_ttl_hours: int = Field(default=24, ge=1, description="缓存 TTL 小时数")

52 cache_dir: str = Field(default=".cache", description="缓存目录")

53

54 # 日志设置

55 log_level: str = Field(default="INFO", pattern=r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")

56 log_file: str | None = Field(default=None, description="日志文件路径")

57

58 # 数据库设置

59 database_url: str = Field(default="sqlite:///data/geo_seo.db", description="数据库 URL")

60

61 @classmethod

62 def from_yaml(cls, path: str) -> "Settings":

63 """从 YAML 文件加载配置.

64

65 Args:

66 path: YAML 配置文件路径

67

68 Returns:

69 Settings 实例

70

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}")

78

79 with open(path_obj, encoding="utf-8") as f:

80 config = yaml.safe_load(f)

81

82 if not isinstance(config, dict):

83 raise ValueError("配置文件必须是 YAML 对象")

84

85 # 展平嵌套配置

86 flattened = _flatten_config(config)

87 return cls(**flattened)

88

89

90 def _flatten_config(config: dict[str, Any], prefix: str = "") -> dict[str, Any]:

91 """展平嵌套配置字典.

92

93 Args:

94 config: 嵌套配置字典

95 prefix: 键前缀

96

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

108

109

110 def load_settings(config_path: str | None = None) -> Settings:

111 """加载应用配置.

112

113 加载优先级（从高到低）：

114 1. 环境变量

115 2. .env 文件

116 3. config/settings.yaml

117 4. 默认值

118

119 Args:

120 config_path: 配置文件路径（可选）

121

122 Returns:

123 Settings 实例

124 """

125 # 默认配置文件路径

126 if config_path is None:

127 config_path = os.environ.get("GEO_SEO_CONFIG", "config/settings.yaml")

128

129 path_obj = Path(config_path)

130

131 # 如果配置文件存在，从 YAML 加载

132 if path_obj.exists():

133 return Settings.from_yaml(str(path_obj))

134

135 # 否则使用默认设置（从环境变量/.env 加载）

136 return Settings()