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

265 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 16:37 +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 PublishService, 

42 ReleaseService, 

43 TemplateService, 

44 TufService, 

45 UpdaterService, 

46 UploaderService, 

47) 

48from ..shared import CompilationResult, CompilerConfig, ReleasePreflight 

49from ..shared.exceptions import ( 

50 CompilationError, 

51 ConfigurationError, 

52 InstallerError, 

53 ReleaseError, 

54 SigningKeyError, 

55 TemplateError, 

56 UploadError, 

57 VersionError, 

58 ZipError, 

59) 

60from ..utils import is_prerelease 

61 

62# /////////////////////////////////////////////////////////////// 

63# CONSTANTS 

64# /////////////////////////////////////////////////////////////// 

65 

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

67_MSG_VERSION_OK = "Version file generated successfully" 

68_MSG_COMPILED_OK = "Project compiled successfully" 

69_MSG_ZIP_OK = "ZIP archive created successfully" 

70 

71# /////////////////////////////////////////////////////////////// 

72# CLASSES 

73# /////////////////////////////////////////////////////////////// 

74 

75 

76class EzCompiler: 

77 """ 

78 Main orchestration class for project compilation and distribution. 

79 

80 Coordinates project compilation using modular compilers, version file 

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

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

83 

84 Attributes: 

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

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

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

88 

89 Example: 

90 >>> config = CompilerConfig(...) 

91 >>> compiler = EzCompiler(config) 

92 >>> compiler.compile_project() 

93 >>> compiler.zip_compiled_project() 

94 """ 

95 

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

97 # INITIALIZATION 

98 # //////////////////////////////////////////////// 

99 

100 def __init__( 

101 self, 

102 config: CompilerConfig | None = None, 

103 compiler_service_factory: ( 

104 Callable[[CompilerConfig], CompilerService] | None 

105 ) = None, 

106 template_service: TemplateService | None = None, 

107 uploader_service: UploaderService | None = None, 

108 pipeline_service: PipelineService | None = None, 

109 ) -> None: 

110 """ 

111 Initialize the EzCompiler orchestrator. 

112 

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

114 passive proxies that produce no output until the host application 

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

116 application-level concern. 

117 

118 Args: 

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

120 compiler_service_factory: Optional factory for CompilerService (for testing) 

121 template_service: Optional TemplateService instance (for testing) 

122 uploader_service: Optional UploaderService instance (for testing) 

123 pipeline_service: Optional PipelineService instance (for testing) 

124 """ 

125 # Configuration management 

126 self._config = config 

127 

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

129 self._printer: _LazyPrinter = get_printer() 

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

131 

132 # Service instances 

133 self._compiler_service_factory = compiler_service_factory or CompilerService 

134 self._compiler_service: CompilerService | None = None 

135 self._template_service = template_service or TemplateService() 

136 self._uploader_service = uploader_service or UploaderService() 

137 self._pipeline_service = pipeline_service or PipelineService( 

138 compiler_service_factory=self._compiler_service_factory 

139 ) 

140 

141 # Compilation state 

142 self._compilation_result: CompilationResult | None = None 

143 

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

145 # LOGGING ACCESSOR PROPERTIES 

146 # //////////////////////////////////////////////// 

147 

148 @property 

149 def printer(self) -> _LazyPrinter: 

150 """ 

151 Get the console printer proxy. 

152 

153 Returns: 

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

155 """ 

156 return self._printer 

157 

158 @property 

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

160 """ 

161 Get the stdlib logger. 

162 

163 Returns: 

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

165 """ 

166 return self._logger 

167 

168 @property 

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

170 """ 

171 Get the current compiler configuration. 

172 

173 Returns: 

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

175 """ 

176 return self._config 

177 

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

179 # PROJECT INITIALIZATION 

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

181 

182 def init_project( 

183 self, 

184 version: str, 

185 project_name: str, 

186 main_file: str, 

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

188 output_folder: Path | str, 

189 **kwargs: Any, 

190 ) -> None: 

