Coverage for src/ezcompiler/interfaces/python_api.py: 60.70%

237 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-04 11:22 +0000

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

2# PYTHON_API - Python API interface for EzCompiler 

3# Project: ezcompiler 

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

5 

6""" 

7Python API interface - High-level Python API for EzCompiler. 

8 

9This module provides the EzCompiler class that orchestrates project compilation, 

10version generation, setup file creation, artifact zipping, and repository upload 

11using the service layer. 

12 

13Interfaces layer can use all log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL). 

14""" 

15 

16from __future__ import annotations 

17 

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

19# IMPORTS 

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

21# Standard library imports 

22from collections.abc import Callable 

23from pathlib import Path 

24from typing import TYPE_CHECKING, Any, Literal, cast 

25 

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

27 import logging 

28 

29 from ezplog.handlers.wizard.dynamic import StageConfig 

30 from ezplog.lib_mode import _LazyPrinter 

31 

32 from .._types import ReleaseDestination, RepoDestination 

33 

34# Third-party imports 

35from ezplog.lib_mode import get_logger, get_printer 

36 

37# Local imports 

38from ..services import ( 

39 CompilerService, 

40 PipelineService, 

41 ReleaseService, 

42 TemplateService, 

43 UpdaterService, 

44 UploaderService, 

45) 

46from ..shared import CompilationResult, CompilerConfig 

47from ..shared.exceptions import ( 

48 CompilationError, 

49 ConfigurationError, 

50 InstallerError, 

51 ReleaseError, 

52 SigningKeyError, 

53 TemplateError, 

54 UploadError, 

55 VersionError, 

56 ZipError, 

57) 

58 

59# /////////////////////////////////////////////////////////////// 

60# CONSTANTS 

61# /////////////////////////////////////////////////////////////// 

62 

63_MSG_NOT_INITIALIZED = "Project not initialized. Call init_project() first." 

64_MSG_VERSION_OK = "Version file generated successfully" 

65_MSG_COMPILED_OK = "Project compiled successfully" 

66_MSG_ZIP_OK = "ZIP archive created successfully" 

67 

68# /////////////////////////////////////////////////////////////// 

69# CLASSES 

70# /////////////////////////////////////////////////////////////// 

71 

72 

73class EzCompiler: 

74 """ 

75 Main orchestration class for project compilation and distribution. 

76 

77 Coordinates project compilation using modular compilers, version file 

78 generation, setup file creation, artifact zipping, and repository upload. 

79 Provides high-level API for managing the full build pipeline. 

80 

81 Attributes: 

82 _config: CompilerConfig instance with project settings (read via .config property) 

83 printer: Lazy printer proxy — silent until host app initializes Ezpl 

84 logger: Stdlib logger — silent until host app configures logging 

85 

86 Example: 

87 >>> config = CompilerConfig(...) 

88 >>> compiler = EzCompiler(config) 

89 >>> compiler.compile_project() 

90 >>> compiler.zip_compiled_project() 

91 >>> compiler.upload() 

92 """ 

93 

94 # //////////////////////////////////////////////// 

95 # INITIALIZATION 

96 # //////////////////////////////////////////////// 

97 

98 def __init__( 

99 self, 

100 config: CompilerConfig | None = None, 

101 compiler_service_factory: ( 

102 Callable[[CompilerConfig], CompilerService] | None 

103 ) = None, 

104 template_service: TemplateService | None = None, 

105 uploader_service: UploaderService | None = None, 

106 pipeline_service: PipelineService | None = None, 

107 ) -> None: 

108 """ 

109 Initialize the EzCompiler orchestrator. 

110 

111 Logging follows the lib_mode pattern: both the printer and logger are 

112 passive proxies that produce no output until the host application 

113 initializes Ezpl. No logging configuration happens here — that is an 

114 application-level concern. 

115 

116 Args: 

117 config: Optional CompilerConfig instance (can be set later via init_project) 

118 compiler_service_factory: Optional factory for CompilerService (for testing) 

119 template_service: Optional TemplateService instance (for testing) 

120 uploader_service: Optional UploaderService instance (for testing) 

121 pipeline_service: Optional PipelineService instance (for testing) 

122 """ 

123 # Configuration management 

124 self._config = config 

125 

126 # Passive lib-mode logging — silent until host app initializes Ezpl 

