Coverage for src/ezcompiler/shared/_compiler_config.py: 91.02%
173 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-04 11:22 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-04 11:22 +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 TYPE_CHECKING, Any
24# Local imports
25from .exceptions import ConfigurationError
27if TYPE_CHECKING: 27 ↛ 28line 27 didn't jump to line 28 because the condition on line 27 was never true
28 from .._types import ReleaseDestination, RepoDestination
30# ///////////////////////////////////////////////////////////////
31# CONSTANTS
32# ///////////////////////////////////////////////////////////////
34# Mapping compiler name -> per-compiler config section key.
35# Only the section matching config.compiler is applied; the others may
36# coexist in the file as ready-to-use alternative configurations.
37COMPILER_SECTION_KEYS: dict[str, str] = {
38 "PyInstaller": "pyinstaller",
39 "Cx_Freeze": "cx_freeze",
40 "Nuitka": "nuitka",
41}
43# ///////////////////////////////////////////////////////////////
44# CLASSES
45# ///////////////////////////////////////////////////////////////
48@dataclass
49class CompilerConfig:
50 """
51 Configuration class for project compilation.
53 Centralizes all configuration parameters needed for project
54 compilation, version generation, packaging, and distribution.
55 Validates configuration on initialization and provides helper
56 properties for file paths.
58 Attributes:
59 version: Project version (e.g., "1.0.0")
60 project_name: Name of the project
61 main_file: Path to main Python file
62 include_files: Dict with 'files' and 'folders' lists
63 output_folder: Path to output directory
64 version_filename: Name of version info file (default: "version_info.txt")
65 project_description: Project description
66 company_name: Company or organization name
67 author: Project author
68 icon: Path to project icon
69 packages: List of Python packages to include
70 includes: List of modules to include
71 excludes: List of modules to exclude
72 console: Show console window in compiled app (default: True)
73 compiler: Compiler to use - "" (unset -> prompt), "Cx_Freeze", "PyInstaller", "Nuitka"
74 repo_destination: TUF repo upload backend - "disk" | "server" | "r2"
75 release_destination: Zip installer upload backend - "disk" | "server"
76 repo_endpoint: Endpoint for TUF repo upload (path, URL, or "bucket/prefix")
77 release_endpoint: Endpoint for zip installer upload (path or URL)
78 optimize: Optimize code (default: True)
79 strip: Strip debug info (default: False)
80 debug: Enable debug mode (default: False)
81 optimize: Optimize code (compiler-specific, set via the compiler section)
82 strip: Strip debug info (compiler-specific, set via the compiler section)
83 compiler_options: Compiler-specific options dict, populated from the
84 per-compiler section ([tool.ezcompiler.<pyinstaller|cx_freeze|nuitka>])
85 that matches the selected compiler (default: {})
86 tuf_enabled: Enable TUF secure release pipeline (default: False)
87 tuf_repo_dir: Path to TUF repository directory
88 tuf_keys_dir: Path to TUF keys directory
90 Example:
91 >>> config = CompilerConfig(
92 ... version="1.0.0",
93 ... project_name="MyApp",
94 ... main_file="main.py",
95 ... include_files={"files": ["config.yaml"], "folders": ["lib"]},
96 ... output_folder=Path("dist")
97 ... )
98 >>> config_dict = config.to_dict()
99 """
101 # ////////////////////////////////////////////////
102 # REQUIRED FIELDS
103 # ////////////////////////////////////////////////
105 version: str
106 project_name: str
107 main_file: str
108 include_files: dict[str, list[str]]
109 output_folder: Path
111 # ////////////////////////////////////////////////
112 # OPTIONAL FIELDS WITH DEFAULTS
113 # ////////////////////////////////////////////////
115 version_filename: str = "version_info.txt"
116 project_description: str = ""
117 company_name: str = ""
118 author: str = ""
119 icon: str = ""
120 packages: list[str] = field(default_factory=list)
121 includes: list[str] = field(default_factory=list)
122 excludes: list[str] = field(default_factory=list)
124 # ////////////////////////////////////////////////
125 # COMPILATION OPTIONS
126 # ////////////////////////////////////////////////
128 console: bool = True
129 compiler: str = "" # "" (unset -> prompt), "Cx_Freeze", "PyInstaller", "Nuitka"
131 # ////////////////////////////////////////////////
132 # UPLOAD OPTIONS
133 # ////////////////////////////////////////////////
135 repo_destination: RepoDestination = "disk"
136 release_destination: ReleaseDestination = "disk"
137 repo_endpoint: str = ""
138 release_endpoint: str = ""
139 repo_public_url: str = ""
141 # ////////////////////////////////////////////////
142 # ADVANCED OPTIONS
143 # ////////////////////////////////////////////////
145 optimize: bool = True
146 strip: bool = False
147 debug: bool = False
149 # ////////////////////////////////////////////////
150 # SECURE RELEASE OPTIONS (tufup)
151 # ////////////////////////////////////////////////
153 tuf_enabled: bool = False
154 tuf_repo_dir: Path | None = None
155 tuf_keys_dir: Path | None = None
156 # Per-role metadata lifetimes (days). Maps TUF role names
157 # (root/targets/snapshot/timestamp) to expiration days. None → tufup
158 # defaults (root=365, targets=7, snapshot=7, timestamp=1). Raise these for
159 # projects updated irregularly so metadata does not expire between releases.
160 tuf_expiration_days: dict[str, int] | None = None
162 # ////////////////////////////////////////////////
163 # INSTALLER OPTIONS (Inno Setup)
164 # ////////////////////////////////////////////////
166 installer_enabled: bool = False
167 installer_output_dir: Path | None = None
168 installer_iss_path: Path | None = None
169 # Per-user install: installs to %LOCALAPPDATA%\Programs (no admin rights
170 # required) instead of Program Files. Required for tufup in-place
171 # auto-update, which cannot self-elevate to overwrite Program Files.
172 installer_per_user: bool = False
174 # ////////////////////////////////////////////////
175 # COMPILER-SPECIFIC OPTIONS
176 # ////////////////////////////////////////////////
178 compiler_options: dict[str, Any] = field(default_factory=dict)
180 # ////////////////////////////////////////////////
181 # INITIALIZATION AND VALIDATION
182 # ////////////////////////////////////////////////
184 def __post_init__(self) -> None:
185 """
186 Validate configuration after initialization.
188 Called automatically after __init__ to validate all fields
189 and ensure configuration is valid before use.
191 Raises:
192 ConfigurationError: If any validation fails
193 """
194 self._validate_required_fields()
195 self._validate_include_files()
196 self._validate_paths()
197 self._validate_compiler_option()
198 self._validate_destinations()
199 self._validate_installer_option()
201 def _validate_required_fields(self) -> None:
202 """
203 Validate required fields are not empty.
205 Raises:
206 ConfigurationError: If any required field is empty
207 """
208 if not self.version: 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true
209 raise ConfigurationError("Version cannot be empty")
210 if not self.project_name: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 raise ConfigurationError("Project name cannot be empty")
212 if not self.main_file: 212 ↛ 213line 212 didn't jump to line 213 because the condition on line 212 was never true
213 raise ConfigurationError("Main file cannot be empty")
215 def _validate_include_files(self) -> None:
216 """
217 Validate and normalize include_files payload.
219 Expected format:
220 {"files": ["..."], "folders": ["..."]}
222 Raises:
223 ConfigurationError: If include_files structure is invalid
224 """
225 if not isinstance(self.include_files, dict): 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 raise ConfigurationError("include_files must be a dictionary")
228 files = self.include_files.get("files", [])
229 folders = self.include_files.get("folders", [])
231 if not isinstance(files, list):
232 raise ConfigurationError("include_files['files'] must be a list")
233 if not isinstance(folders, list): 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true
234 raise ConfigurationError("include_files['folders'] must be a list")
236 if not all(isinstance(item, str) and item.strip() for item in files):
237 raise ConfigurationError(
238 "include_files['files'] must contain non-empty strings"
239 )
240 if not all(isinstance(item, str) and item.strip() for item in folders):
241 raise ConfigurationError(
242 "include_files['folders'] must contain non-empty strings"
243 )
245 # Normalize to canonical shape even when keys are missing.
246 self.include_files = {
247 "files": files,
248 "folders": folders,
249 }
251 def _validate_paths(self) -> None:
252 """
253 Validate file and folder paths.
255 Ensures main file exists and output folder is accessible.
256 Converts output_folder and tuf dirs to Path if they are strings.
258 Raises:
259 ConfigurationError: If main file doesn't exist
260 """
261 if not Path(self.main_file).exists(): 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 raise ConfigurationError(f"Main file not found: {self.main_file}")
264 if isinstance(self.output_folder, str):
265 self.output_folder = Path(self.output_folder)
267 if isinstance(self.tuf_repo_dir, str):
268 self.tuf_repo_dir = Path(self.tuf_repo_dir)
270 if isinstance(self.tuf_keys_dir, str):
271 self.tuf_keys_dir = Path(self.tuf_keys_dir)
273 if isinstance(self.installer_output_dir, str):
274 self.installer_output_dir = Path(self.installer_output_dir)
276 if isinstance(self.installer_iss_path, str): 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true
277 self.installer_iss_path = Path(self.installer_iss_path)
279 def _validate_compiler_option(self) -> None:
280 """
281 Validate compiler option.
283 Ensures compiler is one of the supported options. An empty string
284 means "unset" (resolved interactively at compile time) and is allowed.
286 Raises:
287 ConfigurationError: If compiler is not valid
288 """
289 valid_compilers = ["Cx_Freeze", "PyInstaller", "Nuitka"]
290 if self.compiler and self.compiler not in valid_compilers:
291 raise ConfigurationError(
292 f"Invalid compiler: {self.compiler}. Must be one of {valid_compilers}"
293 )
295 def _validate_destinations(self) -> None:
296 """
297 Validate upload destination backends and require endpoints for non-disk targets.
299 The TUF repository may be uploaded to disk, server or r2; the release
300 zip only to disk or server. Any other value is rejected.
301 Non-disk destinations require the matching endpoint to be non-empty.
303 Raises:
304 ConfigurationError: If a destination is not supported or endpoint is missing
305 """
306 valid_repo = ["disk", "server", "r2"]
307 if self.repo_destination not in valid_repo:
308 raise ConfigurationError(
309 f"Invalid repo_destination: {self.repo_destination}. "
310 f"Must be one of {valid_repo}"
311 )
313 valid_release = ["disk", "server", "r2"]
314 if self.release_destination not in valid_release:
315 raise ConfigurationError(
316 f"Invalid release_destination: {self.release_destination}. "
317 f"Must be one of {valid_release}"
318 )
320 if self.repo_destination != "disk" and not self.repo_endpoint:
321 raise ConfigurationError(
322 f"repo_endpoint is required when repo_destination='{self.repo_destination}'. "
323 "For 'server': provide a URL. For 'r2': provide 'bucket/prefix'."
324 )
326 if self.release_destination != "disk" and not self.release_endpoint:
327 raise ConfigurationError(
328 f"release_endpoint is required when release_destination='{self.release_destination}'. "
329 "For 'server': provide a URL. For 'r2': provide 'bucket/prefix'."
330 )
332 if (
333 self.tuf_enabled
334 and self.repo_destination != "disk"
335 and not self.repo_public_url
336 ):
337 raise ConfigurationError(
338 f"repo_public_url is required when tuf_enabled=True and "
339 f"repo_destination='{self.repo_destination}'. "
340 "Provide the public URL where the TUF repository is served "
341 "(e.g. 'https://updates.myapp.com')."
342 )
344 def _validate_installer_option(self) -> None:
345 """
346 Validate installer configuration.
348 Ensures installer_iss_path, when provided, points to an existing file.
350 Raises:
351 ConfigurationError: If installer_iss_path is set but not found
352 """
353 if (
354 self.installer_iss_path is not None
355 and not Path(self.installer_iss_path).is_file()
356 ):
357 raise ConfigurationError(
358 f"installer_iss_path not found: {self.installer_iss_path}"
359 )
361 # ////////////////////////////////////////////////
362 # PATH HELPER PROPERTIES
363 # ////////////////////////////////////////////////
365 @property
366 def version_file(self) -> Path:
367 """
368 Get the full path to the version file.
370 Returns:
371 Path: Full path to version_info.txt in output folder
372 """
373 return self.output_folder / self.version_filename
375 @property
376 def zip_file_path(self) -> Path:
377 """
378 Get the path to the zip file.
380 Uses the project name as the zip filename, placed next to the
381 output folder (e.g., dist/MyApp.zip).
383 Returns:
384 Path: Path to the zip archive file
385 """
386 return self.output_folder.parent / f"{self.project_name}.zip"
388 # ////////////////////////////////////////////////
389 # RELEASE HELPER PROPERTIES
390 # ////////////////////////////////////////////////
392 @property
393 def resolved_repo_destination(self) -> str | None:
394 """Destination résolue pour l'arbre TUF."""
395 return self.repo_endpoint or None
397 @property
398 def resolved_release_destination(self) -> str | None:
399 """Destination résolue pour le zip installeur."""
400 return self.release_endpoint or None
402 # ////////////////////////////////////////////////
403 # SERIALIZATION METHODS
404 # ////////////////////////////////////////////////
406 def to_dict(self) -> dict[str, Any]:
407 """
408 Convert configuration to dictionary.
410 Creates a comprehensive dictionary representation of the
411 configuration with nested structures for compilation, upload,
412 and advanced settings. Compiler-specific options (optimize, strip
413 and free-form compiler_options) are emitted under the per-compiler
414 section key matching the selected compiler.
416 Returns:
417 dict[str, Any]: Configuration as nested dictionary
419 Example:
420 >>> config = CompilerConfig(...)
421 >>> config_dict = config.to_dict()
422 >>> print(config_dict["version"])
423 '1.0.0'
424 """
425 result: dict[str, Any] = {
426 "version": self.version,
427 "project_name": self.project_name,
428 "project_description": self.project_description,
429 "company_name": self.company_name,
430 "author": self.author,
431 "main_file": self.main_file,
432 "icon": self.icon,
433 "version_filename": self.version_filename,
434 "output_folder": str(self.output_folder),
435 "include_files": self.include_files,
436 "packages": self.packages,
437 "includes": self.includes,
438 "excludes": self.excludes,
439 "compilation": {
440 "console": self.console,
441 "compiler": self.compiler,
442 },
443 "upload": {
444 "repo_destination": self.repo_destination,
445 "release_destination": self.release_destination,
446 "repo_endpoint": self.repo_endpoint,
447 "release_endpoint": self.release_endpoint,
448 "repo_public_url": self.repo_public_url,
449 },
450 "advanced": {
451 "debug": self.debug,
452 },
453 "release": {
454 "tuf_enabled": self.tuf_enabled,
455 "tuf_repo_dir": str(self.tuf_repo_dir) if self.tuf_repo_dir else None,
456 "tuf_keys_dir": str(self.tuf_keys_dir) if self.tuf_keys_dir else None,
457 "tuf_expiration_days": self.tuf_expiration_days,
458 },
459 "installer": {
460 "installer_enabled": self.installer_enabled,
461 "installer_output_dir": (
462 str(self.installer_output_dir)
463 if self.installer_output_dir
464 else None
465 ),
466 "installer_iss_path": (
467 str(self.installer_iss_path) if self.installer_iss_path else None
468 ),
469 "installer_per_user": self.installer_per_user,
470 },
471 }
473 # Emit compiler-specific options under the per-compiler section key.
474 section_key = COMPILER_SECTION_KEYS.get(self.compiler)
475 if section_key: 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 result[section_key] = {
477 "optimize": self.optimize,
478 "strip": self.strip,
479 **self.compiler_options,
480 }
482 return result
484 @classmethod
485 def from_dict(cls, config_dict: dict[str, Any]) -> CompilerConfig:
486 """
487 Create configuration from dictionary.
489 Flattens nested structures (compilation, upload, advanced)
490 and creates a new CompilerConfig instance. Handles backward
491 compatibility for 'version_file' key.
493 Args:
494 config_dict: Configuration dictionary with nested structures
496 Returns:
497 CompilerConfig: New configuration instance
499 Raises:
500 ConfigurationError: If required fields are missing or invalid
502 Example:
503 >>> config_dict = {
504 ... "version": "1.0.0",
505 ... "project_name": "MyApp",
506 ... "main_file": "main.py",
507 ... "include_files": {"files": [], "folders": []},
508 ... "output_folder": "dist"
509 ... }
510 >>> config = CompilerConfig.from_dict(config_dict)
511 """
512 config_copy = config_dict.copy()
514 # Pop per-compiler sections before flattening; only the one matching
515 # the selected compiler is applied (the others may coexist as
516 # ready-to-use alternatives and are ignored).
517 per_compiler_sections = {
518 key: config_copy.pop(key)
519 for key in COMPILER_SECTION_KEYS.values()
520 if key in config_copy
521 }
523 # 'compiler_options' (flat dict shared across compilers) has been
524 # replaced by per-compiler sections.
525 if "compiler_options" in config_copy:
526 raise ConfigurationError(
527 "'compiler_options' a été supprimé. Utiliser une section par "
528 "compilateur : [tool.ezcompiler.pyinstaller], "
529 "[tool.ezcompiler.cx_freeze] ou [tool.ezcompiler.nuitka]."
530 )
532 # 'optimize'/'strip' ont quitté 'advanced' pour les sections par compilateur.
533 advanced = config_copy.get("advanced", {})
534 if "optimize" in advanced or "strip" in advanced:
535 raise ConfigurationError(
536 "'advanced.optimize' / 'advanced.strip' déplacés vers la section "
537 "du compilateur ([tool.ezcompiler.<pyinstaller|cx_freeze|nuitka>])."
538 )
540 # Flatten nested structures
541 compilation = config_copy.get("compilation", {})
542 upload = config_copy.get("upload", {})
543 release = config_copy.get("release", {})
544 installer = config_copy.get("installer", {})
546 config_copy.update(compilation)
547 config_copy.update(upload)
548 config_copy.update(advanced)
549 config_copy.update(release)
550 config_copy.update(installer)
552 # Remove nested keys
553 config_copy.pop("compilation", None)
554 config_copy.pop("upload", None)
555 config_copy.pop("advanced", None)
556 config_copy.pop("release", None)
557 config_copy.pop("installer", None)
559 # Select the per-compiler section matching the resolved compiler.
560 # optimize/strip are promoted to top-level fields; the remaining keys
561 # become the free-form compiler_options passed to the adapter.
562 section_key = COMPILER_SECTION_KEYS.get(config_copy.get("compiler", ""))
563 selected_section = (
564 dict(per_compiler_sections.get(section_key, {})) if section_key else {}
565 )
566 if "optimize" in selected_section:
567 config_copy["optimize"] = selected_section.pop("optimize")
568 if "strip" in selected_section:
569 config_copy["strip"] = selected_section.pop("strip")
570 config_copy["compiler_options"] = selected_section
572 # 'auto' supprimé : plus de compilateur par défaut implicite.
573 if config_copy.get("compiler") == "auto":
574 raise ConfigurationError(
575 "compiler='auto' a été supprimé. Indiquer explicitement "
576 "'Cx_Freeze', 'PyInstaller' ou 'Nuitka', ou laisser le champ "
577 "vide pour choisir interactivement au moment de la compilation."
578 )
580 # "upload.structure" / "upload_structure" supprimés (breaking).
581 if "structure" in config_copy or "upload_structure" in config_copy:
582 raise ConfigurationError(
583 "'upload.structure' / 'upload_structure' a été supprimé. "
584 "Utiliser 'upload.repo_destination' et 'upload.release_destination'."
585 )
587 if "zip_needed" in config_copy: 587 ↛ 588line 587 didn't jump to line 588 because the condition on line 587 was never true
588 raise ConfigurationError(
589 "'zip_needed' a été supprimé. Le zip est toujours produit quand "
590 "tuf_enabled=True. Pour le flux sans release, le zip dépend du "
591 "résultat de compilation."
592 )
594 # Migration errors for removed upload fields.
595 _removed_upload = {
596 "repo_path": "'repo_path' supprimé. Utiliser 'upload.repo_endpoint'.",
597 "server_url": "'server_url' supprimé. Utiliser 'upload.repo_endpoint' ou 'upload.release_endpoint'.",
598 "update_repo_url": "'update_repo_url' supprimé. Utiliser 'upload.repo_endpoint'.",
599 "r2_bucket": "'r2_bucket' supprimé. Utiliser 'upload.repo_endpoint' au format \"bucket/prefix\".",
600 "r2_remote_prefix": "'r2_remote_prefix' supprimé. Voir 'upload.repo_endpoint' (format \"bucket/prefix\").",
601 }
602 for key, msg in _removed_upload.items():
603 if key in config_copy:
604 raise ConfigurationError(msg)
606 # Migration errors for removed release fields.
607 _removed_release = {
608 "release_needed": "'release_needed' renommé en 'tuf_enabled'.",
609 "release_type": "'release_type' supprimé. tufup est l'unique backend de release.",
610 "repo_needed": "'repo_needed' supprimé. Utiliser 'release.tuf_enabled'.",
611 }
612 for key, msg in _removed_release.items():
613 if key in config_copy:
614 raise ConfigurationError(msg)
616 # Handle backward compatibility
617 if "version_file" in config_copy and "version_filename" not in config_copy:
618 config_copy["version_filename"] = config_copy.pop("version_file")
620 # Reject unknown keys with a clear error
621 import dataclasses as _dc
623 valid_fields = {f.name for f in _dc.fields(cls)}
624 unknown = set(config_copy) - valid_fields
625 if unknown: 625 ↛ 626line 625 didn't jump to line 626 because the condition on line 625 was never true
626 raise ConfigurationError(
627 f"Unknown configuration key(s): {sorted(unknown)}. "
628 "Check your config file for typos or removed keys."
629 )
631 return cls(**config_copy)