191 """ 

192 Initialize project configuration. 

193 

194 Creates a CompilerConfig from provided parameters. This is a 

195 convenience method for backward compatibility; can also set 

196 config directly. 

197 

198 Args: 

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

200 project_name: Project name 

201 main_file: Path to main Python file 

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

203 output_folder: Output directory path 

204 **kwargs: Additional config options 

205 

206 Raises: 

207 ConfigurationError: If configuration is invalid 

208 

209 Example: 

210 >>> compiler = EzCompiler() 

211 >>> compiler.init_project( 

212 ... version="1.0.0", 

213 ... project_name="MyApp", 

214 ... main_file="main.py", 

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

216 ... output_folder="dist" 

217 ... ) 

218 """ 

219 try: 

220 # Create configuration from parameters 

221 config_dict: dict[str, Any] = { 

222 "version": version, 

223 "project_name": project_name, 

224 "main_file": main_file, 

225 "include_files": include_files, 

226 "output_folder": str(output_folder), 

227 **kwargs, 

228 } 

229 

230 # Update configuration 

231 self._config = CompilerConfig(**config_dict) 

232 

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

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

235 

236 except ConfigurationError: 

237 raise 

238 except Exception as e: 

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

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

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

242 

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

244 # VERSION AND SETUP GENERATION 

245 # //////////////////////////////////////////////// 

246 

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

248 """ 

249 Generate version information file. 

250 

251 Uses the configured version information to generate a version file 

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

253 

254 Args: 

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

256 

257 Raises: 

258 ConfigurationError: If project not initialized 

259 

260 Note: 

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

262 """ 

263 try: 

264 if not self._config: 

265 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

266 

267 # Generate using TemplateService 

268 config_dict = self._config.to_dict() 

269 version_file_path = Path(name) 

270 self._template_service.generate_version_file(config_dict, version_file_path) 

271 

272 self._printer.success(_MSG_VERSION_OK) 

273 self._logger.info(_MSG_VERSION_OK) 

274 

275 except (ConfigurationError, VersionError, TemplateError): 

276 raise 

277 except Exception as e: 

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

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

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

281 

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

283 """ 

284 Generate setup.py file from template. 

285 

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

287 for backward compatibility. 

288 

289 Args: 

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

291 

292 Raises: 

293 ConfigurationError: If project not initialized 

294 

295 Note: 

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

297 """ 

298 try: 

299 if not self._config: 

300 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

301 

302 # Generate using TemplateService 

303 config_dict = self._config.to_dict() 

304 output_path = Path(file_path) 

305 self._template_service.generate_setup_file( 

306 config_dict, output_path=output_path 

307 ) 

308 

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

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

311 

312 except (ConfigurationError, TemplateError): 

313 raise 

314 except Exception as e: 

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

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

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

318 

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

320 # COMPILATION METHODS 

321 # //////////////////////////////////////////////// 

322 

323 def compile_project( 

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

325 ) -> None: 

326 """ 

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

328 

329 Validates configuration, selects compiler if not specified, and 

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

331 

332 Args: 

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

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

335 - "Cx_Freeze": Creates directory with dependencies 

336 - "PyInstaller": Creates single executable 

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

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

339 

340 Raises: 

341 ConfigurationError: If project not initialized 

342 CompilationError: If compilation fails 

343 

344 Example: 

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

346 """ 

347 try: 

348 if not self._config: 

349 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

350 

351 # Create compiler service and compile 

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

353 self._compilation_result = self._compiler_service.compile( 

354 console=console, 

355 compiler=cast( 

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

357 compiler, 

358 ), 

359 ) 

360 

361 self._printer.success(_MSG_COMPILED_OK) 

362 self._logger.info(_MSG_COMPILED_OK) 

363 

364 except (ConfigurationError, CompilationError): 

365 raise 

