Coverage for src / ezcompiler / services / config_service.py: 58.70%

74 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-27 06:49 +0000

1# /////////////////////////////////////////////////////////////// 

2# CONFIG_SERVICE - Configuration orchestration service 

3# Project: ezcompiler 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Configuration service - Configuration loading and cascade orchestration. 

8 

9This module provides the ConfigService class that orchestrates configuration 

10loading from multiple sources (pyproject.toml, YAML, JSON) with a cascade 

11merge system and CLI overrides. 

12 

13Services layer can use WARNING and ERROR log levels. 

14""" 

15 

16from __future__ import annotations 

17 

18# /////////////////////////////////////////////////////////////// 

19# IMPORTS 

20# /////////////////////////////////////////////////////////////// 

21# Standard library imports 

22from pathlib import Path 

23from typing import Any 

24 

25# Local imports 

26from ..shared.compiler_config import CompilerConfig 

27from ..shared.exceptions import ConfigurationError 

28from ..shared.exceptions.utils.config_exceptions import ( 

29 ConfigFileNotFoundError, 

30 ConfigFileParseError, 

31) 

32from ..utils.config_utils import ConfigUtils 

33 

34# /////////////////////////////////////////////////////////////// 

35# CLASSES 

36# /////////////////////////////////////////////////////////////// 

37 

38 

39class ConfigService: 

40 """ 

41 Configuration orchestration service. 

42 

43 Orchestrates configuration loading from multiple sources with a cascade 

44 merge system. Sources are merged in priority order (later wins): 

45 

46 1. pyproject.toml [project] + [tool.ezcompiler] (base) 

47 2. ezcompiler.yaml or ezcompiler.json (override) 

48 3. CLI arguments (final override) 

49 

50 Example: 

51 >>> config = ConfigService.build_compiler_config() 

52 >>> config = ConfigService.build_compiler_config( 

53 ... config_path=Path("ezcompiler.yaml"), 

54 ... cli_overrides={"compiler": "PyInstaller"}, 

55 ... ) 

56 """ 

57 

58 # //////////////////////////////////////////////// 

59 # CONFIG LOADING 

60 # //////////////////////////////////////////////// 

61 

62 @staticmethod 

63 def load_config( 

64 config_path: Path | None = None, 

65 pyproject_path: Path | None = None, 

66 cli_overrides: dict[str, Any] | None = None, 

67 search_dir: Path | None = None, 

68 ) -> dict[str, Any]: 

69 """ 

70 Load configuration with full cascade. 

71 

72 Merges configuration from multiple sources in priority order: 

73 1. pyproject.toml [project] + [tool.ezcompiler] (base layer) 

74 2. ezcompiler.yaml or .json - explicit or auto-discovered (override) 

75 3. CLI overrides (final override) 

76 

77 Args: 

78 config_path: Explicit config file path for YAML/JSON (skips auto-discovery) 

79 pyproject_path: Explicit pyproject.toml path (default: search_dir/pyproject.toml) 

80 cli_overrides: Dict of CLI-provided overrides (only non-default values) 

81 search_dir: Directory to search for config files (default: cwd) 

82 

83 Returns: 

84 dict[str, Any]: Merged configuration dictionary 

85 

86 Raises: 

87 ConfigurationError: If no configuration source found or loading fails 

88 """ 

89 search_dir = search_dir or Path.cwd() 

90 merged: dict[str, Any] = {} 

91 

92 try: 

93 # Step 1: pyproject.toml as base layer 

94 merged = ConfigService._load_pyproject_layer( 

95 pyproject_path, search_dir, merged 

96 ) 

97 

98 # Step 2: YAML/JSON overlay (explicit or auto-discovered) 

99 merged = ConfigService._load_config_file_layer( 

100 config_path, search_dir, merged 

101 ) 

102 

103 # Step 3: CLI overrides (final layer) 

104 if cli_overrides: 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true

105 merged = ConfigUtils.merge_config_dicts(merged, cli_overrides) 

106 

107 if not merged: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 raise ConfigurationError( 

109 "No configuration source found. Provide a config file, " 

110 "add [tool.ezcompiler] to pyproject.toml, or use CLI options." 

111 ) 

112 

113 return merged 

114 

115 except ConfigurationError: 

116 raise 

117 except (ConfigFileNotFoundError, ConfigFileParseError) as e: 

118 raise ConfigurationError(f"Configuration loading failed: {e}") from e 

119 except Exception as e: 

120 raise ConfigurationError( 

121 f"Unexpected error loading configuration: {e}" 

122 ) from e 

123 

124 # //////////////////////////////////////////////// 

125 # CONFIG BUILDING 

126 # //////////////////////////////////////////////// 

127 

128 @staticmethod 

129 def build_compiler_config( 

130 config_path: Path | None = None, 

131 pyproject_path: Path | None = None, 

132 cli_overrides: dict[str, Any] | None = None, 

133 search_dir: Path | None = None, 

134 ) -> CompilerConfig: 

135 """ 

136 Load configuration and create a CompilerConfig instance. 

137 

138 Convenience method that combines load_config() with 

139 CompilerConfig.from_dict(). 

140 

141 Args: 

142 config_path: Explicit config file path for YAML/JSON 

143 pyproject_path: Explicit pyproject.toml path 

144 cli_overrides: Dict of CLI-provided overrides 

145 search_dir: Directory to search for config files (default: cwd) 

146 

147 Returns: 

