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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Python API interface - High-level Python API for EzCompiler.
9This module provides the EzCompiler class that orchestrates project compilation,
10version generation, setup file creation, artifact zipping, and repository upload
11using the service layer.
13Interfaces layer can use all log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL).
14"""
16from __future__ import annotations
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
26if TYPE_CHECKING: 26 ↛ 27line 26 didn't jump to line 27 because the condition on line 26 was never true
27 import logging
29 from ezplog.handlers.wizard.dynamic import StageConfig
30 from ezplog.lib_mode import _LazyPrinter
32 from .._types import ReleaseDestination, RepoDestination
34# Third-party imports
35from ezplog.lib_mode import get_logger, get_printer
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)
59# ///////////////////////////////////////////////////////////////
60# CONSTANTS
61# ///////////////////////////////////////////////////////////////
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"
68# ///////////////////////////////////////////////////////////////
69# CLASSES
70# ///////////////////////////////////////////////////////////////
73class EzCompiler:
74 """
75 Main orchestration class for project compilation and distribution.
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.
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
86 Example:
87 >>> config = CompilerConfig(...)
88 >>> compiler = EzCompiler(config)
89 >>> compiler.compile_project()
90 >>> compiler.zip_compiled_project()
91 >>> compiler.upload()
92 """
94 # ////////////////////////////////////////////////
95 # INITIALIZATION
96 # ////////////////////////////////////////////////
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.
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.
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
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__)
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 )
139 # Compilation state
140 self._compilation_result: CompilationResult | None = None
142 # ////////////////////////////////////////////////
143 # LOGGING ACCESSOR PROPERTIES
144 # ////////////////////////////////////////////////
146 @property
147 def printer(self) -> _LazyPrinter:
148 """
149 Get the console printer proxy.
151 Returns:
152 _LazyPrinter: Lazy printer — silent until host app initializes Ezpl
153 """
154 return self._printer
156 @property
157 def logger(self) -> logging.Logger:
158 """
159 Get the stdlib logger.
161 Returns:
162 logging.Logger: Stdlib logger — silent until host app configures logging
163 """
164 return self._logger
166 @property
167 def config(self) -> CompilerConfig | None:
168 """
169 Get the current compiler configuration.
171 Returns:
172 CompilerConfig | None: Current configuration or None if not initialized
173 """
174 return self._config
176 # ////////////////////////////////////////////////
177 # PROJECT INITIALIZATION
178 # ////////////////////////////////////////////////
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.
192 Creates a CompilerConfig from provided parameters. This is a
193 convenience method for backward compatibility; can also set
194 config directly.
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
204 Raises:
205 ConfigurationError: If configuration is invalid
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 }
228 # Update configuration
229 self._config = CompilerConfig(**config_dict)
231 self._printer.success("Project configuration initialized successfully")
232 self._logger.info("Project configuration initialized successfully")
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
241 # ////////////////////////////////////////////////
242 # VERSION AND SETUP GENERATION
243 # ////////////////////////////////////////////////
245 def generate_version_file(self, name: str = "version_info.txt") -> None:
246 """
247 Generate version information file.
249 Uses the configured version information to generate a version file
250 at the specified path. Legacy method for backward compatibility.
252 Args:
253 name: Version file name (default: "version_info.txt")
255 Raises:
256 ConfigurationError: If project not initialized
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)
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)
270 self._printer.success(_MSG_VERSION_OK)
271 self._logger.info(_MSG_VERSION_OK)
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
280 def generate_setup_file(self, file_path: Path | str) -> None:
281 """
282 Generate setup.py file from template.
284 Creates a setup.py file using the template system. Legacy method
285 for backward compatibility.
287 Args:
288 file_path: Path where to create the setup.py file
290 Raises:
291 ConfigurationError: If project not initialized
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)
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 )
307 self._printer.success("Setup file generated successfully")
308 self._logger.info("Setup file generated successfully")
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
317 # ////////////////////////////////////////////////
318 # COMPILATION METHODS
319 # ////////////////////////////////////////////////
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.
327 Validates configuration, selects compiler if not specified, and
328 executes compilation. Sets _zip_needed based on compiler output type.
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
338 Raises:
339 ConfigurationError: If project not initialized
340 CompilationError: If compilation fails
342 Example:
343 >>> compiler.compile_project(console=False, compiler="PyInstaller")
344 """
345 try:
346 if not self._config:
347 raise ConfigurationError(_MSG_NOT_INITIALIZED)
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 )
359 self._printer.success(_MSG_COMPILED_OK)
360 self._logger.info(_MSG_COMPILED_OK)
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
369 def zip_compiled_project(self) -> None:
370 """
371 Create ZIP archive of compiled project.
373 Archives the compiled output if needed. Cx_Freeze output is
374 zipped; PyInstaller single-file output is not.
376 Raises:
377 ConfigurationError: If project not initialized
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)
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 )
393 if not zip_needed:
394 self._printer.info("ZIP not needed for this compilation type")
395 return
397 # Create ZIP archive via CompilerService
398 if self._compiler_service is None:
399 self._compiler_service = self._compiler_service_factory(self._config)
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 )
408 self._printer.success(_MSG_ZIP_OK)
409 self._logger.info(_MSG_ZIP_OK)
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
418 # ////////////////////////////////////////////////
419 # UPLOAD METHODS
420 # ////////////////////////////////////////////////
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.
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")
435 Sinon, uploade l'artefact compilé (comportement inchangé).
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.
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)
450 repo_dest = repo_destination or self._config.repo_destination
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 )
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 )
483 self._printer.success(f"Upload completed ({repo_dest})")
484 self._logger.info(f"Upload completed ({repo_dest})")
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
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.
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).
505 Args:
506 bundle_dir: Directory containing the compiled application artifacts.
507 publish: When True, upload the repository/ tree to ``update_repo_url``.
509 Returns:
510 Path: The local ``repository/`` tree produced by tufup.
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
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 )
543 def init_release(self) -> bool:
544 """Initialise les clés/repo TUF depuis la config courante.
546 Action explicite — jamais appelée par run_pipeline().
548 Returns:
549 bool: True si init effectuée, False si clés déjà présentes (skip).
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 )
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.
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.
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.
586 Returns:
587 Path: The local TUF repository directory.
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
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.
621 Generates ``update.py``, ``settings.py``, and copies ``root.json``
622 from the local TUF repository into ``output_dir``.
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()``.
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.
634 Returns:
635 List of generated file paths [settings.py, update.py, root.json].
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)
645 resolved_dir = (
646 Path(output_dir)
647 if output_dir is not None
648 else Path(self._config.main_file).parent
649 )
651 files = UpdaterService.generate(self._config, resolved_dir)
653 if patch_config:
654 self._config.include_files["files"].extend(str(f) for f in files)
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])
659 return files
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.
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.
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
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
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)
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
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)
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 )
724 current_phase = "version"
725 pipeline_error: Exception | None = None
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")
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")
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"
763 def _zip_cb(filename: str, progress: int) -> None:
764 """Update progress display during ZIP file creation.
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)
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")
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")
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")
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
826 if pipeline_error:
827 self._printer.error(str(pipeline_error))
828 self._logger.error(str(pipeline_error))
829 raise pipeline_error
831 self._printer.success("Build pipeline finished")
832 self._logger.info("Build pipeline finished")
834 # ////////////////////////////////////////////////
835 # PRIVATE HELPER METHODS
836 # ////////////////////////////////////////////////
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 )
846 def _zip_progress_callback(self, filename: str, progress: int) -> None:
847 """
848 Progress callback for ZIP archive creation.
850 Logs progress at 10% intervals to reduce log verbosity.
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}")