127 self._printer: _LazyPrinter = get_printer() 

128 self._logger: logging.Logger = get_logger(__name__) 

129 

130 # Service instances 

131 self._compiler_service_factory = compiler_service_factory or CompilerService 

132 self._compiler_service: CompilerService | None = None 

133 self._template_service = template_service or TemplateService() 

134 self._uploader_service = uploader_service or UploaderService() 

135 self._pipeline_service = pipeline_service or PipelineService( 

136 compiler_service_factory=self._compiler_service_factory 

137 ) 

138 

139 # Compilation state 

140 self._compilation_result: CompilationResult | None = None 

141 

142 # //////////////////////////////////////////////// 

143 # LOGGING ACCESSOR PROPERTIES 

144 # //////////////////////////////////////////////// 

145 

146 @property 

147 def printer(self) -> _LazyPrinter: 

148 """ 

149 Get the console printer proxy. 

150 

151 Returns: 

152 _LazyPrinter: Lazy printer — silent until host app initializes Ezpl 

153 """ 

154 return self._printer 

155 

156 @property 

157 def logger(self) -> logging.Logger: 

158 """ 

159 Get the stdlib logger. 

160 

161 Returns: 

162 logging.Logger: Stdlib logger — silent until host app configures logging 

163 """ 

164 return self._logger 

165 

166 @property 

167 def config(self) -> CompilerConfig | None: 

168 """ 

169 Get the current compiler configuration. 

170 

171 Returns: 

172 CompilerConfig | None: Current configuration or None if not initialized 

173 """ 

174 return self._config 

175 

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

177 # PROJECT INITIALIZATION 

178 # //////////////////////////////////////////////// 

179 

180 def init_project( 

181 self, 

182 version: str, 

183 project_name: str, 

184 main_file: str, 

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

186 output_folder: Path | str, 

187 **kwargs: Any, 

188 ) -> None: 

189 """ 

190 Initialize project configuration. 

191 

192 Creates a CompilerConfig from provided parameters. This is a 

193 convenience method for backward compatibility; can also set 

194 config directly. 

195 

196 Args: 

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

198 project_name: Project name 

199 main_file: Path to main Python file 

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

201 output_folder: Output directory path 

202 **kwargs: Additional config options 

203 

204 Raises: 

205 ConfigurationError: If configuration is invalid 

206 

207 Example: 

208 >>> compiler = EzCompiler() 

209 >>> compiler.init_project( 

210 ... version="1.0.0", 

211 ... project_name="MyApp", 

212 ... main_file="main.py", 

213 ... include_files={"files": [], "folders": []}, 

214 ... output_folder="dist" 

215 ... ) 

216 """ 

217 try: 

218 # Create configuration from parameters 

219 config_dict: dict[str, Any] = { 

220 "version": version, 

221 "project_name": project_name, 

222 "main_file": main_file, 

223 "include_files": include_files, 

224 "output_folder": str(output_folder), 

225 **kwargs, 

226 } 

227 

228 # Update configuration 

229 self._config = CompilerConfig(**config_dict) 

230 

231 self._printer.success("Project configuration initialized successfully") 

232 self._logger.info("Project configuration initialized successfully") 

233 

234 except ConfigurationError: 

235 raise 

236 except Exception as e: 

237 self._printer.error(f"Failed to initialize project: {e}") 

238 self._logger.error(f"Failed to initialize project: {e}") 

239 raise ConfigurationError(f"Failed to initialize project: {e}") from e 

240 

241 # //////////////////////////////////////////////// 

242 # VERSION AND SETUP GENERATION 

243 # //////////////////////////////////////////////// 

244 

245 def generate_version_file(self, name: str = "version_info.txt") -> None: 

246 """ 

247 Generate version information file. 

248 

249 Uses the configured version information to generate a version file 

250 at the specified path. Legacy method for backward compatibility. 

251 

252 Args: 

253 name: Version file name (default: "version_info.txt") 

254 

255 Raises: 

256 ConfigurationError: If project not initialized 

257 

258 Note: 

259 Requires project to be initialized first via init_project(). 

260 """ 

261 try: 

262 if not self._config: 

263 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

264 

265 # Generate using TemplateService 

266 config_dict = self._config.to_dict() 

267 version_file_path = Path(name) 

268 self._template_service.generate_version_file(config_dict, version_file_path) 