148 CompilerConfig: Validated configuration instance 

149 

150 Raises: 

151 ConfigurationError: If configuration is invalid or not found 

152 """ 

153 config_dict = ConfigService.load_config( 

154 config_path=config_path, 

155 pyproject_path=pyproject_path, 

156 cli_overrides=cli_overrides, 

157 search_dir=search_dir, 

158 ) 

159 

160 try: 

161 return CompilerConfig.from_dict(config_dict) 

162 except Exception as e: 

163 raise ConfigurationError(f"Failed to build CompilerConfig: {e}") from e 

164 

165 # //////////////////////////////////////////////// 

166 # UTILITIES (public, for use by interfaces layer) 

167 # //////////////////////////////////////////////// 

168 

169 @staticmethod 

170 def load_pyproject_as_dict(pyproject_path: Path) -> dict[str, Any]: 

171 """ 

172 Load and extract configuration from a pyproject.toml file. 

173 

174 Args: 

175 pyproject_path: Path to the pyproject.toml file 

176 

177 Returns: 

178 dict[str, Any]: Extracted configuration dictionary 

179 

180 Raises: 

181 ConfigurationError: If the file cannot be loaded or parsed 

182 """ 

183 try: 

184 toml_data = ConfigUtils.load_toml_config(pyproject_path) 

185 return ConfigUtils.extract_pyproject_config(toml_data) 

186 except (ConfigFileNotFoundError, ConfigFileParseError) as e: 

187 raise ConfigurationError(f"Failed to load pyproject.toml: {e}") from e 

188 

189 @staticmethod 

190 def merge_configs(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: 

191 """ 

192 Deep-merge two configuration dictionaries (override wins on conflicts). 

193 

194 Args: 

195 base: Base configuration dictionary 

196 override: Override dictionary (values win over base) 

197 

198 Returns: 

199 dict[str, Any]: Merged configuration dictionary 

200 """ 

201 return ConfigUtils.merge_config_dicts(base, override) 

202 

203 # //////////////////////////////////////////////// 

204 # PRIVATE HELPERS 

205 # //////////////////////////////////////////////// 

206 

207 @staticmethod 

208 def _load_pyproject_layer( 

209 pyproject_path: Path | None, 

210 search_dir: Path, 

211 merged: dict[str, Any], 

212 ) -> dict[str, Any]: 

213 """ 

214 Load pyproject.toml as the base configuration layer. 

215 

216 Args: 

217 pyproject_path: Explicit pyproject.toml path or None for auto-discovery 

218 search_dir: Directory to search in 

219 merged: Current merged config dict 

220 

221 Returns: 

222 dict[str, Any]: Updated merged config 

223 """ 

224 toml_path = pyproject_path or (search_dir / "pyproject.toml") 

225 

226 if not toml_path.exists(): 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 return merged 

228 

229 toml_data = ConfigUtils.load_toml_config(toml_path) 

230 pyproject_config = ConfigUtils.extract_pyproject_config(toml_data) 

231 if pyproject_config: 231 ↛ 233line 231 didn't jump to line 233 because the condition on line 231 was always true

232 merged = ConfigUtils.merge_config_dicts(merged, pyproject_config) 

233 return merged 

234 

235 @staticmethod 

236 def _load_config_file_layer( 

237 config_path: Path | None, 

238 search_dir: Path, 

239 merged: dict[str, Any], 

240 ) -> dict[str, Any]: 

241 """ 

242 Load YAML/JSON config file as overlay layer. 

243 

244 Args: 

245 config_path: Explicit config path or None for auto-discovery 

246 search_dir: Directory to search in 

247 merged: Current merged config dict 

248 

249 Returns: 

250 dict[str, Any]: Updated merged config 

251 """ 

252 if config_path: 252 ↛ 257line 252 didn't jump to line 257 because the condition on line 252 was always true

253 overlay = ConfigService._load_file_by_extension(config_path) 

254 return ConfigUtils.merge_config_dicts(merged, overlay) 

255 

256 # Auto-discover (YAML/JSON only, pyproject already handled) 

257 discovered = ConfigUtils.discover_config_file(search_dir) 

258 if discovered and discovered.suffix.lower() != ".toml": 

259 overlay = ConfigService._load_file_by_extension(discovered) 

260 return ConfigUtils.merge_config_dicts(merged, overlay) 

261 

262 return merged 

263 

264 @staticmethod 

265 def _load_file_by_extension(path: Path) -> dict[str, Any]: 

266 """ 

267 Load a config file based on its extension. 

268 

269 Args: 

270 path: Path to the config file 

271 

272 Returns: 

273 dict[str, Any]: Parsed configuration dictionary 

274 

275 Raises: 

276 ConfigFileNotFoundError: If file not found 

277 ConfigFileParseError: If parsing fails 

278 ConfigurationError: If extension is unsupported 

279 """ 

280 suffix = path.suffix.lower() 

281 if suffix in (".yaml", ".yml"): 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true

282 return ConfigUtils.load_yaml_config(path) 

283 if suffix == ".json": 283 ↛ 285line 283 didn't jump to line 285 because the condition on line 283 was always true

284 return ConfigUtils.load_json_config(path) 

285 if suffix == ".toml": 

286 toml_data = ConfigUtils.load_toml_config(path) 

287 return ConfigUtils.extract_pyproject_config(toml_data) 

288 raise ConfigurationError(f"Unsupported config file format: {suffix}")