Coverage for src/ezcompiler/shared/_compiler_config.py: 91.24%

188 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 16:37 +0000

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

2# COMPILER_CONFIG - Configuration dataclass 

3# Project: ezcompiler 

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

5 

6""" 

7Compiler configuration - Configuration dataclass for EzCompiler. 

8 

9This module provides the CompilerConfig dataclass for centralizing all 

10configuration parameters needed for project compilation, versioning, 

11packaging, and distribution. 

12""" 

13 

14from __future__ import annotations 

15 

16import re 

17 

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

19# IMPORTS 

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

21# Standard library imports 

22from dataclasses import dataclass, field 

23from pathlib import Path 

24from typing import TYPE_CHECKING, Any 

25 

26# Local imports 

27from ._constants import TUF_PUBLIC_DIRS 

28from ._installer_config import InstallerConfig, raise_on_legacy_installer_keys 

29from .exceptions import ConfigurationError 

30 

31if TYPE_CHECKING: 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true

32 from .._types import ReleaseDestination, RepoDestination 

33 

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

35# CONSTANTS 

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

37 

38# Mapping compiler name -> per-compiler config section key. 

39# Only the section matching config.compiler is applied; the others may 

40# coexist in the file as ready-to-use alternative configurations. 

41COMPILER_SECTION_KEYS: dict[str, str] = { 

42 "PyInstaller": "pyinstaller", 

43 "Cx_Freeze": "cx_freeze", 

44 "Nuitka": "nuitka", 

45} 

46 

47# Keys accepted by each nested config section. The flattening of from_dict() 

48# validates against this schema: without it, any field is accepted in any 

49# section and a key present in two sections is resolved by the merge order. 

50# 'installer' is not listed here — it stays a sub-object (InstallerConfig). 

51SECTION_SCHEMA: dict[str, frozenset[str]] = { 

52 "compilation": frozenset({"console", "compiler"}), 

53 "upload": frozenset( 

54 { 

55 "repo_destination", 

56 "release_destination", 

57 "repo_endpoint", 

58 "release_endpoint", 

59 "repo_public_url", 

60 } 

61 ), 

62 "advanced": frozenset({"debug"}), 

63 "release": frozenset( 

64 {"tuf_enabled", "tuf_repo_dir", "tuf_keys_dir", "tuf_expiration_days"} 

65 ), 

66} 

67 

68# Keys removed by past migrations, with the message that names the 

69# replacement. Checked both inside a section (during the strict flattening) 

70# and at the top level, so the diagnostic is the same wherever the key sits. 

71REMOVED_KEYS: dict[str, str] = { 

72 "repo_path": "'repo_path' was removed. Use 'upload.repo_endpoint'.", 

73 "server_url": "'server_url' was removed. Use 'upload.repo_endpoint' or 'upload.release_endpoint'.", 

74 "update_repo_url": "'update_repo_url' was removed. Use 'upload.repo_endpoint'.", 

75 "r2_bucket": "'r2_bucket' was removed. Use 'upload.repo_endpoint' in \"bucket/prefix\" form.", 

76 "r2_remote_prefix": "'r2_remote_prefix' was removed. See 'upload.repo_endpoint' (\"bucket/prefix\" form).", 

77 "release_needed": "'release_needed' was renamed to 'tuf_enabled'.", 

78 "release_type": "'release_type' was removed. tufup is the only release backend.", 

79 "repo_needed": "'repo_needed' was removed. Use 'release.tuf_enabled'.", 

80 "structure": "'upload.structure' / 'upload_structure' was removed. " 

81 "Use 'upload.repo_destination' and 'upload.release_destination'.", 

82 "upload_structure": "'upload.structure' / 'upload_structure' was removed. " 

83 "Use 'upload.repo_destination' and 'upload.release_destination'.", 

84 "zip_needed": "'zip_needed' was removed. The zip is always produced when " 

85 "tuf_enabled=True. In the release-less flow, the zip depends on the " 

86 "compilation result.", 

87} 

88 

89_OWNER_REPO_RE = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+") 

90 

91# /////////////////////////////////////////////////////////////// 

92# CLASSES 

93# /////////////////////////////////////////////////////////////// 

