Coverage for src / ezcompiler / shared / compiler_config.py: 88.43%
93 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# COMPILER_CONFIG - Configuration dataclass
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""
7Compiler configuration - Configuration dataclass for EzCompiler.
9This module provides the CompilerConfig dataclass for centralizing all
10configuration parameters needed for project compilation, versioning,
11packaging, and distribution.
12"""
14from __future__ import annotations
16# ///////////////////////////////////////////////////////////////
17# IMPORTS
18# ///////////////////////////////////////////////////////////////
19# Standard library imports
20from dataclasses import dataclass, field
21from pathlib import Path
22from typing import Any
24# Local imports
25from .exceptions import ConfigurationError
27# ///////////////////////////////////////////////////////////////
28# CLASSES
29# ///////////////////////////////////////////////////////////////
32@dataclass
33class CompilerConfig:
34 """
35 Configuration class for project compilation.
37 Centralizes all configuration parameters needed for project
38 compilation, version generation, packaging, and distribution.
39 Validates configuration on initialization and provides helper
40 properties for file paths.
42 Attributes:
43 version: Project version (e.g., "1.0.0")
44 project_name: Name of the project
45 main_file: Path to main Python file
46 include_files: Dict with 'files' and 'folders' lists
47 output_folder: Path to output directory
48 version_filename: Name of version info file (default: "version_info.txt")
49 project_description: Project description
50 company_name: Company or organization name
51 author: Project author
52 icon: Path to project icon
53 packages: List of Python packages to include
54 includes: List of modules to include
55 excludes: List of modules to exclude
56 console: Show console window in compiled app (default: True)
57 compiler: Compiler to use - "auto", "Cx_Freeze", "PyInstaller", "Nuitka"
58 zip_needed: Create zip archive (default: True)
59 repo_needed: Use repository (default: False)
60 upload_structure: Upload target - "disk" or "server"
61 repo_path: Repository path (default: "releases")
62 server_url: Server upload URL
63 optimize: Optimize code (default: True)
64 strip: Strip debug info (default: False)
65 debug: Enable debug mode (default: False)
66 compiler_options: Compiler-specific options dict (default: {})
68 Example:
69 >>> config = CompilerConfig(
70 ... version="1.0.0",
71 ... project_name="MyApp",
72 ... main_file="main.py",
73 ... include_files={"files": ["config.yaml"], "folders": ["lib"]},
74 ... output_folder=Path("dist")
75 ... )
76 >>> config_dict = config.to_dict()
77 """
79 # ////////////////////////////////////////////////
80 # REQUIRED FIELDS
81 # ////////////////////////////////////////////////
83 version: str
84 project_name: str
85 main_file: str
86 include_files: dict[str, list[str]]
87 output_folder: Path
89 # ////////////////////////////////////////////////
90 # OPTIONAL FIELDS WITH DEFAULTS
91 # ////////////////////////////////////////////////
93 version_filename: str = "version_info.txt"
94 project_description: str = ""
95 company_name: str = ""
96 author: str = ""
97 icon: str = ""
98 packages: list[str] = field(default_factory=list)
99 includes: list[str] = field(default_factory=list)
100 excludes: list[str] = field(default_factory=list)
102 # ////////////////////////////////////////////////
103 # COMPILATION OPTIONS
104 # ////////////////////////////////////////////////
106 console: bool = True
107 compiler: str = "auto" # "auto", "Cx_Freeze", "PyInstaller", "Nuitka"
108 zip_needed: bool = True
109 repo_needed: bool = False
111 # ////////////////////////////////////////////////
112 # UPLOAD OPTIONS
113 # ////////////////////////////////////////////////
115 upload_structure: str = "disk" # "disk" or "server"
116 repo_path: str = "releases"
117 server_url: str = ""
119 # ////////////////////////////////////////////////
120 # ADVANCED OPTIONS
121 # ////////////////////////////////////////////////
123 optimize: bool = True
124 strip: bool = False
125 debug: bool = False
127 # ////////////////////////////////////////////////
128 # COMPILER-SPECIFIC OPTIONS
129 # ////////////////////////////////////////////////
131 compiler_options: dict[str, Any] = field(default_factory=dict)
133 # ////////////////////////////////////////////////
134 # INITIALIZATION AND VALIDATION
135 # ////////////////////////////////////////////////
137 def __post_init__(self) -> None:
138 """
139 Validate configuration after initialization.
141 Called automatically after __init__ to validate all fields
142 and ensure configuration is valid before use.
144 Raises:
145 ConfigurationError: If any validation fails
146 """
147 self._validate_required_fields()
148 self._validate_include_files()
149 self._validate_paths()
150 self._validate_compiler_option()
152 def _validate_required_fields(self) -> None:
153 """
154 Validate required fields are not empty.
156 Raises:
157 ConfigurationError: If any required field is empty
158 """
159 if not self.version: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 raise ConfigurationError("Version cannot be empty")
161 if not self.project_name: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 raise ConfigurationError("Project name cannot be empty")
163 if not self.main_file: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 raise ConfigurationError("Main file cannot be empty")
166 def _validate_include_files(self) -> None:
167 """
168 Validate and normalize include_files payload.
170 Expected format:
171 {"files": ["..."], "folders": ["..."]}
173 Raises:
174 ConfigurationError: If include_files structure is invalid
175 """
176 if not isinstance(self.include_files, dict): 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 raise ConfigurationError("include_files must be a dictionary")
179 files = self.include_files.get("files", [])
180 folders = self.include_files.get("folders", [])
182 if not isinstance(files, list):
183 raise ConfigurationError("include_files['files'] must be a list")
184 if not isinstance(folders, list): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 raise ConfigurationError("include_files['folders'] must be a list")
187 if not all(isinstance(item, str) and item.strip() for item in files):
188 raise ConfigurationError(
189 "include_files['files'] must contain non-empty strings"
190 )
191 if not all(isinstance(item, str) and item.strip() for item in folders):
192 raise ConfigurationError(
193 "include_files['folders'] must contain non-empty strings"
194 )
196 # Normalize to canonical shape even when keys are missing.
197 self.include_files = {
198 "files": files,
199 "folders": folders,
200 }
202 def _validate_paths(self) -> None:
203 """
204 Validate file and folder paths.
206 Ensures main file exists and output folder is accessible.
207 Converts output_folder to Path if it's a string.
209 Raises:
210 ConfigurationError: If main file doesn't exist
211 """
212 if not Path(self.main_file).exists(): 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 raise ConfigurationError(f"Main file not found: {self.main_file}")
215 if isinstance(self.output_folder, str):
216 self.output_folder = Path(self.output_folder)
218 def _validate_compiler_option(self) -> None:
219 """
220 Validate compiler option.
222 Ensures compiler is one of the supported options.
224 Raises:
225 ConfigurationError: If compiler is not valid
226 """
227 valid_compilers = ["auto", "Cx_Freeze", "PyInstaller", "Nuitka"]
228 if self.compiler not in valid_compilers:
229 raise ConfigurationError(
230 f"Invalid compiler: {self.compiler}. Must be one of {valid_compilers}"
231 )
233 # ////////////////////////////////////////////////
234 # PATH HELPER PROPERTIES
235 # ////////////////////////////////////////////////
237 @property
238 def version_file(self) -> Path:
239 """
240 Get the full path to the version file.
242 Returns:
243 Path: Full path to version_info.txt in output folder
244 """
245 return self.output_folder / self.version_filename
247 @property
248 def zip_file_path(self) -> Path:
249 """
250 Get the path to the zip file.
252 Uses the project name as the zip filename, placed next to the
253 output folder (e.g., dist/MyApp.zip).
255 Returns:
256 Path: Path to the zip archive file
257 """
258 return self.output_folder.parent / f"{self.project_name}.zip"
260 # ////////////////////////////////////////////////
261 # SERIALIZATION METHODS
262 # ////////////////////////////////////////////////
264 def to_dict(self) -> dict[str, Any]:
265 """
266 Convert configuration to dictionary.
268 Creates a comprehensive dictionary representation of the
269 configuration with nested structures for compilation, upload,
270 and advanced settings.
272 Returns:
273 dict[str, Any]: Configuration as nested dictionary
275 Example:
276 >>> config = CompilerConfig(...)
277 >>> config_dict = config.to_dict()
278 >>> print(config_dict["version"])
279 '1.0.0'
280 """
281 return {
282 "version": self.version,
283 "project_name": self.project_name,
284 "project_description": self.project_description,
285 "company_name": self.company_name,
286 "author": self.author,
287 "main_file": self.main_file,
288 "icon": self.icon,
289 "version_filename": self.version_filename,
290 "output_folder": str(self.output_folder),
291 "include_files": self.include_files,
292 "packages": self.packages,
293 "includes": self.includes,
294 "excludes": self.excludes,
295 "compilation": {
296 "console": self.console,
297 "compiler": self.compiler,
298 "zip_needed": self.zip_needed,
299 "repo_needed": self.repo_needed,
300 },
301 "upload": {
302 "structure": self.upload_structure,
303 "repo_path": self.repo_path,
304 "server_url": self.server_url,
305 },
306 "advanced": {
307 "optimize": self.optimize,
308 "strip": self.strip,
309 "debug": self.debug,
310 },
311 "compiler_options": self.compiler_options,
312 }
314 @classmethod
315 def from_dict(cls, config_dict: dict[str, Any]) -> CompilerConfig:
316 """
317 Create configuration from dictionary.
319 Flattens nested structures (compilation, upload, advanced)
320 and creates a new CompilerConfig instance. Handles backward
321 compatibility for 'version_file' key.
323 Args:
324 config_dict: Configuration dictionary with nested structures
326 Returns:
327 CompilerConfig: New configuration instance
329 Raises:
330 ConfigurationError: If required fields are missing or invalid
332 Example:
333 >>> config_dict = {
334 ... "version": "1.0.0",
335 ... "project_name": "MyApp",
336 ... "main_file": "main.py",
337 ... "include_files": {"files": [], "folders": []},
338 ... "output_folder": "dist"
339 ... }
340 >>> config = CompilerConfig.from_dict(config_dict)
341 """
342 config_copy = config_dict.copy()
344 # Flatten nested structures
345 compilation = config_copy.get("compilation", {})
346 upload = config_copy.get("upload", {})
347 advanced = config_copy.get("advanced", {})
349 config_copy.update(compilation)
350 config_copy.update(upload)
351 config_copy.update(advanced)
353 # Remove nested keys
354 config_copy.pop("compilation", None)
355 config_copy.pop("upload", None)
356 config_copy.pop("advanced", None)
358 # Remap nested key names to dataclass field names
359 # "upload.structure" becomes "upload_structure" after flattening
360 if "structure" in config_copy:
361 if "upload_structure" not in config_copy: 361 ↛ 364line 361 didn't jump to line 364 because the condition on line 361 was always true
362 config_copy["upload_structure"] = config_copy.pop("structure")
363 else:
364 config_copy.pop("structure")
366 # Handle backward compatibility
367 if "version_file" in config_copy and "version_filename" not in config_copy:
368 config_copy["version_filename"] = config_copy.pop("version_file")
370 return cls(**config_copy)