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
« 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# ///////////////////////////////////////////////////////////////
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
16import re
18# ///////////////////////////////////////////////////////////////
19# IMPORTS
20# ///////////////////////////////////////////////////////////////
21# Standard library imports
22from dataclasses import dataclass, field
23from pathlib import Path
24from typing import TYPE_CHECKING, Any
26# Local imports
27from ._constants import TUF_PUBLIC_DIRS
28from ._installer_config import InstallerConfig, raise_on_legacy_installer_keys
29from .exceptions import ConfigurationError
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
34# ///////////////////////////////////////////////////////////////
35# CONSTANTS
36# ///////////////////////////////////////////////////////////////
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}
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}
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}
89_OWNER_REPO_RE = re.compile(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+")
91# ///////////////////////////////////////////////////////////////
92# CLASSES
93# ///////////////////////////////////////////////////////////////
96@dataclass
97class CompilerConfig:
98 """
99 Configuration class for project compilation.
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.
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
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 """
150 # ////////////////////////////////////////////////
151 # REQUIRED FIELDS
152 # ////////////////////////////////////////////////
154 version: str
155 project_name: str
156 main_file: str
157 include_files: dict[str, list[str]]
158 output_folder: Path
160 # ////////////////////////////////////////////////
161 # OPTIONAL FIELDS WITH DEFAULTS
162 # ////////////////////////////////////////////////
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)
173 # ////////////////////////////////////////////////
174 # COMPILATION OPTIONS
175 # ////////////////////////////////////////////////
177 console: bool = True
178 compiler: str = "" # "" (unset -> prompt), "Cx_Freeze", "PyInstaller", "Nuitka"
180 # ////////////////////////////////////////////////
181 # UPLOAD OPTIONS
182 # ////////////////////////////////////////////////
184 repo_destination: RepoDestination = "disk"
185 release_destination: ReleaseDestination = "disk"
186 repo_endpoint: str = ""
187 release_endpoint: str = ""
188 repo_public_url: str = ""
190 # ////////////////////////////////////////////////
191 # ADVANCED OPTIONS
192 # ////////////////////////////////////////////////
194 optimize: bool = True
195 strip: bool = False
196 debug: bool = False
198 # ////////////////////////////////////////////////
199 # SECURE RELEASE OPTIONS (tufup)
200 # ////////////////////////////////////////////////
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
211 # ////////////////////////////////////////////////
212 # INSTALLER OPTIONS (Inno Setup)
213 # ////////////////////////////////////////////////
215 installer: InstallerConfig = field(default_factory=InstallerConfig)
217 # ////////////////////////////////////////////////
218 # COMPILER-SPECIFIC OPTIONS
219 # ////////////////////////////////////////////////
221 compiler_options: dict[str, Any] = field(default_factory=dict)
223 # ////////////////////////////////////////////////
224 # INITIALIZATION AND VALIDATION
225 # ////////////////////////////////////////////////
227 def __post_init__(self) -> None:
228 """
229 Validate configuration after initialization.
231 Called automatically after __init__ to validate all fields
232 and ensure configuration is valid before use.
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()
244 def _validate_required_fields(self) -> None:
245 """
246 Validate required fields are not empty.
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")
258 def _validate_include_files(self) -> None:
259 """
260 Validate and normalize include_files payload.
262 Expected format:
263 {"files": ["..."], "folders": ["..."]}
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")
271 files = self.include_files.get("files", [])
272 folders = self.include_files.get("folders", [])
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")
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 )
288 # Normalize to canonical shape even when keys are missing.
289 self.include_files = {
290 "files": files,
291 "folders": folders,
292 }
294 def _validate_paths(self) -> None:
295 """
296 Validate file and folder paths.
298 Ensures main file exists and output folder is accessible.
299 Converts output_folder and tuf dirs to Path if they are strings.
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}")
307 if isinstance(self.output_folder, str):
308 self.output_folder = Path(self.output_folder)
310 if isinstance(self.tuf_repo_dir, str):
311 self.tuf_repo_dir = Path(self.tuf_repo_dir)
313 if isinstance(self.tuf_keys_dir, str):
314 self.tuf_keys_dir = Path(self.tuf_keys_dir)
316 self._validate_tuf_keys_location()
318 def _validate_tuf_keys_location(self) -> None:
319 """
320 Refuse a private keystore placed inside the published part of the tree.
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.
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
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 )
346 def _validate_compiler_option(self) -> None:
347 """
348 Validate compiler option.
350 Ensures compiler is one of the supported options. An empty string
351 means "unset" (resolved interactively at compile time) and is allowed.
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 )
362 def _validate_destinations(self) -> None:
363 """
364 Validate upload and publication destinations and their endpoints.
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.
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 )
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 )
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 )
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 )
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 )
422 def _validate_installer_icon(self) -> None:
423 """
424 Validate the icon format when the installer stage is enabled.
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.
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 )
442 # ////////////////////////////////////////////////
443 # PATH HELPER PROPERTIES
444 # ////////////////////////////////////////////////
446 @property
447 def version_file(self) -> Path:
448 """
449 Get the full path to the version file.
451 Returns:
452 Path: Full path to version_info.txt in output folder
453 """
454 return self.output_folder / self.version_filename
456 @property
457 def zip_file_path(self) -> Path:
458 """
459 Get the path to the zip file.
461 Uses the project name as the zip filename, placed next to the
462 output folder (e.g., dist/MyApp.zip).
464 Returns:
465 Path: Path to the zip archive file
466 """
467 return self.output_folder.parent / f"{self.project_name}.zip"
469 # ////////////////////////////////////////////////
470 # RELEASE HELPER PROPERTIES
471 # ////////////////////////////////////////////////
473 @property
474 def resolved_repo_destination(self) -> str | None:
475 """Resolved destination for the TUF tree."""
476 return self.repo_endpoint or None
478 @property
479 def resolved_release_destination(self) -> str | None:
480 """Resolved destination for the installer zip."""
481 return self.release_endpoint or None
483 # ////////////////////////////////////////////////
484 # SERIALIZATION METHODS
485 # ////////////////////////////////////////////////
487 def to_dict(self) -> dict[str, Any]:
488 """
489 Convert configuration to dictionary.
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.
497 Returns:
498 dict[str, Any]: Configuration as nested dictionary
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 }
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 }
552 return result
554 @classmethod
555 def _flatten_sections(cls, config: dict[str, Any]) -> None:
556 """
557 Flatten the nested config sections in place, strictly.
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).
564 Args:
565 config: Mutable config mapping; sections are popped and their keys
566 promoted to the top level.
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
609 @classmethod
610 def from_dict(cls, config_dict: dict[str, Any]) -> CompilerConfig:
611 """
612 Create configuration from dictionary.
614 Flattens nested structures (compilation, upload, advanced)
615 and creates a new CompilerConfig instance. Handles backward
616 compatibility for 'version_file' key.
618 Args:
619 config_dict: Configuration dictionary with nested structures
621 Returns:
622 CompilerConfig: New configuration instance
624 Raises:
625 ConfigurationError: If required fields are missing or invalid
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()
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 }
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 )
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 )
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)
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)
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)
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
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 )
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)
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")
710 # Reject unknown keys with a clear error
711 import dataclasses as _dc
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 )
721 return cls(**config_copy)