94 

95 

96@dataclass 

97class CompilerConfig: 

98 """ 

99 Configuration class for project compilation. 

100 

101 Centralizes all configuration parameters needed for project 

102 compilation, version generation, packaging, and distribution. 

103 Validates configuration on initialization and provides helper 

104 properties for file paths. 

105 

106 Attributes: 

107 version: Project version (e.g., "1.0.0") 

108 project_name: Name of the project 

109 main_file: Path to main Python file 

110 include_files: Dict with 'files' and 'folders' lists 

111 output_folder: Path to output directory 

112 version_filename: Name of version info file (default: "version_info.txt") 

113 project_description: Project description 

114 company_name: Company or organization name 

115 author: Project author 

116 icon: Path to project icon 

117 packages: List of Python packages to include 

118 includes: List of modules to include 

119 excludes: List of modules to exclude 

120 console: Show console window in compiled app (default: True) 

121 compiler: Compiler to use - "" (unset -> prompt), "Cx_Freeze", "PyInstaller", "Nuitka" 

122 repo_destination: TUF repo upload backend - "disk" | "server" | "r2" 

123 release_destination: Release asset destination - disk, server, r2, 

124 github, or gitlab 

125 repo_endpoint: Endpoint for TUF repo upload (path, URL, or "bucket/prefix") 

126 release_endpoint: Release endpoint (path, URL, bucket/prefix, or owner/repo) 

127 optimize: Optimize code (default: True) 

128 strip: Strip debug info (default: False) 

129 debug: Enable debug mode (default: False) 

130 optimize: Optimize code (compiler-specific, set via the compiler section) 

131 strip: Strip debug info (compiler-specific, set via the compiler section) 

132 compiler_options: Compiler-specific options dict, populated from the 

133 per-compiler section ([tool.ezcompiler.<pyinstaller|cx_freeze|nuitka>]) 

134 that matches the selected compiler (default: {}) 

135 tuf_enabled: Enable TUF secure release pipeline (default: False) 

136 tuf_repo_dir: Path to TUF repository directory 

137 tuf_keys_dir: Path to TUF keys directory 

138 

139 Example: 

140 >>> config = CompilerConfig( 

141 ... version="1.0.0", 

142 ... project_name="MyApp", 

143 ... main_file="main.py", 

144 ... include_files={"files": ["config.yaml"], "folders": ["lib"]}, 

145 ... output_folder=Path("dist") 

146 ... ) 

147 >>> config_dict = config.to_dict() 

148 """ 

149 

150 # //////////////////////////////////////////////// 

151 # REQUIRED FIELDS 

152 # //////////////////////////////////////////////// 

153 

154 version: str 

155 project_name: str 

156 main_file: str 

157 include_files: dict[str, list[str]] 

158 output_folder: Path 

159 

160 # //////////////////////////////////////////////// 

161 # OPTIONAL FIELDS WITH DEFAULTS 

162 # //////////////////////////////////////////////// 

163 

164 version_filename: str = "version_info.txt" 

165 project_description: str = "" 

166 company_name: str = "" 

167 author: str = "" 

168 icon: str = "" 

169 packages: list[str] = field(default_factory=list) 

170 includes: list[str] = field(default_factory=list) 

171 excludes: list[str] = field(default_factory=list) 

172 

173 # //////////////////////////////////////////////// 

174 # COMPILATION OPTIONS 

175 # //////////////////////////////////////////////// 

176 

177 console: bool = True 

178 compiler: str = "" # "" (unset -> prompt), "Cx_Freeze", "PyInstaller", "Nuitka" 

179 

180 # //////////////////////////////////////////////// 

181 # UPLOAD OPTIONS 

182 # //////////////////////////////////////////////// 

183 

184 repo_destination: RepoDestination = "disk" 

185 release_destination: ReleaseDestination = "disk" 

186 repo_endpoint: str = "" 

187 release_endpoint: str = "" 

188 repo_public_url: str = "" 

189 

190 # //////////////////////////////////////////////// 

191 # ADVANCED OPTIONS 

192 # //////////////////////////////////////////////// 

193 

194 optimize: bool = True 

195 strip: bool = False 

