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

109 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 15:01 +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 

11from collections.abc import Callable, Sequence 

12from pathlib import Path 

13 

14from pydantic import BaseModel, ValidationError 

15 

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

17from taipanstack.resilience.watchdogs._base import BaseWatcher 

18 

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

20 

21MAX_CONFIG_FILE_SIZE = 1048576 

22 

23 

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

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

26 

27 Args: 

28 path: Path to the file. 

29 

30 Returns: 

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

32 

33 """ 

34 try: 

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

36 return Err( 

37 ValueError( 

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

39 ), 

40 ) 

41 data = path.read_bytes() 

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

43 except OSError as exc: 

44 return Err(exc) 

45 

46 

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

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

49 

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

51 Surrounding quotes on values are stripped. 

52 

53 Args: 

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

55 

56 Returns: 

57 Parsed key-value mapping. 

58 

59 """ 

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

61 for line in text.splitlines(): 

62 stripped = line.strip() 

63 if not stripped or stripped.startswith("#"): 

64 continue 

65 if "=" not in stripped: 

66 continue 

67 key, _, value = stripped.partition("=") 

68 value = value.strip().strip("\"'") 

69 result[key.strip()] = value 

70 return result 

71 

72 

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

74 """Parse JSON text. 

75 

76 Args: 

77 text: Raw JSON string. 

78 

79 Returns: 

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

81 

82 """ 

83 try: 

84 data = json.loads(text) 

85 if not isinstance(data, dict): 

86 return Err(TypeError(f"Expected JSON object, got {type(data).__name__}")) 

87 return Ok(data) 

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

89 return Err(exc) 

90 

91 

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

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

94 try: 

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

96 return Err( 

97 ValueError( 

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

99 ), 

100 ) 

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

102 except OSError as exc: 

103 return Err(exc) 

104 

105 

106def _parse_content_by_extension( 

107 path: Path, 

108 text: str, 

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

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

111 suffix = path.suffix.lower() 

112 if suffix == ".json": 

113 return _parse_json(text) 

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

115 return Ok(_parse_env(text)) 

116 

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

118 

119 

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

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

122 

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

124 

125 Args: 

126 path: Path to the config file. 

127 

128 Returns: 

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

130 

131 """ 

132 content_result = _read_file_content(path) 

133 if isinstance(content_result, Err): 

134 return content_result 

135 

136 return _parse_content_by_extension(path, content_result.ok_value) 

137 

138 

139def validate_config( 

140 data: dict[str, object], 

141 model: type[BaseModel], 

142) -> Result[BaseModel, Exception]: 

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

144 

145 Args: 

146 data: Raw configuration data. 

147 model: Pydantic model class to validate against. 

148 

149 Returns: 

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

151 on failure. 

152 

153 """ 

154 try: 

155 return Ok(model.model_validate(data)) 

156 except ValidationError as exc: 

157 return Err(exc) 

158 

159 

160class ConfigWatcher(BaseWatcher): 

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

162 

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

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

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

166 

167 Args: 

168 config_paths: Files to watch. 

169 config_model: Pydantic model for validation. 

170 interval: Seconds between polls. 

171 on_config_change: Callback receiving the validated model. 

172 on_validation_error: Callback receiving the ``Exception`` 

173 when validation fails. 

174 

175 Example: 

176 >>> watcher = ConfigWatcher( 

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

178 ... config_model=MySettings, 

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

180 ... ) 

181 >>> await watcher.start() 

182 

183 """ 

184 

185 def __init__( 

186 self, 

187 *, 

188 config_paths: Sequence[Path], 

189 config_model: type[BaseModel], 

190 interval: float = 2.0, 

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

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

193 ) -> None: 

194 """Initialize the config watcher. 

195 

196 Args: 

197 config_paths: Files to watch. 

198 config_model: Pydantic model for validation. 

199 interval: Seconds between polls. 

200 on_config_change: Callback for valid config changes. 

201 on_validation_error: Callback for validation failures. 

202 

203 """ 

204 super().__init__(interval=interval) 

205 self._config_paths = list(config_paths) 

206 self._config_model = config_model 

207 self._on_config_change = on_config_change 

208 self._on_validation_error = on_validation_error 

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

210 

211 def _process_hash_result( 

212 self, 

213 path: Path, 

214 current_hash: str, 

215 changed: list[Path], 

216 ) -> None: 

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

218 previous = self._file_hashes.get(path) 

219 if previous is None: 

220 # First time seeing this file — record hash 

221 self._file_hashes[path] = current_hash 

222 elif current_hash != previous: 

223 self._file_hashes[path] = current_hash 

224 changed.append(path) 

225 

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

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

228 

229 Returns: 

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

231 

232 """ 

233 changed: list[Path] = [] 

234 for path in self._config_paths: 

235 hash_result = _hash_file(path) 

236 if isinstance(hash_result, Ok): 

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

238 else: 

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

240 return Ok(changed) 

241 

242 def _handle_validation_success( 

243 self, 

244 path: Path, 

245 model: BaseModel, 

246 ) -> Result[BaseModel, Exception]: 

247 """Handle successful validation.""" 

248 logger.info( 

249 "Config hot-reloaded from %s", 

250 path, 

251 ) 

252 if self._on_config_change is not None: 

253 self._on_config_change(model) 

254 return Ok(model) 

255 

256 def _handle_validation_failure( 

257 self, 

258 path: Path, 

259 val_error: Exception, 

260 ) -> Result[BaseModel, Exception]: 

261 """Handle validation failure.""" 

262 logger.error( 

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

264 path, 

265 val_error, 

266 ) 

267 if self._on_validation_error is not None: 

268 self._on_validation_error(val_error) 

269 return Err(val_error) 

270 

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

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

273 

274 Args: 

275 path: Path to the changed config file. 

276 

277 Returns: 

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

279 

280 """ 

281 load_result = _load_file_data(path) 

282 if isinstance(load_result, Err): 

283 return Err(load_result.err_value) 

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

285 if isinstance(validation, Ok): 

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

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

288 

289 async def _run(self) -> None: 

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

291 changes = self._detect_changes() 

292 if isinstance(changes, Ok): 

293 for path in changes.ok_value: 

294 self._validate_and_apply(path) 

295 else: 

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