366 except Exception as e: 

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

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

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

370 

371 def zip_compiled_project(self) -> None: 

372 """ 

373 Create ZIP archive of compiled project. 

374 

375 Archives the compiled output if needed. Cx_Freeze output is 

376 zipped; PyInstaller single-file output is not. 

377 

378 Raises: 

379 ConfigurationError: If project not initialized 

380 

381 Note: 

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

383 """ 

384 try: 

385 if not self._config: 

386 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

387 

388 # Check if ZIP is needed from compilation result 

389 zip_needed = ( 

390 self._compilation_result.zip_needed 

391 if self._compilation_result 

392 else True 

393 ) 

394 

395 if not zip_needed: 

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

397 return 

398 

399 # Create ZIP archive via CompilerService 

400 if self._compiler_service is None: 

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

402 

403 self._pipeline_service.zip_artifact( 

404 config=self._config, 

405 compiler_service=self._compiler_service, 

406 compilation_result=self._compilation_result, 

407 progress_callback=self._zip_progress_callback, 

408 ) 

409 

410 self._printer.success(_MSG_ZIP_OK) 

411 self._logger.info(_MSG_ZIP_OK) 

412 

413 except (ConfigurationError, ZipError): 

414 raise 

415 except Exception as e: 

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

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

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

419 

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

421 # UPLOAD METHODS 

422 # //////////////////////////////////////////////// 

423 

424 def upload( 

425 self, 

426 destination: str | None = None, 

427 repo_destination: RepoDestination | None = None, 

428 release_destination: ReleaseDestination | None = None, 

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

430 ) -> None: 

431 """Upload the TUF repo and/or the installer zip, as the config says. 

432 

433 Deprecated: 

434 Deprecated since 4.1.0, removed in v5. Use ``publish_update()`` 

435 then ``publish_release()``, or the CLI (``ezcompiler publish 

436 update`` then ``ezcompiler publish release``), which asks for 

437 confirmation before any irreversible publication. 

438 

439 When ``release_needed`` is True, performs two sequential uploads: 

440 1. TUF tree -> ``<dest>/update/`` 

441 2. installer zip -> ``<dest>/release/`` (skipped if repo_destination="r2") 

442 

443 Otherwise, uploads the compiled artifact (unchanged behavior). 

444 

445 Args: 

446 destination: Shared override for both destinations. 

447 repo_destination: Override for ``config.repo_destination``. 

448 release_destination: Override for ``config.release_destination``. 

449 upload_config: Extra options passed to the uploaders. 

450 

451 Raises: 

452 ConfigurationError: If the project is not initialized. 

453 UploadError: If an upload fails. 

454 """ 

455 if not self._config: 

456 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

457 

458 import warnings # noqa: PLC0415 

459 

460 warnings.warn( 

461 "EzCompiler.upload() is deprecated and will be removed in v5. " 

462 "Use `publish_update()` then `publish_release()` (or the CLI: " 

463 "`ezcompiler publish update` then `ezcompiler publish release`, " 

464 "which asks for confirmation before any irreversible publication).", 

465 DeprecationWarning, 

466 stacklevel=2, 

467 ) 

468 

469 repo_dest = repo_destination or self._config.repo_destination 

470 

471 try: 

472 if self._config.tuf_enabled: 

473 repo_dir = TufService.repo_dir(self._config) 

474 rel_dest = release_destination or self._config.release_destination 

475 release_root = ( 

476 None 

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

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

479 ) 

480 UploaderService.upload_release( 

481 config=self._config, 

482 repo_dir=repo_dir, 

483 release_root=release_root, 

484 destination=destination, 

485 repo_destination=repo_destination, 

486 release_destination=release_destination, 

487 upload_config=upload_config, 

488 ) 

489 

490 else: 

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

492 self._pipeline_service.upload_artifact( 

493 config=self._config, 

494 structure=repo_dest, 

495 destination=str(dest), 

496 compilation_result=self._compilation_result, 

497 upload_config=upload_config, 

498 ) 