196 debug: bool = False 

197 

198 # //////////////////////////////////////////////// 

199 # SECURE RELEASE OPTIONS (tufup) 

200 # //////////////////////////////////////////////// 

201 

202 tuf_enabled: bool = False 

203 tuf_repo_dir: Path | None = None 

204 tuf_keys_dir: Path | None = None 

205 # Per-role metadata lifetimes (days). Maps TUF role names 

206 # (root/targets/snapshot/timestamp) to expiration days. None → tufup 

207 # defaults (root=365, targets=7, snapshot=7, timestamp=1). Raise these for 

208 # projects updated irregularly so metadata does not expire between releases. 

209 tuf_expiration_days: dict[str, int] | None = None 

210 

211 # //////////////////////////////////////////////// 

212 # INSTALLER OPTIONS (Inno Setup) 

213 # //////////////////////////////////////////////// 

214 

215 installer: InstallerConfig = field(default_factory=InstallerConfig) 

216 

217 # //////////////////////////////////////////////// 

218 # COMPILER-SPECIFIC OPTIONS 

219 # //////////////////////////////////////////////// 

220 

221 compiler_options: dict[str, Any] = field(default_factory=dict) 

222 

223 # //////////////////////////////////////////////// 

224 # INITIALIZATION AND VALIDATION 

225 # //////////////////////////////////////////////// 

226 

227 def __post_init__(self) -> None: 

228 """ 

229 Validate configuration after initialization. 

230 

231 Called automatically after __init__ to validate all fields 

232 and ensure configuration is valid before use. 

233 

234 Raises: 

235 ConfigurationError: If any validation fails 

236 """ 

237 self._validate_required_fields() 

238 self._validate_include_files() 

239 self._validate_paths() 

240 self._validate_compiler_option() 

241 self._validate_destinations() 

242 self._validate_installer_icon() 

243 

244 def _validate_required_fields(self) -> None: 

245 """ 

246 Validate required fields are not empty. 

247 

248 Raises: 

249 ConfigurationError: If any required field is empty 

250 """ 

251 if not self.version: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 raise ConfigurationError("Version cannot be empty") 

253 if not self.project_name: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 raise ConfigurationError("Project name cannot be empty") 

255 if not self.main_file: 255 ↛ 256line 255 didn't jump to line 256 because the condition on line 255 was never true

256 raise ConfigurationError("Main file cannot be empty") 

257 

258 def _validate_include_files(self) -> None: 

259 """ 

260 Validate and normalize include_files payload. 

261 

262 Expected format: 

263 {"files": ["..."], "folders": ["..."]} 

264 

265 Raises: 

266 ConfigurationError: If include_files structure is invalid 

267 """ 

268 if not isinstance(self.include_files, dict): 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 raise ConfigurationError("include_files must be a dictionary") 

270 

271 files = self.include_files.get("files", []) 

272 folders = self.include_files.get("folders", []) 

273 

274 if not isinstance(files, list): 

275 raise ConfigurationError("include_files['files'] must be a list") 

276 if not isinstance(folders, list): 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 raise ConfigurationError("include_files['folders'] must be a list") 

278 

279 if not all(isinstance(item, str) and item.strip() for item in files): 

280 raise ConfigurationError( 

281 "include_files['files'] must contain non-empty strings" 

282 ) 

283 if not all(isinstance(item, str) and item.strip() for item in folders): 

284 raise ConfigurationError( 

285 "include_files['folders'] must contain non-empty strings" 

286 ) 

287 

288 # Normalize to canonical shape even when keys are missing. 

289 self.include_files = { 

290 "files": files, 

291 "folders": folders, 

292 } 

293 

294 def _validate_paths(self) -> None: 

295 """ 

296 Validate file and folder paths. 

297 

298 Ensures main file exists and output folder is accessible. 

299 Converts output_folder and tuf dirs to Path if they are strings. 

300 

301 Raises: 

302 ConfigurationError: If main file doesn't exist 

303 """ 

304 if not Path(self.main_file).exists(): 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true

305 raise ConfigurationError(f"Main file not found: {self.main_file}") 

306 

307 if isinstance(self.output_folder, str): 