269 

270 self._printer.success(_MSG_VERSION_OK) 

271 self._logger.info(_MSG_VERSION_OK) 

272 

273 except (ConfigurationError, VersionError, TemplateError): 

274 raise 

275 except Exception as e: 

276 self._printer.error(f"Failed to generate version file: {e}") 

277 self._logger.error(f"Failed to generate version file: {e}") 

278 raise VersionError(f"Failed to generate version file: {e}") from e 

279 

280 def generate_setup_file(self, file_path: Path | str) -> None: 

281 """ 

282 Generate setup.py file from template. 

283 

284 Creates a setup.py file using the template system. Legacy method 

285 for backward compatibility. 

286 

287 Args: 

288 file_path: Path where to create the setup.py file 

289 

290 Raises: 

291 ConfigurationError: If project not initialized 

292 

293 Note: 

294 Requires project to be initialized first via init_project(). 

295 """ 

296 try: 

297 if not self._config: 

298 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

299 

300 # Generate using TemplateService 

301 config_dict = self._config.to_dict() 

302 output_path = Path(file_path) 

303 self._template_service.generate_setup_file( 

304 config_dict, output_path=output_path 

305 ) 

306 

307 self._printer.success("Setup file generated successfully") 

308 self._logger.info("Setup file generated successfully") 

309 

310 except (ConfigurationError, TemplateError): 

311 raise 

312 except Exception as e: 

313 self._printer.error(f"Failed to generate setup file: {e}") 

314 self._logger.error(f"Failed to generate setup file: {e}") 

315 raise TemplateError(f"Failed to generate setup file: {e}") from e 

316 

317 # //////////////////////////////////////////////// 

318 # COMPILATION METHODS 

319 # //////////////////////////////////////////////// 

320 

321 def compile_project( 

322 self, console: bool = True, compiler: str | None = None 

323 ) -> None: 

324 """ 

325 Compile the project using specified or auto-selected compiler. 

326 

327 Validates configuration, selects compiler if not specified, and 

328 executes compilation. Sets _zip_needed based on compiler output type. 

329 

330 Args: 

331 console: Whether to show console window (default: True) 

332 compiler: Compiler to use or None for auto-selection 

333 - "Cx_Freeze": Creates directory with dependencies 

334 - "PyInstaller": Creates single executable 

335 - "Nuitka": Creates standalone folder or single executable 

336 - None: Prompt user for choice or use config default 

337 

338 Raises: 

339 ConfigurationError: If project not initialized 

340 CompilationError: If compilation fails 

341 

342 Example: 

343 >>> compiler.compile_project(console=False, compiler="PyInstaller") 

344 """ 

345 try: 

346 if not self._config: 

347 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

348 

349 # Create compiler service and compile 

350 self._compiler_service = self._compiler_service_factory(self._config) 

351 self._compilation_result = self._compiler_service.compile( 

352 console=console, 

353 compiler=cast( 

354 Literal["Cx_Freeze", "PyInstaller", "Nuitka"] | None, 

355 compiler, 

356 ), 

357 ) 

358 

359 self._printer.success(_MSG_COMPILED_OK) 

360 self._logger.info(_MSG_COMPILED_OK) 

361 

362 except (ConfigurationError, CompilationError): 

363 raise 

364 except Exception as e: 

365 self._printer.error(f"Compilation failed: {e}") 

366 self._logger.error(f"Compilation failed: {e}") 

367 raise CompilationError(f"Compilation failed: {e}") from e 

368 

369 def zip_compiled_project(self) -> None: 

370 """ 

371 Create ZIP archive of compiled project. 

372 

373 Archives the compiled output if needed. Cx_Freeze output is 

374 zipped; PyInstaller single-file output is not. 

375 

376 Raises: 

377 ConfigurationError: If project not initialized 

378 

379 Note: 

380 ZIP creation is optional based on compiler type and settings. 

381 """ 

382 try: 

383 if not self._config: 

384 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

385 

386 # Check if ZIP is needed from compilation result 

387 zip_needed = ( 

388 self._compilation_result.zip_needed 

389 if self._compilation_result 

390 else True 

391 ) 

392 

393 if not zip_needed: 

394 self._printer.info("ZIP not needed for this compilation type") 

395 return 

396 

397 # Create ZIP archive via CompilerService 

