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

1"""配置管理模块. 

2 

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

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

5""" 

6 

7import os 

8from pathlib import Path 

9from typing import Any 

10 

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

12from pydantic import Field 

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

14 

15 

16class 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 

90def _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 

110def 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()