499 

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

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

502 

503 except (ConfigurationError, UploadError, ReleaseError): 

504 raise 

505 except Exception as e: 

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

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

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

509 

510 def publish_update( 

511 self, 

512 destination: str | None = None, 

513 repo_destination: RepoDestination | None = None, 

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

515 ) -> None: 

516 """Publish the signed TUF tree to the update backend. 

517 

518 Python counterpart of ``ezcompiler publish update``. Only the public 

519 part of the tree is transferred; the interactive confirmation stays 

520 specific to the CLI, a programmatic call being explicit by nature. 

521 

522 Args: 

523 destination: Override for the resolved update destination. 

524 repo_destination: Override for ``config.repo_destination``. 

525 upload_config: Extra options passed to the uploader. 

526 

527 Raises: 

528 ConfigurationError: If the project is not initialized. 

529 UploadError: If the transfer fails. 

530 """ 

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

532 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

533 

534 PublishService.publish_update( 

535 self._config, 

536 destination=destination, 

537 repo_destination=repo_destination, 

538 upload_config=upload_config, 

539 ) 

540 self._logger.info("TUF update tree published") 

541 

542 def preflight_release( 

543 self, 

544 *, 

545 tag: str | None = None, 

546 release_destination: ReleaseDestination | None = None, 

547 ) -> ReleasePreflight: 

548 """Run the pre-publication checks and return the recap. 

549 

550 Args: 

551 tag: Target tag (default: ``v<version>``). 

552 release_destination: Override for ``config.release_destination``. 

553 

554 Returns: 

555 ReleasePreflight: What the publication would do, once every local 

556 check has passed. 

557 

558 Raises: 

559 ConfigurationError: If the project is not initialized. 

560 PublishError: If the platform CLI is missing, not authenticated, 

561 or if the tag already exists. 

562 ReleaseError: If an enabled installer is missing, or if nothing 

563 was built. 

564 """ 

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

566 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

567 

568 return PublishService.preflight_release( 

569 self._config, 

570 tag=tag or f"v{self._config.version}", 

571 release_destination=release_destination, 

572 ) 

573 

574 def publish_release( 

575 self, 

576 *, 

577 tag: str | None = None, 

578 title: str | None = None, 

579 notes: str | None = None, 

580 prerelease: bool | None = None, 

581 draft: bool = False, 

582 destination: str | None = None, 

583 release_destination: ReleaseDestination | None = None, 

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

585 ) -> str | None: 

586 """Publish the installer and the zip as a release. 

587 

588 Python counterpart of ``ezcompiler publish release``. On a platform 

589 (``github``), creates the release and attaches the artifacts; on a 

590 file destination (``disk``/``server``/``r2``), copies the artifacts. 

591 The operation is irreversible on the platform side: call 

592 ``preflight_release()`` first to present a recap. 

593 

594 Args: 

595 tag: Release tag (default: ``v<version>``). 

596 title: Title (default: ``<project> v<version>``). 

597 notes: Release body; ``None`` asks for generated notes. 

598 prerelease: Force the pre-release label (default: inferred from 

599 the version). 

600 draft: Create the release unpublished. 

601 destination: Override for the resolved file destination. 

602 release_destination: Override for ``config.release_destination``. 

603 upload_config: Extra options passed to the uploader. 

604 

605 Returns: 

606 str | None: The release URL on the platform path, ``None`` on the 

607 file path. 

608 

609 Raises: 

610 ConfigurationError: If the project is not initialized. 

611 PublishError: If publishing to the platform fails. 

612 UploadError: If the file transfer fails. 

613 ReleaseError: If an enabled installer is missing, or if nothing 

614 was built. 

615 """ 

616 if not self._config: 

617 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

618 

619 resolved_tag = tag or f"v{self._config.version}" 

