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

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 

16# /////////////////////////////////////////////////////////////// 

17# IMPORTS 

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

19# Standard library imports 

20from dataclasses import dataclass, field 

21from pathlib import Path 

22from typing import TYPE_CHECKING, Any 

23 

24# Local imports 

25from .exceptions import ConfigurationError 

26 

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 

29 

30# /////////////////////////////////////////////////////////////// 

31# CONSTANTS 

32# /////////////////////////////////////////////////////////////// 

33 

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} 

42 

43# /////////////////////////////////////////////////////////////// 

44# CLASSES 

45# /////////////////////////////////////////////////////////////// 

46 

47 

48@dataclass 

49class CompilerConfig: 

50 """ 

51 Configuration class for project compilation. 

52 

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. 

57 

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 

89 

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 """ 

100 

101 # //////////////////////////////////////////////// 

102 # REQUIRED FIELDS 

103 # //////////////////////////////////////////////// 

104 

105 version: str 

106 project_name: str 

107 main_file: str 

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

109 output_folder: Path 

110 

111 # //////////////////////////////////////////////// 

112 # OPTIONAL FIELDS WITH DEFAULTS 

113 # //////////////////////////////////////////////// 

114 

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) 

123 

124 # //////////////////////////////////////////////// 

125 # COMPILATION OPTIONS 

126 # //////////////////////////////////////////////// 

127 

128 console: bool = True 

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

130 

131 # //////////////////////////////////////////////// 

132 # UPLOAD OPTIONS 

133 # //////////////////////////////////////////////// 

134 

135 repo_destination: RepoDestination = "disk" 

136 release_destination: ReleaseDestination = "disk" 

137 repo_endpoint: str = "" 

138 release_endpoint: str = "" 

139 repo_public_url: str = "" 

140 

141 # //////////////////////////////////////////////// 

142 # ADVANCED OPTIONS 

143 # //////////////////////////////////////////////// 

144 

145 optimize: bool = True 

146 strip: bool = False 

147 debug: bool = False 

148 

149 # //////////////////////////////////////////////// 

150 # SECURE RELEASE OPTIONS (tufup) 

151 # //////////////////////////////////////////////// 

152 

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 

161 

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

163 # INSTALLER OPTIONS (Inno Setup) 

164 # //////////////////////////////////////////////// 

165 

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 

173 

174 # //////////////////////////////////////////////// 

175 # COMPILER-SPECIFIC OPTIONS 

176 # //////////////////////////////////////////////// 

177 

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

179 

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

181 # INITIALIZATION AND VALIDATION 

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

183 

184 def __post_init__(self) -> None: 

185 """ 

186 Validate configuration after initialization. 

187 

188 Called automatically after __init__ to validate all fields 

189 and ensure configuration is valid before use. 

190 

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() 

200 

201 def _validate_required_fields(self) -> None: 

202 """ 

203 Validate required fields are not empty. 

204 

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") 

214 

215 def _validate_include_files(self) -> None: 

216 """ 

217 Validate and normalize include_files payload. 

218 

219 Expected format: 

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

221 

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") 

227 

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

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

230 

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") 

235 

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 ) 

244 

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

246 self.include_files = { 

247 "files": files, 

248 "folders": folders, 

249 } 

250 

251 def _validate_paths(self) -> None: 

252 """ 

253 Validate file and folder paths. 

254 

255 Ensures main file exists and output folder is accessible. 

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

257 

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}") 

263 

264 if isinstance(self.output_folder, str): 

265 self.output_folder = Path(self.output_folder) 

266 

267 if isinstance(self.tuf_repo_dir, str): 

268 self.tuf_repo_dir = Path(self.tuf_repo_dir) 

269 

270 if isinstance(self.tuf_keys_dir, str): 

271 self.tuf_keys_dir = Path(self.tuf_keys_dir) 

272 

273 if isinstance(self.installer_output_dir, str): 

274 self.installer_output_dir = Path(self.installer_output_dir) 

275 

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) 

278 

279 def _validate_compiler_option(self) -> None: 

280 """ 

281 Validate compiler option. 

282 

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

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

285 

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 ) 

294 

295 def _validate_destinations(self) -> None: 

296 """ 

297 Validate upload destination backends and require endpoints for non-disk targets. 

298 

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. 

302 

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 ) 

312 

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 ) 

319 

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 ) 

325 

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 ) 

331 

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 ) 

343 

344 def _validate_installer_option(self) -> None: 

345 """ 

346 Validate installer configuration. 

347 

348 Ensures installer_iss_path, when provided, points to an existing file. 

349 

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 ) 

360 

361 # //////////////////////////////////////////////// 

362 # PATH HELPER PROPERTIES 

363 # //////////////////////////////////////////////// 

364 

365 @property 

366 def version_file(self) -> Path: 

367 """ 

368 Get the full path to the version file. 

369 

370 Returns: 

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

372 """ 

373 return self.output_folder / self.version_filename 

374 

375 @property 

376 def zip_file_path(self) -> Path: 

377 """ 

378 Get the path to the zip file. 

379 

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

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

382 

383 Returns: 

384 Path: Path to the zip archive file 

385 """ 

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

387 

388 # //////////////////////////////////////////////// 

389 # RELEASE HELPER PROPERTIES 

390 # //////////////////////////////////////////////// 

391 

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 

396 

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 

401 

402 # //////////////////////////////////////////////// 

403 # SERIALIZATION METHODS 

404 # //////////////////////////////////////////////// 

405 

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

407 """ 

408 Convert configuration to dictionary. 

409 

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. 

415 

416 Returns: 

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

418 

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 } 

472 

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 } 

481 

482 return result 

483 

484 @classmethod 

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

486 """ 

487 Create configuration from dictionary. 

488 

489 Flattens nested structures (compilation, upload, advanced) 

490 and creates a new CompilerConfig instance. Handles backward 

491 compatibility for 'version_file' key. 

492 

493 Args: 

494 config_dict: Configuration dictionary with nested structures 

495 

496 Returns: 

497 CompilerConfig: New configuration instance 

498 

499 Raises: 

500 ConfigurationError: If required fields are missing or invalid 

501 

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() 

513 

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 } 

522 

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 ) 

531 

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 ) 

539 

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", {}) 

545 

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) 

551 

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) 

558 

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 

571 

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 ) 

579 

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 ) 

586 

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 ) 

593 

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) 

605 

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) 

615 

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") 

619 

620 # Reject unknown keys with a clear error 

621 import dataclasses as _dc 

622 

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 ) 

630 

631 return cls(**config_copy)