398 if self._compiler_service is None: 

399 self._compiler_service = self._compiler_service_factory(self._config) 

400 

401 self._pipeline_service.zip_artifact( 

402 config=self._config, 

403 compiler_service=self._compiler_service, 

404 compilation_result=self._compilation_result, 

405 progress_callback=self._zip_progress_callback, 

406 ) 

407 

408 self._printer.success(_MSG_ZIP_OK) 

409 self._logger.info(_MSG_ZIP_OK) 

410 

411 except (ConfigurationError, ZipError): 

412 raise 

413 except Exception as e: 

414 self._printer.error(f"Failed to create ZIP archive: {e}") 

415 self._logger.error(f"Failed to create ZIP archive: {e}") 

416 raise ZipError(f"Failed to create ZIP archive: {e}") from e 

417 

418 # //////////////////////////////////////////////// 

419 # UPLOAD METHODS 

420 # //////////////////////////////////////////////// 

421 

422 def upload( 

423 self, 

424 destination: str | None = None, 

425 repo_destination: RepoDestination | None = None, 

426 release_destination: ReleaseDestination | None = None, 

427 upload_config: dict[str, Any] | None = None, 

428 ) -> None: 

429 """Upload le repo TUF et/ou le zip installeur selon la config. 

430 

431 Quand ``release_needed`` est True, effectue deux uploads séquentiels : 

432 1. arbre TUF → ``<dest>/update/`` 

433 2. zip installeur → ``<dest>/release/`` (ignoré si repo_destination="r2") 

434 

435 Sinon, uploade l'artefact compilé (comportement inchangé). 

436 

437 Args: 

438 destination: Override commun pour les deux destinations. 

439 repo_destination: Override de ``config.repo_destination``. 

440 release_destination: Override de ``config.release_destination``. 

441 upload_config: Options supplémentaires passées aux uploaders. 

442 

443 Raises: 

444 ConfigurationError: Si le projet n'est pas initialisé. 

445 UploadError: Si un upload échoue. 

446 """ 

447 if not self._config: 

448 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

449 

450 repo_dest = repo_destination or self._config.repo_destination 

451 

452 try: 

453 if self._config.tuf_enabled: 

454 repo_dir = self._config.tuf_repo_dir or ( 

455 self._config.output_folder / "repo" 

456 ) 

457 rel_dest = release_destination or self._config.release_destination 

458 release_root = ( 

459 None 

460 if repo_dest == "r2" and rel_dest == "disk" 

461 else self._pipeline_service.assemble_release_dir(self._config) 

462 ) 

463 UploaderService.upload_release( 

464 config=self._config, 

465 repo_dir=repo_dir, 

466 release_root=release_root, 

467 destination=destination, 

468 repo_destination=repo_destination, 

469 release_destination=release_destination, 

470 upload_config=upload_config, 

471 ) 

472 

473 else: 

474 dest = destination or self._config.resolved_repo_destination or "" 

475 self._pipeline_service.upload_artifact( 

476 config=self._config, 

477 structure=repo_dest, 

478 destination=str(dest), 

479 compilation_result=self._compilation_result, 

480 upload_config=upload_config, 

481 ) 

482 

483 self._printer.success(f"Upload completed ({repo_dest})") 

484 self._logger.info(f"Upload completed ({repo_dest})") 

485 

486 except (ConfigurationError, UploadError, ReleaseError): 

487 raise 

488 except Exception as e: 

489 self._printer.error(f"Upload failed: {e}") 

490 self._logger.error(f"Upload failed: {e}") 

491 raise UploadError(f"Upload failed: {e}") from e 

492 

493 def release( 

494 self, 

495 bundle_dir: Path, 

496 *, 

497 publish: bool = False, 

498 ) -> Path: 

499 """Package a compiled bundle into a signed TUF repository. 

500 

501 Reads app name, version and tufup directories from the config. 

502 When ``publish`` is True, the repository tree is transferred via the 

503 configured uploader (``update_repo_url`` + repo_destination). 

504 

505 Args: 

506 bundle_dir: Directory containing the compiled application artifacts. 

507 publish: When True, upload the repository/ tree to ``update_repo_url``. 

508 

509 Returns: 

510 Path: The local ``repository/`` tree produced by tufup. 

511 

512 Raises: 

513 ConfigurationError: If project not initialized. 

514 ReleaseError: If release packaging or remote publishing fails. 

515 """ 

