Coverage for agentos/core/config.py: 0%

263 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 08:01 +0800

1""" 

2Production-grade typed configuration management. 

3 

4Supports: 

5- Multiple sources: env vars, .env files, YAML, TOML, JSON, dict 

6- Strict typing with Pydantic-style validation 

7- Hierarchical merging with precedence 

8- Environment-specific overrides (dev/staging/prod) 

9- Secret masking in logs 

10- Hot-reload with callbacks 

11 

12Copyright 2026 AgentOS. All rights reserved. 

13""" 

14 

15from __future__ import annotations 

16 

17import json 

18import os 

19from copy import deepcopy 

20from dataclasses import dataclass, field, fields, is_dataclass, MISSING 

21from enum import Enum 

22from pathlib import Path 

23from typing import ( 

24 Any, Callable, Dict, Generic, List, Optional, Set, Tuple, Type, TypeVar, Union, 

25 get_args, get_origin, get_type_hints, ClassVar, 

26) 

27import logging 

28 

29logger = logging.getLogger("agentos.config") 

30 

31T = TypeVar("T") 

32 

33# --------------------------------------------------------------------------- 

34# Exceptions 

35# --------------------------------------------------------------------------- 

36 

37class ConfigError(Exception): 

38 """Base configuration error.""" 

39 

40 

41class ConfigValidationError(ConfigError): 

42 """Configuration value validation failure.""" 

43 

44 def __init__(self, field_path: str, value: Any, reason: str): 

45 self.field_path = field_path 

46 self.value = value 

47 self.reason = reason 

48 super().__init__(f"{field_path}: {reason} (got {value!r})") 

49 

50 

51class ConfigNotFoundError(ConfigError): 

52 """Required configuration key not found.""" 

53 

54 

55class ConfigSourceError(ConfigError): 

56 """Failed to load configuration from a source.""" 

57 

58 

59# --------------------------------------------------------------------------- 

60# Source Types 

61# --------------------------------------------------------------------------- 

62 

63class SourceType(Enum): 

64 ENV = "env" 

65 DOTENV = "dotenv" 

66 YAML = "yaml" 

67 TOML = "toml" 

68 JSON = "json" 

69 DICT = "dict" 

70 

71 

72@dataclass 

73class ConfigSource: 

74 """Configuration source with precedence (lower number = higher priority).""" 

75 source_type: SourceType 

76 data: Dict[str, Any] 

77 precedence: int = 100 

78 description: str = "" 

79 

80 @classmethod 

81 def from_env(cls, prefix: str = "", precedence: int = 200) -> "ConfigSource": 

82 data: Dict[str, Any] = {} 

83 for key, val in os.environ.items(): 

84 if prefix and not key.startswith(prefix): 

85 continue 

86 clean_key = key[len(prefix):] if prefix else key 

87 # parse simple types 

88 data[clean_key.lower()] = _parse_env_value(val) 

89 return cls(SourceType.ENV, data, precedence, f"env(prefix={prefix!r})") 

90 

91 @classmethod 

92 def from_dotenv(cls, path: Union[str, Path], precedence: int = 300, 

93 override: bool = False) -> "ConfigSource": 

94 from pathlib import Path as P 

95 path = P(path) 

96 if not path.exists(): 

97 if override: 

98 return cls(SourceType.DOTENV, {}, precedence, f"dotenv({path})") 

99 raise ConfigSourceError(f".env file not found: {path}") 

100 data: Dict[str, Any] = {} 

101 for line in path.read_text().splitlines(): 

102 line = line.strip() 

103 if not line or line.startswith("#"): 

104 continue 

105 if "=" not in line: 

106 continue 

107 key, _, val = line.partition("=") 

108 key = key.strip().lower() 

109 val = val.strip().strip('"').strip("'") 

110 data[key] = _parse_env_value(val) 

111 return cls(SourceType.DOTENV, data, precedence, f"dotenv({path})") 

112 

113 @classmethod 

114 def from_yaml(cls, path: Union[str, Path], precedence: int = 400) -> "ConfigSource": 

115 import yaml 

116 path = Path(path) 

117 if not path.exists(): 

118 raise ConfigSourceError(f"YAML file not found: {path}") 

119 with open(path) as f: 

120 data = _flatten_dict(yaml.safe_load(f) or {}) 

121 return cls(SourceType.YAML, data, precedence, f"yaml({path})") 

122 

123 @classmethod 

124 def from_toml(cls, path: Union[str, Path], precedence: int = 400) -> "ConfigSource": 

125 path = Path(path) 

126 if not path.exists(): 

127 raise ConfigSourceError(f"TOML file not found: {path}") 

128 try: 

129 import tomllib 

130 except ImportError: 

131 import tomli as tomllib # type: ignore 

132 with open(path, "rb") as f: 

133 data = _flatten_dict(tomllib.load(f) or {}) 

134 return cls(SourceType.TOML, data, precedence, f"toml({path})") 