620 preflight = PublishService.preflight_release( 

621 self._config, tag=resolved_tag, release_destination=release_destination 

622 ) 

623 assets = list(preflight.assets) 

624 url = PublishService.publish_release( 

625 self._config, 

626 assets, 

627 tag=resolved_tag, 

628 title=title or f"{self._config.project_name} v{self._config.version}", 

629 notes=notes, 

630 prerelease=( 

631 is_prerelease(self._config.version) 

632 if prerelease is None 

633 else prerelease 

634 ), 

635 draft=draft, 

636 destination=destination, 

637 release_destination=release_destination, 

638 upload_config=upload_config, 

639 ) 

640 self._logger.info("Release %s published: %s", resolved_tag, url) 

641 return url 

642 

643 def release( 

644 self, 

645 bundle_dir: Path, 

646 *, 

647 publish: bool = False, 

648 required: bool = False, 

649 ) -> Path: 

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

651 

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

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

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

655 

656 Args: 

657 bundle_dir: Directory containing the compiled application artifacts. 

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

659 Deprecated since 4.1.0 (removed in v5): run ``run_pipeline()`` 

660 then ``ezcompiler publish update`` / ``ezcompiler publish 

661 release`` instead. 

662 required: Mark this version as mandatory for TUF clients. 

663 

664 Returns: 

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

666 

667 Raises: 

668 ConfigurationError: If project not initialized. 

669 ReleaseError: If release packaging or remote publishing fails. 

670 """ 

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

672 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

673 if publish: 

674 import warnings # noqa: PLC0415 

675 

676 warnings.warn( 

677 "release(publish=True) is deprecated: run run_pipeline() " 

678 "then `ezcompiler publish update` / `ezcompiler publish " 

679 "release`. run_pipeline() performs no remote transfer.", 

680 DeprecationWarning, 

681 stacklevel=2, 

682 ) 

683 repo_dir = TufService.repo_dir(self._config) 

684 keys_dir = TufService.keys_dir(self._config) 

685 return ReleaseService.release_and_publish( 

686 bundle_dir=bundle_dir, 

687 app_name=self._config.project_name, 

688 version=self._config.version, 

689 repo_dir=repo_dir, 

690 publish=publish, 

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

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

693 releaser_config={ 

694 "keys_dir": keys_dir, 

695 "expiration_days": self._config.tuf_expiration_days, 

696 }, 

697 required=required, 

698 ) 

699 

700 def init_release(self) -> bool: 

701 """Initialize the TUF keys/repo from the current config. 

702 

703 Explicit action - never called by run_pipeline(). 

704 

705 Returns: 

706 bool: True if the init ran, False if the keys were already present 

707 (skip). 

708 

709 Raises: 

710 ConfigurationError: If project not initialized. 

711 ReleaseError: If TUF initialization fails. 

712 """ 

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

714 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

715 repo_dir = TufService.repo_dir(self._config) 

716 keys_dir = TufService.keys_dir(self._config) 

717 return ReleaseService.init_release( 

718 app_name=self._config.project_name, 

719 repo_dir=repo_dir, 

720 keys_dir=keys_dir, 

721 releaser_config={ 

722 "keys_dir": keys_dir, 

723 "expiration_days": self._config.tuf_expiration_days, 

724 }, 

725 ) 

726 

727 def refresh_release_expiration( 

728 self, 

729 *, 

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

731 days: int | None = None, 

732 ) -> Path: 

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

734 

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

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

737 keep trusting the repository between releases. 

738 

739 Args: 

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

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

742 (or tufup defaults) for each role. 

743 

744 Returns: 

745 Path: The local TUF repository directory. 

746 

747 Raises: 

748 ConfigurationError: If project not initialized. 

749 ReleaseError: If the refresh fails. 

750 SigningKeyError: If signing keys are missing. 

751 """ 

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

753 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

754 repo_dir = TufService.repo_dir(self._config) 

