Coverage for src/taipanstack/resilience/watchdogs/config_watcher.py: 100%

115 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-10-13 20:52 +0000

1""" 

2Configuration watcher — detects file changes and hot-reloads config. 

3 

4Polls configuration files for modifications using SHA-256 hashes 

5and validates new content via Pydantic before applying changes. 

6""" 

7 

8import hashlib 

9import json 

10import logging 

11import math 

12from collections.abc import Callable, Sequence 

13from pathlib import Path 

14 

15from pydantic import BaseModel, ValidationError 

16 

17from taipanstack.core.result import Err, Ok, Result 

18from taipanstack.resilience.watchdogs._base import BaseWatcher 

19 

20logger = logging.getLogger("taipanstack.resilience.watchdogs.config") 

21 

22MAX_CONFIG_FILE_SIZE = 1048576 

23 

24 

25def _hash_file(path: Path) -> Result[str, Exception]: 

26 """Compute the SHA-256 hex digest of a file. 

27 

28 Args: 

29 path: Path to the file. 

30 

31 Returns: 

32 ``Ok(hex_digest)`` on success, ``Err`` on I/O failure. 

33 

34 """ 

35 try: 

36 if path.stat().st_size > MAX_CONFIG_FILE_SIZE: 

37 return Err( 

38 ValueError( 

39 f"File {path} exceeds max size ({MAX_CONFIG_FILE_SIZE} bytes)", 

40 ), 

41 ) 

42 data = path.read_bytes() 

43 return Ok(hashlib.sha256(data).hexdigest()) 

44 except OSError as exc: 

45 return Err(exc) 

46 

47 

48def _is_valid_env_line(line: str) -> bool: 

49 """Check if a line is a valid .env assignment.""" 

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

51 return False 

52 return "=" in line 

53 

54 

55def _parse_env_line(line: str) -> tuple[str, str]: 

56 """Parse a valid .env assignment line into key and value.""" 

57 key, _, value = line.partition("=") 

58 return key.strip(), value.strip().strip("\"'") 

59 

60 

61def _parse_env(text: str) -> dict[str, object]: 

62 """Parse a simple ``.env`` key=value file. 

63 

64 Lines starting with ``#`` or blank lines are skipped. 

65 Surrounding quotes on values are stripped. 

66 

67 Args: 

68 text: Raw text content of the ``.env`` file. 

69 

70 Returns: 

71 Parsed key-value mapping. 

72 

73 """ 

74 result: dict[str, object] = {} 

75 for line in text.splitlines(): 

76 stripped = line.strip() 

77 if _is_valid_env_line(stripped): 

78 key, value = _parse_env_line(stripped) 

79 result[key] = value 

80 return result 

81 

82 

83def _parse_json(text: str) -> Result[dict[str, object], Exception]: 

84 """Parse JSON text. 

85 

86 Args: 

87 text: Raw JSON string. 

88 

89 Returns: 

90 ``Ok(dict)`` on success, ``Err`` on parse failure. 

91 

92 """ 

93 try: 

94 data = json.loads(text) # type: ignore[misc] 

95 if not isinstance(data, dict): # type: ignore[misc] 

96 return Err(TypeError(f"Expected JSON object, got {type(data).__name__}")) # type: ignore[misc] 

97 return Ok(data) 

98 except (json.JSONDecodeError, ValueError) as exc: 

99 return Err(exc) 

100 

101 

102def _read_file_content(path: Path) -> Result[str, Exception]: 

103 """Read file content with size validation.""" 

104 try: 

105 if path.stat().st_size > MAX_CONFIG_FILE_SIZE: 

106 return Err( 

107 ValueError( 

108 f"File {path} exceeds max size ({MAX_CONFIG_FILE_SIZE} bytes)", 

109 ), 

110 ) 

111 return Ok(path.read_text(encoding="utf-8")) 

112 except OSError as exc: 

113 return Err(exc) 

114 

115 

116def _parse_content_by_extension( 

117 path: Path, 

118 text: str, 

119) -> Result[dict[str, object], Exception]: 

120 """Parse file content based on extension.""" 

121 suffix = path.suffix.lower() 

122 if suffix == ".json": 

123 return _parse_json(text) 

124 if suffix == ".env" or path.name == ".env": 

125 return Ok(_parse_env(text)) 

126 

127 return Err(ValueError(f"Unsupported config file extension: {suffix}")) 

128 

129 

130def _load_file_data(path: Path) -> Result[dict[str, object], Exception]: 

131 """Read and parse a configuration file based on its extension. 

132 

133 Supported extensions: ``.env``, ``.json``. 

134 

135 Args: 

136 path: Path to the config file. 

137 

138 Returns: 

139 ``Ok(dict)`` with parsed data, or ``Err`` on failure. 

140 

141 """ 

142 content_result = _read_file_content(path) 

143 if isinstance(content_result, Err): 

144 return content_result 

145 

146 return _parse_content_by_extension(path, content_result.ok_value) 

147 

148 

149def validate_config( 

150 data: dict[str, object], 

151 model: type[BaseModel], 

152) -> Result[BaseModel, Exception]: 