516 if not self._config: 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true

517 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

518 if publish: 

519 import warnings # noqa: PLC0415 

520 

521 warnings.warn( 

522 "release(publish=True) est déprécié : le transfert distant est " 

523 "désormais assuré par le stage upload de run_pipeline.", 

524 DeprecationWarning, 

525 stacklevel=2, 

526 ) 

527 repo_dir = self._config.tuf_repo_dir or (self._config.output_folder / "repo") 

528 keys_dir = self._config.tuf_keys_dir or (repo_dir / "keystore") 

529 return ReleaseService.release_and_publish( 

530 bundle_dir=bundle_dir, 

531 app_name=self._config.project_name, 

532 version=self._config.version, 

533 repo_dir=repo_dir, 

534 publish=publish, 

535 upload_type=self._config.repo_destination if publish else None, 

536 destination=self._config.repo_endpoint if publish else None, 

537 releaser_config={ 

538 "keys_dir": keys_dir, 

539 "expiration_days": self._config.tuf_expiration_days, 

540 }, 

541 ) 

542 

543 def init_release(self) -> bool: 

544 """Initialise les clés/repo TUF depuis la config courante. 

545 

546 Action explicite — jamais appelée par run_pipeline(). 

547 

548 Returns: 

549 bool: True si init effectuée, False si clés déjà présentes (skip). 

550 

551 Raises: 

552 ConfigurationError: If project not initialized. 

553 ReleaseError: If TUF initialization fails. 

554 """ 

555 if not self._config: 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true

556 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

557 repo_dir = self._config.tuf_repo_dir or (self._config.output_folder / "repo") 

558 keys_dir = self._config.tuf_keys_dir or (repo_dir / "keystore") 

559 return ReleaseService.init_release( 

560 app_name=self._config.project_name, 

561 repo_dir=repo_dir, 

562 keys_dir=keys_dir, 

563 releaser_config={ 

564 "keys_dir": keys_dir, 

565 "expiration_days": self._config.tuf_expiration_days, 

566 }, 

567 ) 

568 

569 def refresh_release_expiration( 

570 self, 

571 *, 

572 roles: tuple[str, ...] = ("targets", "snapshot", "timestamp"), 

573 days: int | None = None, 

574 ) -> Path: 

575 """Re-sign TUF metadata to extend expiration without a new release. 

576 

577 Native tufup keep-alive for projects updated irregularly: bumps the 

578 expiration date of the short-lived roles and re-publishes, so clients 

579 keep trusting the repository between releases. 

580 

581 Args: 

582 roles: Role names to refresh (default: targets/snapshot/timestamp). 

583 days: Days from now. None → the config's ``tuf_expiration_days`` 

584 (or tufup defaults) for each role. 

585 

586 Returns: 

587 Path: The local TUF repository directory. 

588 

589 Raises: 

590 ConfigurationError: If project not initialized. 

591 ReleaseError: If the refresh fails. 

592 SigningKeyError: If signing keys are missing. 

593 """ 

594 if not self._config: 594 ↛ 595line 594 didn't jump to line 595 because the condition on line 594 was never true

595 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

596 repo_dir = self._config.tuf_repo_dir or (self._config.output_folder / "repo") 

597 keys_dir = self._config.tuf_keys_dir or (repo_dir / "keystore") 

598 repo = ReleaseService.refresh_expiration( 

599 app_name=self._config.project_name, 

600 repo_dir=repo_dir, 

601 keys_dir=keys_dir, 

602 roles=roles, 

603 days=days, 

604 releaser_config={ 

605 "keys_dir": keys_dir, 

606 "expiration_days": self._config.tuf_expiration_days, 

607 }, 

608 ) 

609 self._printer.success("TUF metadata expiration refreshed") 

610 self._logger.info("TUF metadata expiration refreshed: %s", repo) 

611 return repo 

612 

613 def generate_updater( 

614 self, 

615 output_dir: Path | str | None = None, 

616 *, 

617 patch_config: bool = True, 

618 ) -> list[Path]: 