308 self.output_folder = Path(self.output_folder) 

309 

310 if isinstance(self.tuf_repo_dir, str): 

311 self.tuf_repo_dir = Path(self.tuf_repo_dir) 

312 

313 if isinstance(self.tuf_keys_dir, str): 

314 self.tuf_keys_dir = Path(self.tuf_keys_dir) 

315 

316 self._validate_tuf_keys_location() 

317 

318 def _validate_tuf_keys_location(self) -> None: 

319 """ 

320 Refuse a private keystore placed inside the published part of the tree. 

321 

322 Only ``metadata/`` and ``targets/`` of the TUF repository are uploaded, 

323 so a ``tuf_keys_dir`` sitting under one of them would publish the 

324 private signing keys. 

325 

326 Raises: 

327 ConfigurationError: If ``tuf_keys_dir`` resolves inside 

328 ``<tuf_repo_dir>/metadata`` or ``<tuf_repo_dir>/targets``. 

329 """ 

330 if self.tuf_keys_dir is None: 

331 return 

332 

333 repo_dir = self.tuf_repo_dir or (Path(self.output_folder) / "repo") 

334 keys = Path(self.tuf_keys_dir).expanduser().resolve() 

335 for public in TUF_PUBLIC_DIRS: 

336 forbidden = (Path(repo_dir).expanduser() / public).resolve() 

337 if keys == forbidden or forbidden in keys.parents: 

338 raise ConfigurationError( 

339 f"tuf_keys_dir invalide : {self.tuf_keys_dir} se trouve sous " 

340 f"{forbidden}, which is published with the TUF tree — the " 

341 "private signing keys would leave the machine. Move the " 

342 "keystore outside metadata/ and targets/ (default " 

343 "<tuf_repo_dir>/keystore)." 

344 ) 

345 

346 def _validate_compiler_option(self) -> None: 

347 """ 

348 Validate compiler option. 

349 

350 Ensures compiler is one of the supported options. An empty string 

351 means "unset" (resolved interactively at compile time) and is allowed. 

352 

353 Raises: 

354 ConfigurationError: If compiler is not valid 

355 """ 

356 valid_compilers = ["Cx_Freeze", "PyInstaller", "Nuitka"] 

357 if self.compiler and self.compiler not in valid_compilers: 

358 raise ConfigurationError( 

359 f"Invalid compiler: {self.compiler}. Must be one of {valid_compilers}" 

360 ) 

361 

362 def _validate_destinations(self) -> None: 

363 """ 

364 Validate upload and publication destinations and their endpoints. 

365 

366 The TUF repository may be uploaded to disk, server or r2. Release assets 

367 may also be published to github or gitlab. Server and r2 destinations 

368 require an endpoint; publication destinations may infer the repository. 

369 

370 Raises: 

371 ConfigurationError: If a destination is not supported or endpoint is missing 

372 """ 

373 valid_repo = ["disk", "server", "r2"] 

374 if self.repo_destination not in valid_repo: 

375 raise ConfigurationError( 

376 f"Invalid repo_destination: {self.repo_destination}. " 

377 f"Must be one of {valid_repo}" 

378 ) 

379 

380 valid_release = ["disk", "server", "r2", "github", "gitlab"] 

381 if self.release_destination not in valid_release: 

382 raise ConfigurationError( 

383 f"Invalid release_destination: {self.release_destination}. " 

384 f"Must be one of {valid_release}" 

385 ) 

386 

387 if self.repo_destination != "disk" and not self.repo_endpoint: 

388 raise ConfigurationError( 

389 f"repo_endpoint is required when repo_destination='{self.repo_destination}'. " 

390 "For 'server': provide a URL. For 'r2': provide 'bucket/prefix'." 

391 ) 

392 

393 if self.release_destination in ("github", "gitlab"): 

394 if self.release_endpoint and not _OWNER_REPO_RE.fullmatch( 

395 self.release_endpoint 

396 ): 

397 raise ConfigurationError( 

398 f"Invalid release_endpoint for " 

399 f"release_destination='{self.release_destination}': " 

400 f"'{self.release_endpoint}'. Expected the " 

401 f'"owner/repo" form (no URL, no protocol), or empty to ' 

402 f"let the CLI infer the repository from the git remote." 

403 ) 