755 keys_dir = TufService.keys_dir(self._config) 

756 repo = ReleaseService.refresh_expiration( 

757 app_name=self._config.project_name, 

758 repo_dir=repo_dir, 

759 keys_dir=keys_dir, 

760 roles=roles, 

761 days=days, 

762 releaser_config={ 

763 "keys_dir": keys_dir, 

764 "expiration_days": self._config.tuf_expiration_days, 

765 }, 

766 ) 

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

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

769 return repo 

770 

771 def generate_updater( 

772 self, 

773 output_dir: Path | str | None = None, 

774 *, 

775 patch_config: bool = True, 

776 ) -> list[Path]: 

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

778 

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

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

781 

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

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

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

785 ``run_pipeline()``. 

786 

787 Args: 

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

789 directory containing ``main_file``. 

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

791 

792 Returns: 

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

794 

795 Raises: 

796 ConfigurationError: If project not initialized. 

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

798 UpdaterGenerationError: If writing files fails. 

799 """ 

800 if not self._config: 

801 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

802 

803 resolved_dir = ( 

804 Path(output_dir) 

805 if output_dir is not None 

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

807 ) 

808 

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

810 

811 if patch_config: 

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

813 

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

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

816 

817 return files 

818 

819 def run_pipeline( 

820 self, 

821 console: bool = True, 

822 compiler: str | None = None, 

823 skip_zip: bool = False, 

824 skip_release: bool = False, 

825 skip_installer: bool = False, 

826 skip_build: bool = False, 

827 required: bool = False, 

828 ) -> None: 

829 """ 

830 Run the build pipeline with visual progress tracking. 

831 

832 Executes version generation, compilation, optional ZIP creation, 

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

834 DynamicLayeredProgress display. Publication is not part of the 

835 pipeline — run ``ezcompiler publish update`` / ``ezcompiler publish 

836 release`` afterwards. 

837 

838 Args: 

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

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

841 skip_zip: Skip ZIP archive creation 

842 skip_release: Skip the TUF release stage 

843 skip_installer: Skip the installer build stage 

844 skip_build: Skip version generation and compilation, and resume 

845 from the existing build in ``output_folder`` 

846 required: Mark the released version as mandatory (needs the TUF 

847 release stage) 

848 

849 Raises: 

850 ConfigurationError: If project not initialized, or required without 

851 a release stage 

852 CompilationError: If compilation fails, or no existing build is 

853 found when ``skip_build`` is set 

854 VersionError: If version file generation fails 

855 ZipError: If ZIP creation fails 

856 ReleaseError: If the release stage fails 

857 InstallerError: If the installer build fails 

858 

859 Example: 

860 >>> compiler = EzCompiler(config) 

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