135 

136 @classmethod 

137 def from_json(cls, path: Union[str, Path], precedence: int = 400) -> "ConfigSource": 

138 path = Path(path) 

139 if not path.exists(): 

140 raise ConfigSourceError(f"JSON file not found: {path}") 

141 with open(path) as f: 

142 data = _flatten_dict(json.load(f) or {}) 

143 return cls(SourceType.JSON, data, precedence, f"json({path})") 

144 

145 @classmethod 

146 def from_dict(cls, d: Dict[str, Any], precedence: int = 500, 

147 description: str = "dict") -> "ConfigSource": 

148 return cls(SourceType.DICT, dict(d), precedence, description) 

149 

150 

151# --------------------------------------------------------------------------- 

152# Config Manager 

153# --------------------------------------------------------------------------- 

154 

155class ConfigManager: 

156 """Central configuration manager with source merging and typed access. 

157 

158 Usage: 

159 cm = ConfigManager() 

160 cm.add_source(ConfigSource.from_env("AGENTOS_")) 

161 cm.add_source(ConfigSource.from_yaml("config/prod.yaml")) 

162 

163 db_host = cm.get("database.host", default="localhost") 

164 db_port = cm.get_int("database.port", default=5432) 

165 

166 # Bind to dataclass 

167 from dataclasses import dataclass 

168 

169 @dataclass 

170 class AppConfig: 

171 host: str = "0.0.0.0" 

172 port: int = 8080 

173 debug: bool = False 

174 

175 app_cfg = cm.bind(AppConfig) 

176 """ 

177 

178 def __init__(self, auto_env: bool = True, env_prefix: str = "AGENTOS_"): 

179 self._sources: List[ConfigSource] = [] 

180 self._merged: Optional[Dict[str, Any]] = None 

181 self._listeners: List[Callable[[str, Any, Any], None]] = [] 

182 self._secrets: Set[str] = set() 

183 if auto_env: 

184 self.add_source(ConfigSource.from_env(env_prefix)) 

185 

186 # -- Source management -- 

187 

188 def add_source(self, source: ConfigSource) -> None: 

189 self._sources.append(source) 

190 self._sources.sort(key=lambda s: s.precedence) 

191 self._merged = None 

192 

193 def mark_secret(self, key: str) -> None: 

194 self._secrets.add(key.lower()) 

195 

196 def mark_secrets(self, keys: List[str]) -> None: 

197 for k in keys: 

198 self._secrets.add(k.lower()) 

199 

200 # -- Merge -- 

201 

202 def _merge_sources(self) -> Dict[str, Any]: 

203 result: Dict[str, Any] = {} 

204 # Lower precedence = higher priority → iterate reversed so high-priority overrides 

205 for source in reversed(self._sources): 

206 for key, val in source.data.items(): 

207 result[key] = val 

208 return result 

209 

210 def _ensure_merged(self) -> Dict[str, Any]: 

211 if self._merged is None: 

212 self._merged = self._merge_sources() 

213 return self._merged 

214 

215 # -- Read -- 

216 

217 def get(self, key: str, default: Any = MISSING) -> Any: 

218 merged = self._ensure_merged() 

219 value = merged.get(key.lower(), MISSING) 

220 if value is MISSING: 

221 if default is not MISSING: 

222 return default 

223 raise ConfigNotFoundError(f"Configuration key not found: {key}") 

224 return value 

225 

226 def get_str(self, key: str, default: Any = MISSING) -> str: 

227 return str(self.get(key, default)) 

228 

229 def get_int(self, key: str, default: Any = MISSING) -> int: 

230 val = self.get(key, default) 

231 return int(val) 

232 

233 def get_float(self, key: str, default: Any = MISSING) -> float: 

234 val = self.get(key, default) 

235 return float(val) 

236 

237 def get_bool(self, key: str, default: Any = MISSING) -> bool: 

238 val = self.get(key, default) 

239 if isinstance(val, bool): 

240 return val 

241 if isinstance(val, str): 

242 return val.lower() in ("true", "1", "yes", "on") 

243 return bool(val) 

244 

245 def get_list(self, key: str, default: Any = MISSING, separator: str = ",") -> List[str]: 

246 val = self.get(key, default) 

247 if isinstance(val, list): 

248 return [str(v) for v in val] 

249 if isinstance(val, str): 

250 return [v.strip() for v in val.split(separator) if v.strip()] 

251 return [str(val)] 

252 

253 def get_dict(self, key: str, default: Any = MISSING) -> Dict[str, Any]: 

254 val = self.get(key, default) 

255 if isinstance(val, dict): 

256 return val 

257 raise ConfigValidationError(key, val, "expected dict") 

258 

259 def keys(self) -> List[str]: 

260 return sorted(self._ensure_merged().keys()) 

261 

262 def to_dict(self, mask_secrets: bool = True) -> Dict[str, Any]: 