404 elif self.release_destination != "disk" and not self.release_endpoint: 

405 raise ConfigurationError( 

406 f"release_endpoint is required when release_destination='{self.release_destination}'. " 

407 "For 'server': provide a URL. For 'r2': provide 'bucket/prefix'." 

408 ) 

409 

410 if ( 

411 self.tuf_enabled 

412 and self.repo_destination != "disk" 

413 and not self.repo_public_url 

414 ): 

415 raise ConfigurationError( 

416 f"repo_public_url is required when tuf_enabled=True and " 

417 f"repo_destination='{self.repo_destination}'. " 

418 "Provide the public URL where the TUF repository is served " 

419 "(e.g. 'https://updates.myapp.com')." 

420 ) 

421 

422 def _validate_installer_icon(self) -> None: 

423 """ 

424 Validate the icon format when the installer stage is enabled. 

425 

426 Inno Setup requires a .ico file; other formats fail ISCC with an 

427 opaque exit 2. Compilers accept other icon formats, so this check 

428 only applies once the installer stage is enabled. 

429 

430 Raises: 

431 ConfigurationError: If installer.enabled and icon is not a .ico file 

432 """ 

433 if ( 

434 self.installer.enabled 

435 and self.icon 

436 and Path(self.icon).suffix.lower() != ".ico" 

437 ): 

438 raise ConfigurationError( 

439 f"icon must be a .ico file when the installer is enabled: {self.icon}" 

440 ) 

441 

442 # //////////////////////////////////////////////// 

443 # PATH HELPER PROPERTIES 

444 # //////////////////////////////////////////////// 

445 

446 @property 

447 def version_file(self) -> Path: 

448 """ 

449 Get the full path to the version file. 

450 

451 Returns: 

452 Path: Full path to version_info.txt in output folder 

453 """ 

454 return self.output_folder / self.version_filename 

455 

456 @property 

457 def zip_file_path(self) -> Path: 

458 """ 

459 Get the path to the zip file. 

460 

461 Uses the project name as the zip filename, placed next to the 

462 output folder (e.g., dist/MyApp.zip). 

463 

464 Returns: 

465 Path: Path to the zip archive file 

466 """ 

467 return self.output_folder.parent / f"{self.project_name}.zip" 

468 

469 # //////////////////////////////////////////////// 

470 # RELEASE HELPER PROPERTIES 

471 # //////////////////////////////////////////////// 

472 

473 @property 

474 def resolved_repo_destination(self) -> str | None: 

475 """Resolved destination for the TUF tree.""" 

476 return self.repo_endpoint or None 

477 

478 @property 

479 def resolved_release_destination(self) -> str | None: 

480 """Resolved destination for the installer zip.""" 

481 return self.release_endpoint or None 

482 

483 # //////////////////////////////////////////////// 

484 # SERIALIZATION METHODS 

485 # //////////////////////////////////////////////// 

486 

487 def to_dict(self) -> dict[str, Any]: 

488 """ 

489 Convert configuration to dictionary. 

490 

491 Creates a comprehensive dictionary representation of the 

492 configuration with nested structures for compilation, upload, 

493 and advanced settings. Compiler-specific options (optimize, strip 

494 and free-form compiler_options) are emitted under the per-compiler 

495 section key matching the selected compiler. 

496 

497 Returns: 

498 dict[str, Any]: Configuration as nested dictionary 

499 

500 Example: 

501 >>> config = CompilerConfig(...) 

502 >>> config_dict = config.to_dict() 

503 >>> print(config_dict["version"]) 

504 '1.0.0' 

505 """ 

