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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Configuration service - Configuration loading and cascade orchestration.
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.
13Services layer can use WARNING and ERROR log levels.
14"""
16from __future__ import annotations
18# ///////////////////////////////////////////////////////////////
19# IMPORTS
20# ///////////////////////////////////////////////////////////////
21# Standard library imports
22from pathlib import Path
23from typing import Any
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
34# ///////////////////////////////////////////////////////////////
35# CLASSES
36# ///////////////////////////////////////////////////////////////
39class ConfigService:
40 """
41 Configuration orchestration service.
43 Orchestrates configuration loading from multiple sources with a cascade
44 merge system. Sources are merged in priority order (later wins):
46 1. pyproject.toml [project] + [tool.ezcompiler] (base)
47 2. ezcompiler.yaml or ezcompiler.json (override)
48 3. CLI arguments (final override)
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 """
58 # ////////////////////////////////////////////////
59 # CONFIG LOADING
60 # ////////////////////////////////////////////////
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.
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)
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)
83 Returns:
84 dict[str, Any]: Merged configuration dictionary
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] = {}
92 try:
93 # Step 1: pyproject.toml as base layer
94 merged = ConfigService._load_pyproject_layer(
95 pyproject_path, search_dir, merged
96 )
98 # Step 2: YAML/JSON overlay (explicit or auto-discovered)
99 merged = ConfigService._load_config_file_layer(
100 config_path, search_dir, merged
101 )
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)
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 )
113 return merged
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
124 # ////////////////////////////////////////////////
125 # CONFIG BUILDING
126 # ////////////////////////////////////////////////
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.
138 Convenience method that combines load_config() with
139 CompilerConfig.from_dict().
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)
147 Returns:
148 CompilerConfig: Validated configuration instance
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 )
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
165 # ////////////////////////////////////////////////
166 # UTILITIES (public, for use by interfaces layer)
167 # ////////////////////////////////////////////////
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.
174 Args:
175 pyproject_path: Path to the pyproject.toml file
177 Returns:
178 dict[str, Any]: Extracted configuration dictionary
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
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).
194 Args:
195 base: Base configuration dictionary
196 override: Override dictionary (values win over base)
198 Returns:
199 dict[str, Any]: Merged configuration dictionary
200 """
201 return ConfigUtils.merge_config_dicts(base, override)
203 # ////////////////////////////////////////////////
204 # PRIVATE HELPERS
205 # ////////////////////////////////////////////////
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.
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
221 Returns:
222 dict[str, Any]: Updated merged config
223 """
224 toml_path = pyproject_path or (search_dir / "pyproject.toml")
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
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
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.
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
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)
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)
262 return merged
264 @staticmethod
265 def _load_file_by_extension(path: Path) -> dict[str, Any]:
266 """
267 Load a config file based on its extension.
269 Args:
270 path: Path to the config file
272 Returns:
273 dict[str, Any]: Parsed configuration dictionary
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}")