Coverage for ml_workbench/config.py: 16%

170 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2026-01-06 16:09 +0200

1from __future__ import annotations 

2 

3from collections.abc import Iterable, Mapping, MutableMapping 

4from copy import deepcopy 

5from pathlib import Path 

6from string import Formatter 

7from typing import Any, Union 

8 

9import yaml 

10 

11YamlPrimitive = Union[str, int, float, bool, None] # noqa: UP007 

12YamlValue = Union[YamlPrimitive, "YamlDict", "YamlList"] # noqa: UP007 

13YamlDict = dict[str, YamlValue] 

14YamlList = list[YamlValue] 

15 

16 

17def _extract_field_names(template: str) -> list[str]: 

18 """Return field names used in a ``str.format``-style template string. 

19 

20 Example: "{catalog}.{schema}.table" -> ["catalog", "schema"] 

21 """ 

22 

23 field_names: list[str] = [] 

24 for _literal_text, field_name, _format_spec, _conversion in Formatter().parse( 

25 template 

26 ): 

27 if field_name: # skip None and empty 

28 field_names.append(field_name) 

29 return field_names 

30 

31 

32def _interpolate_string( 

33 value: str, variables: Mapping[str, Any], *, strict: bool 

34) -> str: 

35 """Interpolate placeholders in a string using ``variables``. 

36 

37 - If ``strict`` is True (default), raise a KeyError if any placeholder is missing. 

38 - If ``strict`` is False, leave unknown placeholders as-is. 

39 """ 

40 

41 if "{" not in value or "}" not in value: 

42 return value 

43 

44 placeholders = _extract_field_names(value) 

45 if not placeholders: 

46 return value 

47 

48 missing = [name for name in placeholders if name not in variables] 

49 if missing and strict: 

50 raise KeyError( # noqa: TRY003 

51 f"Missing values for placeholders {missing} while formatting: {value!r}" 

52 ) 

53 

54 class _DefaultDict(dict): 

55 def __missing__(self, key: str) -> str: # type: ignore[override] 

56 return "{" + key + "}" 

57 

58 mapping: Mapping[str, Any] 

59 mapping = variables if strict else _DefaultDict(variables) # type: ignore[arg-type] 

60 return value.format_map(mapping) # type: ignore[arg-type] 

61 

62 

63def _interpolate_value( 

64 value: YamlValue, variables: Mapping[str, Any], *, strict: bool 

65) -> YamlValue: 

66 if isinstance(value, str): 

67 return _interpolate_string(value, variables, strict=strict) 

68 if isinstance(value, dict): 

69 return { 

70 k: _interpolate_value(v, variables, strict=strict) for k, v in value.items() 

71 } 

72 if isinstance(value, list): 

73 return [_interpolate_value(v, variables, strict=strict) for v in value] 

74 return value 

75 

76 

77def _wrap(value: YamlValue) -> Any: 

78 if isinstance(value, dict): 

79 return _ConfigNode(value) 

80 if isinstance(value, list): 

81 return [_wrap(v) for v in value] 

82 return value 

83 

84 

85def _unwrap(value: Any) -> YamlValue: 

86 if isinstance(value, _ConfigNode): 

87 return {k: _unwrap(v) for k, v in value.items()} 

88 if isinstance(value, list): 

89 return [_unwrap(v) for v in value] 

90 return value 

91 

92 

93## moved to dataset.py: _is_databricks_table_name, _infer_dataset_format_from_path 

94 

95 

96class _ConfigNode(MutableMapping[str, Any]): 

97 """Lightweight mapping that supports both dict- and attribute-style access. 

98 

99 This is used for nested configuration sections. 

100 """ 

101 

102 def __init__(self, data: YamlDict) -> None: 

103 self._data: YamlDict = data 

104 

105 # Mapping interface 

106 def __getitem__(self, key: str) -> Any: 

107 return _wrap(self._data[key]) 

108 

109 def __setitem__(self, key: str, value: Any) -> None: 

110 self._data[key] = _unwrap(value) 

111 

112 def __delitem__(self, key: str) -> None: 

113 del self._data[key] 

114 

115 def __iter__(self) -> Iterable[str]: 

116 return iter(self._data) 

117 

118 def __len__(self) -> int: # noqa: D401 - trivial 

119 return len(self._data) 

120 

121 # Attribute-style access 

122 def __getattr__(self, name: str) -> Any: 

123 try: 

124 return _wrap(self._data[name]) 

125 except KeyError as exc: 

126 raise AttributeError(name) from exc # noqa: TRY003 

127 

128 def get(self, key: str, default: Any | None = None) -> Any: 

129 if key in self._data: 

130 return _wrap(self._data[key]) 

131 return default 

132 

133 def to_dict(self) -> YamlDict: 

134 return deepcopy(self._data) 

135 

136 def __repr__(self) -> str: # pragma: no cover - representation only 