506 result: dict[str, Any] = { 

507 "version": self.version, 

508 "project_name": self.project_name, 

509 "project_description": self.project_description, 

510 "company_name": self.company_name, 

511 "author": self.author, 

512 "main_file": self.main_file, 

513 "icon": self.icon, 

514 "version_filename": self.version_filename, 

515 "output_folder": str(self.output_folder), 

516 "include_files": self.include_files, 

517 "packages": self.packages, 

518 "includes": self.includes, 

519 "excludes": self.excludes, 

520 "compilation": { 

521 "console": self.console, 

522 "compiler": self.compiler, 

523 }, 

524 "upload": { 

525 "repo_destination": self.repo_destination, 

526 "release_destination": self.release_destination, 

527 "repo_endpoint": self.repo_endpoint, 

528 "release_endpoint": self.release_endpoint, 

529 "repo_public_url": self.repo_public_url, 

530 }, 

531 "advanced": { 

532 "debug": self.debug, 

533 }, 

534 "release": { 

535 "tuf_enabled": self.tuf_enabled, 

536 "tuf_repo_dir": str(self.tuf_repo_dir) if self.tuf_repo_dir else None, 

537 "tuf_keys_dir": str(self.tuf_keys_dir) if self.tuf_keys_dir else None, 

538 "tuf_expiration_days": self.tuf_expiration_days, 

539 }, 

540 "installer": self.installer.to_dict(), 

541 } 

542 

543 # Emit compiler-specific options under the per-compiler section key. 

544 section_key = COMPILER_SECTION_KEYS.get(self.compiler) 

545 if section_key: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true

546 result[section_key] = { 

547 "optimize": self.optimize, 

548 "strip": self.strip, 

549 **self.compiler_options, 

550 } 

551 

552 return result 

553 

554 @classmethod 

555 def _flatten_sections(cls, config: dict[str, Any]) -> None: 

556 """ 

557 Flatten the nested config sections in place, strictly. 

558 

559 Two silent behaviours are removed here: a key accepted in a section it 

560 does not belong to, and a key defined in two sections where the order 

561 of the merges decided the winner (``[advanced] debug=false`` used to 

562 beat ``[compilation] debug=true`` without a word). 

563 

564 Args: 

565 config: Mutable config mapping; sections are popped and their keys 

566 promoted to the top level. 

567 

568 Raises: 

569 ConfigurationError: If a key sits in the wrong section, or is 

570 defined both at the top level and in a section, or in two 

571 sections at once. 

572 """ 

573 owner: dict[str, str] = { 

574 key: "racine" for key in config if key not in SECTION_SCHEMA 

575 } 

576 for section, allowed in SECTION_SCHEMA.items(): 

577 values = config.pop(section, {}) 

578 if not values: 

579 continue 

580 if not isinstance(values, dict): 580 ↛ 581line 580 didn't jump to line 581 because the condition on line 580 was never true

581 raise ConfigurationError( 

582 f"Section '{section}' must be a table, not a " 

583 f"{type(values).__name__}." 

584 ) 

585 for key, value in values.items(): 

586 expected = next( 

587 (s for s, keys in SECTION_SCHEMA.items() if key in keys), None 

588 ) 

589 if key in REMOVED_KEYS: 

590 raise ConfigurationError(REMOVED_KEYS[key]) 

591 if key not in allowed: 

592 hint = ( 

593 f" That key belongs to section '{expected}'." 

594 if expected 

595 else "" 

596 ) 

597 raise ConfigurationError( 

598 f"Invalid key '{key}' in section '{section}'.{hint}" 

599 ) 

600 if key in owner: 

601 raise ConfigurationError( 

602 f"Key '{key}' defined twice: in '{owner[key]}' and " 

603 f"in '{section}'. Declare it only once — the merge " 

604 "order must not decide which value wins." 

605 ) 

606 owner[key] = section 

607 config[key] = value 

608 

609 @classmethod 

610 def from_dict(cls, config_dict: dict[str, Any]) -> CompilerConfig: 

611 """ 

612 Create configuration from dictionary. 

613 

614 Flattens nested structures (compilation, upload, advanced) 

615 and creates a new CompilerConfig instance. Handles backward 

616 compatibility for 'version_file' key. 

617 

618 Args: 

619 config_dict: Configuration dictionary with nested structures 

620 

621 Returns: 

622 CompilerConfig: New configuration instance 

623 

624 Raises: 

625 ConfigurationError: If required fields are missing or invalid 

626 

627 Example: 

628 >>> config_dict = { 

629 ... "version": "1.0.0", 

630 ... "project_name": "MyApp", 

631 ... "main_file": "main.py", 

632 ... "include_files": {"files": [], "folders": []}, 

633 ... "output_folder": "dist" 

634 ... } 

635 >>> config = CompilerConfig.from_dict(config_dict) 

636 """ 

