Coverage for src / ezcompiler / utils / config_utils.py: 46.54%
113 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_UTILS - Configuration-specific utility functions
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""
7Configuration utilities - Configuration-specific utility functions for EzCompiler.
9This module provides specialized utility functions for configuration validation
10and processing. Uses thematic utils (ValidationUtils, FileUtils) internally.
12Note: These utilities are intended for use by services and other layers that
13need to validate or process CompilerConfig instances. The CompilerConfig class
14itself performs its own validation during initialization.
16Utils layer can only use DEBUG and ERROR log levels.
17"""
19from __future__ import annotations
21# ///////////////////////////////////////////////////////////////
22# IMPORTS
23# ///////////////////////////////////////////////////////////////
24# Standard library imports
25import json
26import tomllib
27from pathlib import Path
28from typing import Any
30# Third-party imports
31import yaml
33# Local imports
34from ..shared.compiler_config import CompilerConfig
35from ..shared.exceptions.utils.config_exceptions import (
36 CompilerOptionError,
37 ConfigFileNotFoundError,
38 ConfigFileParseError,
39 ConfigPathError,
40 MissingRequiredConfigError,
41)
42from .file_utils import FileUtils
43from .validators import validate_string_length
45# ///////////////////////////////////////////////////////////////
46# CLASSES
47# ///////////////////////////////////////////////////////////////
50class ConfigUtils:
51 """
52 Utility class for configuration-specific operations.
54 Provides static methods for configuration validation and processing.
55 Uses thematic utils (ValidationUtils, FileUtils) internally.
57 Example:
58 >>> config = CompilerConfig(...)
59 >>> ConfigUtils.validate_required_config_fields(config)
60 >>> ConfigUtils.validate_config_paths(config)
61 >>> ConfigUtils.validate_compiler_option(config.compiler)
62 """
64 # ////////////////////////////////////////////////
65 # VALIDATION METHODS
66 # ////////////////////////////////////////////////
68 @staticmethod
69 def validate_required_config_fields(config: CompilerConfig) -> None:
70 """
71 Validate required configuration fields are not empty.
73 Args:
74 config: CompilerConfig instance to validate
76 Raises:
77 ConfigurationError: If any required field is empty
79 Note:
80 Uses ValidationUtils for string validation.
82 Example:
83 >>> config = CompilerConfig(...)
84 >>> ConfigUtils.validate_required_config_fields(config)
85 """
86 if not config.version:
87 raise MissingRequiredConfigError("Version cannot be empty")
89 if not validate_string_length(config.project_name, min_length=1):
90 raise MissingRequiredConfigError("Project name cannot be empty")
92 if not validate_string_length(config.main_file, min_length=1):
93 raise MissingRequiredConfigError("Main file cannot be empty")
95 if not config.include_files:
96 raise MissingRequiredConfigError("Include files cannot be empty")
98 @staticmethod
99 def validate_config_paths(config: CompilerConfig) -> None:
100 """
101 Validate file and folder paths in configuration.
103 Ensures main file exists and normalizes output_folder to Path.
104 Does not create the output folder (that's done during compilation).
106 Args:
107 config: CompilerConfig instance to validate
109 Raises:
110 ConfigurationError: If main file doesn't exist
112 Note:
113 Uses FileUtils for file existence checks.
115 Example:
116 >>> config = CompilerConfig(..., main_file="main.py", output_folder="dist")
117 >>> ConfigUtils.validate_config_paths(config)
118 """
119 if not FileUtils.validate_file_exists(config.main_file):
120 raise ConfigPathError(f"Main file not found: {config.main_file}")
122 # Normalize output_folder to Path if it's a string
123 if isinstance(config.output_folder, str):
124 config.output_folder = Path(config.output_folder)
126 @staticmethod
127 def validate_compiler_option(compiler: str) -> None:
128 """
129 Validate compiler option value.
131 Args:
132 compiler: Compiler name to validate
134 Raises:
135 ConfigurationError: If compiler is not valid
137 Example:
138 >>> ConfigUtils.validate_compiler_option("PyInstaller")
139 >>> ConfigUtils.validate_compiler_option("invalid")
140 ConfigurationError: Invalid compiler: invalid
141 """
142 valid_compilers = ["auto", "Cx_Freeze", "PyInstaller", "Nuitka"]
143 if compiler not in valid_compilers:
144 raise CompilerOptionError(
145 f"Invalid compiler: {compiler}. Must be one of {valid_compilers}"
146 )
148 @staticmethod
149 def normalize_output_folder(output_folder: str | Path) -> Path:
150 """
151 Normalize output folder path to Path object.
153 Args:
154 output_folder: Output folder as string or Path
156 Returns:
157 Path: Normalized Path object
159 Example:
160 >>> folder = ConfigUtils.normalize_output_folder("dist")
161 >>> print(type(folder))
162 <class 'pathlib.Path'>
163 """
164 if isinstance(output_folder, str):
165 return Path(output_folder)
166 return output_folder
168 # ////////////////////////////////////////////////
169 # FILE LOADING METHODS
170 # ////////////////////////////////////////////////
172 @staticmethod
173 def load_yaml_config(path: Path) -> dict[str, Any]:
174 """
175 Load configuration from a YAML file.
177 Args:
178 path: Path to the YAML file
180 Returns:
181 dict[str, Any]: Parsed configuration dictionary
183 Raises:
184 ConfigFileNotFoundError: If the file does not exist
185 ConfigFileParseError: If the file cannot be parsed
186 """
187 if not path.exists():
188 raise ConfigFileNotFoundError(f"YAML config file not found: {path}")
189 try:
190 with open(path, encoding="utf-8") as f:
191 data = yaml.safe_load(f)
192 return data if isinstance(data, dict) else {}
193 except yaml.YAMLError as e:
194 raise ConfigFileParseError(f"Failed to parse YAML file {path}: {e}") from e
196 @staticmethod
197 def load_json_config(path: Path) -> dict[str, Any]:
198 """
199 Load configuration from a JSON file.
201 Args:
202 path: Path to the JSON file
204 Returns:
205 dict[str, Any]: Parsed configuration dictionary
207 Raises:
208 ConfigFileNotFoundError: If the file does not exist
209 ConfigFileParseError: If the file cannot be parsed
210 """
211 if not path.exists(): 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true
212 raise ConfigFileNotFoundError(f"JSON config file not found: {path}")
213 try:
214 with open(path, encoding="utf-8") as f:
215 data = json.load(f)
216 return data if isinstance(data, dict) else {}
217 except json.JSONDecodeError as e:
218 raise ConfigFileParseError(f"Failed to parse JSON file {path}: {e}") from e
220 @staticmethod
221 def load_toml_config(path: Path) -> dict[str, Any]:
222 """
223 Load and return the full parsed TOML dictionary.
225 Args:
226 path: Path to the TOML file
228 Returns:
229 dict[str, Any]: Parsed TOML dictionary
231 Raises:
232 ConfigFileNotFoundError: If the file does not exist
233 ConfigFileParseError: If the file cannot be parsed
234 """
235 if not path.exists(): 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true
236 raise ConfigFileNotFoundError(f"TOML config file not found: {path}")
237 try:
238 with open(path, "rb") as f:
239 return tomllib.load(f)
240 except Exception as e:
241 raise ConfigFileParseError(f"Failed to parse TOML file {path}: {e}") from e
243 # ////////////////////////////////////////////////
244 # PYPROJECT.TOML EXTRACTION
245 # ////////////////////////////////////////////////
247 @staticmethod
248 def extract_pyproject_config(toml_data: dict[str, Any]) -> dict[str, Any]:
249 """
250 Extract ezcompiler configuration from pyproject.toml structure.
252 Maps [project] fields (name, version, description, authors) and
253 [tool.ezcompiler] fields into the flat config dict format expected
254 by CompilerConfig.from_dict().
256 Args:
257 toml_data: Full parsed pyproject.toml dictionary
259 Returns:
260 dict[str, Any]: Extracted configuration dictionary (may be empty)
261 """
262 result: dict[str, Any] = {}
264 # Extract from [project] section
265 project = toml_data.get("project", {})
266 if "name" in project: 266 ↛ 268line 266 didn't jump to line 268 because the condition on line 266 was always true
267 result["project_name"] = project["name"]
268 if "version" in project: 268 ↛ 270line 268 didn't jump to line 270 because the condition on line 268 was always true
269 result["version"] = project["version"]
270 if "description" in project: 270 ↛ 272line 270 didn't jump to line 272 because the condition on line 270 was always true
271 result["project_description"] = project["description"]
272 if "authors" in project and project["authors"]: 272 ↛ 280line 272 didn't jump to line 280 because the condition on line 272 was always true
273 first_author = project["authors"][0]
274 if isinstance(first_author, dict): 274 ↛ 280line 274 didn't jump to line 280 because the condition on line 274 was always true
275 author_name = first_author.get("name", "")
276 result["author"] = author_name
277 result["company_name"] = author_name
279 # Extract from [tool.ezcompiler] section
280 tool_config = toml_data.get("tool", {}).get("ezcompiler", {})
281 if tool_config: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true
282 result.update(tool_config)
284 return result
286 # ////////////////////////////////////////////////
287 # CONFIG DISCOVERY
288 # ////////////////////////////////////////////////
290 @staticmethod
291 def discover_config_file(search_dir: Path) -> Path | None:
292 """
293 Auto-discover configuration file in the given directory.
295 Priority order:
296 1. ezcompiler.yaml
297 2. ezcompiler.json
298 3. pyproject.toml (only if [tool.ezcompiler] section exists)
300 Args:
301 search_dir: Directory to search in
303 Returns:
304 Path | None: Path to discovered config file, or None if not found
305 """
306 # Check YAML first
307 yaml_path = search_dir / "ezcompiler.yaml"
308 if yaml_path.exists():
309 return yaml_path
311 # Check JSON
312 json_path = search_dir / "ezcompiler.json"
313 if json_path.exists():
314 return json_path
316 # Check pyproject.toml (only if it has [tool.ezcompiler])
317 toml_path = search_dir / "pyproject.toml"
318 if toml_path.exists():
319 try:
320 with open(toml_path, "rb") as f:
321 data = tomllib.load(f)
322 if data.get("tool", {}).get("ezcompiler"):
323 return toml_path
324 except Exception: # noqa: S110
325 pass
327 return None
329 # ////////////////////////////////////////////////
330 # CONFIG MERGING
331 # ////////////////////////////////////////////////
333 @staticmethod
334 def merge_config_dicts(
335 base: dict[str, Any], override: dict[str, Any]
336 ) -> dict[str, Any]:
337 """
338 Merge two configuration dictionaries.
340 Override replaces base values. For known nested sections
341 (compilation, upload, advanced), keys are merged within the section.
342 All other values (including include_files, lists) are replaced entirely.
344 Args:
345 base: Base configuration dictionary
346 override: Override configuration dictionary
348 Returns:
349 dict[str, Any]: Merged configuration dictionary
350 """
351 result = base.copy()
352 nested_sections = {"compilation", "upload", "advanced"}
354 for key, value in override.items():
355 if ( 355 ↛ 361line 355 didn't jump to line 361 because the condition on line 355 was never true
356 key in nested_sections
357 and key in result
358 and isinstance(result[key], dict)
359 and isinstance(value, dict)
360 ):
361 result[key] = {**result[key], **value}
362 else:
363 result[key] = value
365 return result