619 """Generate auto-update client files for embedding in the compiled bundle. 

620 

621 Generates ``update.py``, ``settings.py``, and copies ``root.json`` 

622 from the local TUF repository into ``output_dir``. 

623 

624 When ``patch_config=True`` (default), the three generated files are 

625 added to ``config.include_files["files"]`` so they are automatically 

626 bundled by the next ``run_pipeline()`` call. Call this method BEFORE 

627 ``run_pipeline()``. 

628 

629 Args: 

630 output_dir: Directory where files are written. Defaults to the 

631 directory containing ``main_file``. 

632 patch_config: If True, add generated file paths to include_files. 

633 

634 Returns: 

635 List of generated file paths [settings.py, update.py, root.json]. 

636 

637 Raises: 

638 ConfigurationError: If project not initialized. 

639 UpdaterConfigError: If tuf_enabled=False or root.json absent. 

640 UpdaterGenerationError: If writing files fails. 

641 """ 

642 if not self._config: 

643 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

644 

645 resolved_dir = ( 

646 Path(output_dir) 

647 if output_dir is not None 

648 else Path(self._config.main_file).parent 

649 ) 

650 

651 files = UpdaterService.generate(self._config, resolved_dir) 

652 

653 if patch_config: 

654 self._config.include_files["files"].extend(str(f) for f in files) 

655 

656 self._printer.success(f"Updater files generated in {resolved_dir}") 

657 self._logger.info("Updater files generated: %s", [str(f) for f in files]) 

658 

659 return files 

660 

661 def run_pipeline( 

662 self, 

663 console: bool = True, 

664 compiler: str | None = None, 

665 skip_zip: bool = False, 

666 skip_release: bool = False, 

667 skip_installer: bool = False, 

668 ) -> None: 

669 """ 

670 Run the build pipeline with visual progress tracking. 

671 

672 Executes version generation, compilation, optional ZIP creation, 

673 optional installer build and optional TUF release in sequence with a 

674 DynamicLayeredProgress display. Upload is no longer part of the 

675 pipeline — call ``upload()`` explicitly afterwards. 

676 

677 Args: 

678 console: Whether to show console window (default: True) 

679 compiler: Compiler to use or None for auto-selection 

680 skip_zip: Skip ZIP archive creation 

681 skip_release: Skip the TUF release stage 

682 skip_installer: Skip the installer build stage 

683 

684 Raises: 

685 ConfigurationError: If project not initialized 

686 CompilationError: If compilation fails 

687 VersionError: If version file generation fails 

688 ZipError: If ZIP creation fails 

689 ReleaseError: If the release stage fails 

690 InstallerError: If the installer build fails 

691 

692 Example: 

693 >>> compiler = EzCompiler(config) 

694 >>> compiler.run_pipeline(console=False) 

695 >>> compiler.upload() 

696 """ 

697 if not self._config: 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true

698 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

699 

700 # Determine which optional stages to include 

701 should_zip = not skip_zip 

702 should_release = not skip_release and self._config.tuf_enabled 

703 should_installer = not skip_installer and self._config.installer_enabled 

704 

705 # Pre-flight: fail early if release needed but keys absent 

706 if should_release: 

707 repo_dir = self._config.tuf_repo_dir or ( 

708 self._config.output_folder / "repo" 

709 ) 

710 keys_dir = self._config.tuf_keys_dir or (repo_dir / "keystore") 

711 self._preflight_release(keys_dir) 

712 

713 # Build stages 

714 stages: list[StageConfig] = cast( 

715 list["StageConfig"], 

716 PipelineService.build_stages( 

717 self._config, 

718 should_zip=should_zip, 

719 should_release=should_release, 

720 should_installer=should_installer, 

721 ), 

722 ) 

723 

724 current_phase = "version" 

725 pipeline_error: Exception | None = None 

726 

727 with self._printer.wizard.dynamic_layered_progress(stages) as dlp: 

728 try: 

729 # Version file 

730 current_phase = "version" 

731 dlp.update_layer("version", 0, "Processing template...") 

732 config_dict = self._config.to_dict() 

733 version_file_path = Path(self._config.version_filename) 

734 self._template_service.generate_version_file( 

735 config_dict, version_file_path 

736 ) 

737 self._logger.info(_MSG_VERSION_OK) 

738 dlp.complete_layer("version") 

739 

740 # Compilation 

741 current_phase = "compile" 

742 dlp.update_layer("compile", 0, "Initializing compiler...") 