637 config_copy = config_dict.copy() 

638 

639 # Pop per-compiler sections before flattening; only the one matching 

640 # the selected compiler is applied (the others may coexist as 

641 # ready-to-use alternatives and are ignored). 

642 per_compiler_sections = { 

643 key: config_copy.pop(key) 

644 for key in COMPILER_SECTION_KEYS.values() 

645 if key in config_copy 

646 } 

647 

648 # 'compiler_options' (flat dict shared across compilers) has been 

649 # replaced by per-compiler sections. 

650 if "compiler_options" in config_copy: 

651 raise ConfigurationError( 

652 "'compiler_options' was removed. Use a per-compiler " 

653 "section: [tool.ezcompiler.pyinstaller], " 

654 "[tool.ezcompiler.cx_freeze] ou [tool.ezcompiler.nuitka]." 

655 ) 

656 

657 # 'optimize'/'strip' left 'advanced' for the per-compiler sections. 

658 advanced = config_copy.get("advanced", {}) 

659 if "optimize" in advanced or "strip" in advanced: 

660 raise ConfigurationError( 

661 "'advanced.optimize' / 'advanced.strip' moved to the compiler " 

662 "section ([tool.ezcompiler.<pyinstaller|cx_freeze|nuitka>])." 

663 ) 

664 

665 # Legacy flat installer keys (pre-4.0.0) — same pattern as the 

666 # compiler_options / advanced.optimize removals. 

667 raise_on_legacy_installer_keys(config_copy) 

668 

669 # Flatten the nested sections. Each key is checked against the schema 

670 # of its own section, and a key defined twice is an error instead of 

671 # being resolved silently by the order of the merges. 

672 cls._flatten_sections(config_copy) 

673 

674 installer_section = config_copy.pop("installer", {}) 

675 if isinstance(installer_section, InstallerConfig): 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true

676 config_copy["installer"] = installer_section 

677 else: 

678 config_copy["installer"] = InstallerConfig.from_dict(installer_section) 

679 

680 # Select the per-compiler section matching the resolved compiler. 

681 # optimize/strip are promoted to top-level fields; the remaining keys 

682 # become the free-form compiler_options passed to the adapter. 

683 section_key = COMPILER_SECTION_KEYS.get(config_copy.get("compiler", "")) 

684 selected_section = ( 

685 dict(per_compiler_sections.get(section_key, {})) if section_key else {} 

686 ) 

687 if "optimize" in selected_section: 

688 config_copy["optimize"] = selected_section.pop("optimize") 

689 if "strip" in selected_section: 

690 config_copy["strip"] = selected_section.pop("strip") 

691 config_copy["compiler_options"] = selected_section 

692 

693 # 'auto' was removed: no implicit default compiler any more. 

694 if config_copy.get("compiler") == "auto": 

695 raise ConfigurationError( 

696 "compiler='auto' was removed. State explicitly " 

697 "'Cx_Freeze', 'PyInstaller' or 'Nuitka', or leave the field " 

698 "empty to choose interactively at compile time." 

699 ) 

700 

701 # Migration errors for removed upload/release fields. 

702 for key, msg in REMOVED_KEYS.items(): 

703 if key in config_copy: 703 ↛ 704line 703 didn't jump to line 704 because the condition on line 703 was never true

704 raise ConfigurationError(msg) 

705 

706 # Handle backward compatibility 

707 if "version_file" in config_copy and "version_filename" not in config_copy: 

708 config_copy["version_filename"] = config_copy.pop("version_file") 

709 

710 # Reject unknown keys with a clear error 

711 import dataclasses as _dc 

712 

713 valid_fields = {f.name for f in _dc.fields(cls)} 

714 unknown = set(config_copy) - valid_fields 

715 if unknown: 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true

716 raise ConfigurationError( 

717 f"Unknown configuration key(s): {sorted(unknown)}. " 

718 "Check your config file for typos or removed keys." 

719 ) 

720 

721 return cls(**config_copy)