862 """ 

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

864 raise ConfigurationError(_MSG_NOT_INITIALIZED) 

865 

866 # Determine which optional stages to include 

867 should_zip = not skip_zip 

868 should_release = not skip_release and self._config.tuf_enabled 

869 should_installer = not skip_installer and self._config.installer.enabled 

870 

871 if required and not should_release: 

872 raise ConfigurationError( 

873 "required=True only has an effect when the TUF release step " 

874 "runs (tuf_enabled, without skip_release)." 

875 ) 

876 

877 # Pre-flight: fail early if release needed but keys absent, or if the 

878 # version is not above a withdrawn one (before a possibly long compile) 

879 if should_release: 

880 self._preflight_release(TufService.keys_dir(self._config)) 

881 TufService.ensure_releasable( 

882 TufService.repo_dir(self._config), self._config.version 

883 ) 

884 

885 # Build stages 

886 stages: list[StageConfig] = cast( 

887 list["StageConfig"], 

888 PipelineService.build_stages( 

889 self._config, 

890 should_zip=should_zip, 

891 should_release=should_release, 

892 should_installer=should_installer, 

893 should_build=not skip_build, 

894 ), 

895 ) 

896 

897 current_phase = "version" 

898 pipeline_error: Exception | None = None 

899 

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

901 try: 

902 if skip_build: 

903 # Resume from a previous build: no version file, no compile 

904 current_phase = "compile" 

905 dlp.update_layer("compile", 0, "Checking existing build...") 

906 self._compiler_service, self._compilation_result = ( 

907 self._pipeline_service.reuse_build( 

908 config=self._config, 

909 compiler=compiler, 

910 ) 

911 ) 

912 self._logger.info( 

913 f"Reusing existing build in {self._config.output_folder}" 

914 ) 

915 dlp.complete_layer("compile") 

916 else: 

917 # Version file 

918 current_phase = "version" 

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

920 config_dict = self._config.to_dict() 

921 version_file_path = Path(self._config.version_filename) 

922 self._template_service.generate_version_file( 

923 config_dict, version_file_path 

924 ) 

925 self._logger.info(_MSG_VERSION_OK) 

926 dlp.complete_layer("version") 

927 

928 # Compilation 

929 current_phase = "compile" 

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

931 self._compiler_service, self._compilation_result = ( 

932 self._pipeline_service.compile_project( 

933 config=self._config, 

934 console=console, 

935 compiler=compiler, 

936 ) 

937 ) 

938 self._logger.info(_MSG_COMPILED_OK) 

939 dlp.complete_layer("compile") 

940 

941 # ZIP 

942 zip_needed = ( 

943 self._compilation_result.zip_needed 

944 if self._compilation_result 

945 else True 

946 ) 

947 if should_zip: 

948 if zip_needed: 

949 current_phase = "zip" 

950 

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

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

953 

954 Args: 

955 filename: The name of the file being compressed. 

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

957 """ 

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

959 

960 self._pipeline_service.zip_artifact( 

961 config=self._config, 

962 compiler_service=self._compiler_service, 

963 compilation_result=self._compilation_result, 

964 progress_callback=_zip_cb, 

965 ) 

966 self._logger.info(_MSG_ZIP_OK) 

967 dlp.complete_layer("zip") 

968 else: 

969 # Stage was added but not needed at runtime 

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

971 dlp.complete_layer("zip") 

972 

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

974 if should_installer: 

975 current_phase = "installer" 

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

977 installer_path = self._pipeline_service.build_installer( 

978 config=self._config, 

979 compilation_result=self._compilation_result, 

980 ) 

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

982 dlp.complete_layer("installer") 

983 

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

985 if should_release: 

986 current_phase = "release" 

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

988 repository_path = self._pipeline_service.release_artifact( 

989 config=self._config, 

990 compilation_result=self._compilation_result, 

991 required=required, 

992 ) 

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

994 dlp.complete_layer("release") 

995 

996 except ( 

997 ConfigurationError, 

998 CompilationError, 

999 TemplateError, 

1000 VersionError, 

1001 UploadError, 

1002 ZipError, 

1003 ReleaseError, 

1004 SigningKeyError, 

1005 InstallerError, 

1006 ) as e: 

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

1008 dlp.emergency_stop(str(e)) 

1009 pipeline_error = e 

1010 except Exception as e: 

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

1012 dlp.emergency_stop(str(e)) 

1013 pipeline_error = e 

1014 

1015 if pipeline_error: 

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

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

1018 raise pipeline_error 

1019 

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

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

1022 

1023 # //////////////////////////////////////////////// 

1024 # PRIVATE HELPER METHODS 

1025 # //////////////////////////////////////////////// 

1026 

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

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

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

1030 raise SigningKeyError( 

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

1032 "Run `ezcompiler tuf init` first." 

1033 ) 

1034 

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

1036 """ 

1037 Progress callback for ZIP archive creation. 

1038 

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

1040 

1041 Args: 

1042 filename: Current file being zipped 

1043 progress: Progress percentage (0-100) 

1044 """ 

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

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

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