137 return f"_ConfigNode({self._data!r})" 

138 

139 

140class YamlConfig(_ConfigNode): 

141 """Configuration loaded from a YAML file with placeholder interpolation. 

142 

143 Placeholders use Python's ``str.format`` style, e.g. "{catalog}". 

144 Values for placeholders are assembled as: 

145 1) ``defaults`` key from the YAML file (if present), then 

146 2) Keyword arguments provided to the constructor, which override defaults. 

147 

148 Parameters 

149 ---------- 

150 yaml_path: 

151 Path to the YAML file. 

152 strict: 

153 If True (default), raise when placeholders are missing. If False, leave 

154 unknown placeholders unchanged. 

155 **variables: 

156 Additional key/value pairs to use for placeholder interpolation. 

157 """ 

158 

159 def __init__( 

160 self, yaml_path: str | Path, *, strict: bool = True, **variables: Any 

161 ) -> None: 

162 path = Path(yaml_path) 

163 if not path.exists(): 

164 raise FileNotFoundError(f"YAML file not found: {path}") # noqa: TRY003 

165 

166 # Load YAML with recursive include processing 

167 loaded = self._load_with_includes(path, set()) 

168 

169 if not isinstance(loaded, dict): 

170 raise TypeError("Top-level YAML structure must be a mapping (dict)") # noqa: TRY003 

171 

172 defaults_section: Mapping[str, Any] = ( 

173 loaded.get("defaults", {}) 

174 if isinstance(loaded.get("defaults", {}), dict) 

175 else {} 

176 ) 

177 

178 # Merge defaults with user-supplied variables (kwargs override defaults) 

179 variables_merged: dict[str, Any] = {**defaults_section, **variables} 

180 

181 # Interpolate placeholders across the entire loaded YAML structure 

182 interpolated: YamlDict = _interpolate_value( 

183 loaded, variables_merged, strict=strict 

184 ) # type: ignore[assignment] 

185 

186 super().__init__(interpolated) 

187 self.path: Path = path 

188 self.variables: dict[str, Any] = variables_merged 

189 self.strict: bool = strict 

190 

191 # Post-process datasets to impute missing information 

192 # Delayed import to avoid circular dependency 

193 from .dataset import Dataset # noqa: PLC0415 

194 

195 Dataset.verify_config(self) 

196 

197 from .feature import Feature # noqa: PLC0415 

198 

199 Feature.verify_config(self) 

200 

201 from .model import Model # noqa: PLC0415 

202 

203 Model.verify_config(self) 

204 

205 from .experiment import Experiment # noqa: PLC0415 

206 

207 Experiment.verify_config(self) 

208 

209 from .mlflow_conf import MlflowConf # noqa: PLC0415 

210 

211 MlflowConf.verify_config(self) 

212 

213 def _load_with_includes( 

214 self, yaml_path: Path, visited: set[Path] 

215 ) -> dict[str, Any]: 

216 """ 

217 Load a YAML file and recursively process any 'include' directives. 

218 

219 The 'include' field should be a list of file paths (relative to the current file 

220 or absolute). Included files are merged into the current file, with the current 

221 file's values taking precedence. 

222 

223 Parameters 

224 ---------- 

225 yaml_path : Path 

226 Path to the YAML file to load 

227 visited : set[Path] 

228 Set of already visited paths to prevent circular includes 

229 

230 Returns 

231 ------- 

232 Dict[str, Any] 

233 Merged dictionary from all included files and the current file 

234 

235 Raises 

236 ------ 

237 ValueError 

238 If a circular include is detected 

239 """ 

240 # Resolve to absolute path to detect circular includes 

241 abs_path = yaml_path.resolve() 

242 

243 if abs_path in visited: 

244 raise ValueError(f"Circular include detected: {abs_path}") # noqa: TRY003 

245 

246 visited.add(abs_path) 

247 

248 # Load the current file 

249 with abs_path.open("r", encoding="utf-8") as f: 

250 current_data: Any = yaml.safe_load(f) or {} 

251 

252 if not isinstance(current_data, dict): 

253 return current_data 

254 

255 # Check for 'include' directive 

256 include_list = current_data.pop("include", None) 

257 

258 if include_list is None: 

259 return current_data 

260 

261 # Ensure include is a list 

262 if not isinstance(include_list, list): 

263 raise TypeError( # noqa: TRY003 

264 f"'include' directive must be a list, got {type(include_list).__name__}" 

265 ) 

266 

267 # Start with an empty result 

268 result: dict[str, Any] = {} 

269 

270 # Process each included file 

271 for include_path_str in include_list: 

272 if not isinstance(include_path_str, str): 

273 raise TypeError( # noqa: TRY003 

274 f"Include path must be a string, got {type(include_path_str).__name__}" 

275 ) 

276 

277 # Resolve include path 

278 include_path = Path(include_path_str) 