263 d = dict(self._ensure_merged()) 

264 if mask_secrets: 

265 for sk in self._secrets: 

266 if sk in d: 

267 d[sk] = "***MASKED***" 

268 return d 

269 

270 # -- Bind to dataclass -- 

271 

272 def bind(self, cls: Type[T]) -> T: 

273 """Bind merged configuration to a dataclass instance.""" 

274 hints = get_type_hints(cls) 

275 kwargs: Dict[str, Any] = {} 

276 for fld in fields(cls): 

277 key = fld.name.lower() 

278 field_type = hints.get(fld.name, str) 

279 try: 

280 raw = self._ensure_merged().get(key, MISSING) 

281 except Exception: 

282 raw = MISSING 

283 if raw is MISSING: 

284 if fld.default is not MISSING: 

285 kwargs[fld.name] = fld.default 

286 elif fld.default_factory is not MISSING: 

287 kwargs[fld.name] = fld.default_factory() 

288 else: 

289 raise ConfigNotFoundError( 

290 f"Required config '{fld.name}' not found and has no default" 

291 ) 

292 else: 

293 kwargs[fld.name] = _coerce(raw, field_type) 

294 return cls(**kwargs) 

295 

296 # -- Listener -- 

297 

298 def on_change(self, callback: Callable[[str, Any, Any], None]) -> None: 

299 self._listeners.append(callback) 

300 

301 def set(self, key: str, value: Any) -> None: 

302 old = self._ensure_merged().get(key.lower()) 

303 self._ensure_merged()[key.lower()] = value 

304 for cb in self._listeners: 

305 cb(key, old, value) 

306 

307 def reload(self) -> None: 

308 self._merged = None 

309 self._ensure_merged() 

310 

311 def __repr__(self) -> str: 

312 keys = self.keys() 

313 return f"ConfigManager(sources={len(self._sources)}, keys={len(keys)})" 

314 

315 

316# --------------------------------------------------------------------------- 

317# Helpers 

318# --------------------------------------------------------------------------- 

319 

320def _parse_env_value(val: str) -> Any: 

321 """Parse string environment value to native types.""" 

322 v = val.strip() 

323 # bool 

324 if v.lower() in ("true", "yes", "on"): 

325 return True 

326 if v.lower() in ("false", "no", "off"): 

327 return False 

328 # null 

329 if v.lower() in ("null", "none", ""): 

330 return None 

331 # int 

332 try: 

333 return int(v) 

334 except ValueError: 

335 pass 

336 # float 

337 try: 

338 return float(v) 

339 except ValueError: 

340 pass 

341 # JSON 

342 if (v.startswith("{") and v.endswith("}")) or (v.startswith("[") and v.endswith("]")): 

343 try: 

344 return json.loads(v) 

345 except (json.JSONDecodeError, ValueError): 

346 pass 

347 return v 

348 

349 

350def _flatten_dict(d: Dict[str, Any], parent_key: str = "", 

351 sep: str = "_") -> Dict[str, Any]: 

352 """Flatten nested dicts into dot-separated keys.""" 

353 items: List[Tuple[str, Any]] = [] 

354 for k, v in d.items(): 

355 new_key = f"{parent_key}{sep}{k}".lower() if parent_key else k.lower() 

356 if isinstance(v, dict) and not any( 

357 isinstance(v, t) for t in (list, tuple, set) 

358 ) and not (k.isupper() and all(c.isupper() or c == "_" for c in k)): 

359 items.extend(_flatten_dict(v, new_key, sep).items()) 

360 else: 

361 items.append((new_key, v)) 

362 return dict(items) 

363 

364 

365def _coerce(value: Any, target_type: Type) -> Any: 

366 """Coerce a value to the target type.""" 

367 if value is None: 

368 return None 

369 origin = get_origin(target_type) 

370 if origin is Union: 

371 args = get_args(target_type) 

372 if type(None) in args: 

373 non_none = [a for a in args if a is not type(None)] 

374 if value is None: 

375 return None 

376 if non_none: 

377 return _coerce(value, non_none[0]) 

378 if target_type is bool: 

379 if isinstance(value, bool): 

380 return value 

381 if isinstance(value, str): 

382 return value.lower() in ("true", "1", "yes", "on") 

383 return bool(value) 

384 if target_type is int: 

385 return int(value) 

386 if target_type is float: 

387 return float(value) 

388 if target_type is str: 

389 return str(value) 

390 if target_type is list or origin is list: 

391 if isinstance(value, list): 

392 return value 

393 if isinstance(value, str): 

394 return [v.strip() for v in value.split(",") if v.strip()] 

395 return [value] 

396 if origin is dict: 

397 if isinstance(value, dict): 

398 return value 

399 raise ConfigValidationError("", value, f"cannot coerce to dict") 

400 if is_dataclass(target_type) and isinstance(value, dict): 

401 return target_type(**value) 

402 return value