153 """Validate a data dictionary against a Pydantic model. 

154 

155 Args: 

156 data: Raw configuration data. 

157 model: Pydantic model class to validate against. 

158 

159 Returns: 

160 ``Ok(model_instance)`` on success, ``Err(ValidationError)`` 

161 on failure. 

162 

163 """ 

164 try: 

165 return Ok(model.model_validate(data)) 

166 except ValidationError as exc: 

167 return Err(exc) 

168 

169 

170class ConfigWatcher(BaseWatcher): 

171 """Background watcher that detects configuration file changes. 

172 

173 Polls file hashes at each interval. When a change is detected 

174 the content is validated via the provided Pydantic model and, 

175 if valid, the ``on_config_change`` callback is invoked. 

176 

177 Args: 

178 config_paths: Files to watch. 

179 config_model: Pydantic model for validation. 

180 interval: Seconds between polls. 

181 on_config_change: Callback receiving the validated model. 

182 on_validation_error: Callback receiving the ``Exception`` 

183 when validation fails. 

184 

185 Example: 

186 >>> watcher = ConfigWatcher( 

187 ... config_paths=[Path(".env")], 

188 ... config_model=MySettings, 

189 ... on_config_change=lambda cfg: apply(cfg), 

190 ... ) 

191 >>> await watcher.start() 

192 

193 """ 

194 

195 def __init__( 

196 self, 

197 *, 

198 config_paths: Sequence[Path], 

199 config_model: type[BaseModel], 

200 interval: float = 2.0, 

201 on_config_change: Callable[[BaseModel], None] | None = None, 

202 on_validation_error: Callable[[Exception], None] | None = None, 

203 ) -> None: 

204 """Initialize the config watcher. 

205 

206 Args: 

207 config_paths: Files to watch. 

208 config_model: Pydantic model for validation. 

209 interval: Seconds between polls. 

210 on_config_change: Callback for valid config changes. 

211 on_validation_error: Callback for validation failures. 

212 

213 """ 

214 if ( 

215 not isinstance(interval, (int, float)) 

216 or not math.isfinite(interval) 

217 or interval <= 0 

218 ): 

219 raise ValueError("interval must be a finite positive number") 

220 super().__init__(interval=interval) 

221 self._config_paths = list(config_paths) 

222 self._config_model = config_model 

223 self._on_config_change = on_config_change 

224 self._on_validation_error = on_validation_error 

225 self._file_hashes: dict[Path, str] = {} 

226 

227 def _process_hash_result( 

228 self, 

229 path: Path, 

230 current_hash: str, 

231 changed: list[Path], 

232 ) -> None: 

233 """Process successful hash result and update changed list.""" 

234 previous = self._file_hashes.get(path) 

235 if previous is None: 

236 # First time seeing this file — record hash 

237 self._file_hashes[path] = current_hash 

238 elif current_hash != previous: 

239 self._file_hashes[path] = current_hash 

240 changed.append(path) 

241 

242 def _detect_changes(self) -> Result[list[Path], Exception]: 

243 """Detect which watched files have changed since last check. 

244 

245 Returns: 

246 ``Ok(list[Path])`` of changed file paths. 

247 

248 """ 

249 changed: list[Path] = [] 

250 for path in self._config_paths: 

251 hash_result = _hash_file(path) 

252 if isinstance(hash_result, Ok): 

253 self._process_hash_result(path, hash_result.ok_value, changed) 

254 else: 

255 logger.warning("Cannot hash %s: %s", path, hash_result.err_value) 

256 return Ok(changed) 

257 

258 def _handle_validation_success( 

259 self, 

260 path: Path, 

261 model: BaseModel, 

262 ) -> Result[BaseModel, Exception]: 

263 """Handle successful validation.""" 

264 logger.info( 

265 "Config hot-reloaded from %s", 

266 path, 

267 ) 

268 if self._on_config_change is not None: 

269 self._on_config_change(model) 

270 return Ok(model) 

271 

272 def _handle_validation_failure( 

273 self, 

274 path: Path, 

275 val_error: Exception, 

276 ) -> Result[BaseModel, Exception]: 

277 """Handle validation failure.""" 

278 logger.error( 

279 "Config validation failed for %s: %s", 

280 path, 

281 val_error, 

282 ) 

283 if self._on_validation_error is not None: 

284 self._on_validation_error(val_error) 

285 return Err(val_error) 

286 

287 def _validate_and_apply(self, path: Path) -> Result[BaseModel, Exception]: 

288 """Load, validate, and apply configuration from a file. 

289 

290 Args: 

291 path: Path to the changed config file. 

292 

293 Returns: 

294 ``Ok(model)`` if valid, ``Err`` otherwise. 

295 

296 """ 

297 load_result = _load_file_data(path) 

298 if isinstance(load_result, Err): 

299 return Err(load_result.err_value) 

300 validation = validate_config(load_result.ok_value, self._config_model) 

301 if isinstance(validation, Ok): 

302 return self._handle_validation_success(path, validation.ok_value) 

303 return self._handle_validation_failure(path, validation.err_value) 

304 

305 async def _run(self) -> None: 

306 """Execute a single config-check cycle.""" 

307 changes = self._detect_changes() 

308 if isinstance(changes, Ok): 

309 for path in changes.ok_value: 

310 self._validate_and_apply(path) 

311 else: 

312 logger.error("Change detection failed: %s", changes.err_value)