743 self._compiler_service, self._compilation_result = ( 

744 self._pipeline_service.compile_project( 

745 config=self._config, 

746 console=console, 

747 compiler=compiler, 

748 ) 

749 ) 

750 self._logger.info(_MSG_COMPILED_OK) 

751 dlp.complete_layer("compile") 

752 

753 # ZIP 

754 zip_needed = ( 

755 self._compilation_result.zip_needed 

756 if self._compilation_result 

757 else True 

758 ) 

759 if should_zip: 

760 if zip_needed: 760 ↛ 761line 760 didn't jump to line 761 because the condition on line 760 was never true

761 current_phase = "zip" 

762 

763 def _zip_cb(filename: str, progress: int) -> None: 

764 """Update progress display during ZIP file creation. 

765 

766 Args: 

767 filename: The name of the file being compressed. 

768 progress: The current progress percentage (0-100). 

769 """ 

770 dlp.update_layer("zip", progress, Path(filename).name) 

771 

772 self._pipeline_service.zip_artifact( 

773 config=self._config, 

774 compiler_service=self._compiler_service, 

775 compilation_result=self._compilation_result, 

776 progress_callback=_zip_cb, 

777 ) 

778 self._logger.info(_MSG_ZIP_OK) 

779 dlp.complete_layer("zip") 

780 else: 

781 # Stage was added but not needed at runtime 

782 dlp.update_layer("zip", 0, "Skipped (not needed)") 

783 dlp.complete_layer("zip") 

784 

785 # Installer (Inno Setup setup.exe for first deployment) 

786 if should_installer: 

787 current_phase = "installer" 

788 dlp.update_layer("installer", 0, "Building installer...") 

789 installer_path = self._pipeline_service.build_installer( 

790 config=self._config, 

791 compilation_result=self._compilation_result, 

792 ) 

793 self._logger.info(f"Installer built: {installer_path}") 

794 dlp.complete_layer("installer") 

795 

796 # Release (build the local TUF tree; upload is a separate step) 

797 if should_release: 

798 current_phase = "release" 

799 dlp.update_layer("release", 0, "Signing bundle...") 

800 repository_path = self._pipeline_service.release_artifact( 

801 config=self._config, 

802 compilation_result=self._compilation_result, 

803 ) 

804 self._logger.info(f"TUF release built: {repository_path}") 

805 dlp.complete_layer("release") 

806 

807 except ( 

808 ConfigurationError, 

809 CompilationError, 

810 TemplateError, 

811 VersionError, 

812 UploadError, 

813 ZipError, 

814 ReleaseError, 

815 SigningKeyError, 

816 InstallerError, 

817 ) as e: 

818 dlp.handle_error(current_phase, str(e)) 

819 dlp.emergency_stop(str(e)) 

820 pipeline_error = e 

821 except Exception as e: 

822 dlp.handle_error(current_phase, str(e)) 

823 dlp.emergency_stop(str(e)) 

824 pipeline_error = e 

825 

826 if pipeline_error: 

827 self._printer.error(str(pipeline_error)) 

828 self._logger.error(str(pipeline_error)) 

829 raise pipeline_error 

830 

831 self._printer.success("Build pipeline finished") 

832 self._logger.info("Build pipeline finished") 

833 

834 # //////////////////////////////////////////////// 

835 # PRIVATE HELPER METHODS 

836 # //////////////////////////////////////////////// 

837 

838 def _preflight_release(self, keys_dir: Path) -> None: 

839 """Raise SigningKeyError before compilation if signing keys are absent.""" 

840 if not keys_dir.is_dir() or not any(keys_dir.iterdir()): 

841 raise SigningKeyError( 

842 f"Signing keys not found in {keys_dir}. " 

843 "Run `ezcompiler release init` first." 

844 ) 

845 

846 def _zip_progress_callback(self, filename: str, progress: int) -> None: 

847 """ 

848 Progress callback for ZIP archive creation. 

849 

850 Logs progress at 10% intervals to reduce log verbosity. 

851 

852 Args: 

853 filename: Current file being zipped 

854 progress: Progress percentage (0-100) 

855 """ 

856 if progress % 10 == 0: # Log every 10% 

857 self._printer.debug(f"ZIP progress: {progress}% - {filename}") 

858 self._logger.debug(f"ZIP progress: {progress}% - {filename}")