279 if not include_path.is_absolute(): 

280 # Try relative to current file's directory first 

281 relative_to_current = abs_path.parent / include_path 

282 if relative_to_current.exists(): 

283 include_path = relative_to_current 

284 else: 

285 # Try relative to project root (parent of current file's parent) 

286 # This handles cases like "snippets/datasets.yaml" from "snippets/features.yaml" 

287 project_root = abs_path.parent.parent 

288 relative_to_root = project_root / include_path 

289 if relative_to_root.exists(): 

290 include_path = relative_to_root 

291 else: 

292 # Neither worked, raise error with both attempted paths 

293 raise FileNotFoundError( # noqa: TRY003 

294 f"Included file not found: {include_path_str}\n" 

295 f" Tried: {relative_to_current}\n" 

296 f" Tried: {relative_to_root}" 

297 ) 

298 

299 if not include_path.exists(): 

300 raise FileNotFoundError(f"Included file not found: {include_path}") # noqa: TRY003 

301 

302 # Recursively load the included file 

303 included_data = self._load_with_includes(include_path, visited.copy()) 

304 

305 # Merge included data into result (deep merge) 

306 result = self._deep_merge(result, included_data) 

307 

308 # Merge current file's data on top (current file takes precedence) 

309 return self._deep_merge(result, current_data) 

310 

311 def _deep_merge( 

312 self, base: dict[str, Any], override: dict[str, Any] 

313 ) -> dict[str, Any]: 

314 """ 

315 Deep merge two dictionaries, with override taking precedence. 

316 

317 Parameters 

318 ---------- 

319 base : Dict[str, Any] 

320 Base dictionary 

321 override : Dict[str, Any] 

322 Override dictionary (takes precedence) 

323 

324 Returns 

325 ------- 

326 Dict[str, Any] 

327 Merged dictionary 

328 """ 

329 result = base.copy() 

330 

331 for key, value in override.items(): 

332 if ( 

333 key in result 

334 and isinstance(result[key], dict) 

335 and isinstance(value, dict) 

336 ): 

337 # Recursively merge nested dictionaries 

338 result[key] = self._deep_merge(result[key], value) 

339 else: 

340 # Override takes precedence 

341 result[key] = value 

342 

343 return result 

344 

345 @classmethod 

346 def from_file( 

347 cls, yaml_path: str | Path, *, strict: bool = True, **variables: Any 

348 ) -> YamlConfig: 

349 return cls(yaml_path, strict=strict, **variables) 

350 

351 def to_dict(self) -> YamlDict: 

352 return deepcopy(self._data) 

353 

354 def get_data(self) -> YamlDict: 

355 """Get the raw configuration data dictionary. 

356 

357 Returns 

358 ------- 

359 YamlDict 

360 The raw configuration data dictionary 

361 """ 

362 return self._data 

363 

364 def reload(self, *, strict: bool | None = None, **variables: Any) -> None: 

365 """Reload the YAML file, optionally overriding variables and strictness. 

366 

367 Useful if the file or variables have changed. 

368 """ 

369 

370 new_strict = self.strict if strict is None else strict 

371 updated_vars = {**self.variables, **variables} 

372 refreshed = type(self).from_file(self.path, strict=new_strict, **updated_vars) 

373 # Replace internal state 

374 self._data = refreshed.get_data() 

375 self.variables = refreshed.variables 

376 self.strict = refreshed.strict 

377 

378 def get_datasets_list(self) -> list[str]: 

379 """Return list of dataset names defined in the configuration. 

380 

381 Returns 

382 ------- 

383 List[str] 

384 List of dataset names, or empty list if no datasets section exists 

385 """ 

386 datasets = self._data.get("datasets") 

387 if not isinstance(datasets, dict): 

388 return [] 

389 return list(datasets.keys()) 

390 

391 def get_dataset_config(self, name: str) -> YamlDict: 

392 """Return configuration dictionary for a specific dataset. 

393 

394 Parameters 

395 ---------- 

396 name : str 

397 Dataset name 

398 

399 Returns 

400 ------- 

401 YamlDict 

402 Dataset configuration as a plain dictionary 

403 

404 Raises 

405 ------ 

406 KeyError 

407 If dataset with given name does not exist 

408 """ 

409 datasets = self._data.get("datasets") 

410 if not isinstance(datasets, dict): 

411 raise KeyError("No datasets section found in configuration") # noqa: TRY003 

412 

413 if name not in datasets: 

414 raise KeyError(f"Dataset '{name}' not found in configuration") # noqa: TRY003 

415 

416 dataset_spec = datasets[name] 

417 if isinstance(dataset_spec, dict): 

418 return deepcopy(dataset_spec) 

419 

420 raise TypeError(f"Dataset '{name}' configuration is not a valid mapping") # noqa: TRY003 

421 

422 # Internal utilities 

423 

424 

425__all__ = ["YamlConfig"]