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
« 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# ///////////////////////////////////////////////////////////////
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 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
62# ///////////////////////////////////////////////////////////////
63# CONSTANTS
64# ///////////////////////////////////////////////////////////////
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"
71# ///////////////////////////////////////////////////////////////
72# CLASSES
73# ///////////////////////////////////////////////////////////////
76class EzCompiler:
77 """
78 Main orchestration class for project compilation and distribution.
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.
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
89 Example:
90 >>> config = CompilerConfig(...)
91 >>> compiler = EzCompiler(config)
92 >>> compiler.compile_project()
93 >>> compiler.zip_compiled_project()
94 """
96 # ////////////////////////////////////////////////
97 # INITIALIZATION
98 # ////////////////////////////////////////////////
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.
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.
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
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__)
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 )
141 # Compilation state
142 self._compilation_result: CompilationResult | None = None
144 # ////////////////////////////////////////////////
145 # LOGGING ACCESSOR PROPERTIES
146 # ////////////////////////////////////////////////
148 @property
149 def printer(self) -> _LazyPrinter:
150 """
151 Get the console printer proxy.
153 Returns:
154 _LazyPrinter: Lazy printer — silent until host app initializes Ezpl
155 """
156 return self._printer
158 @property
159 def logger(self) -> logging.Logger:
160 """
161 Get the stdlib logger.
163 Returns:
164 logging.Logger: Stdlib logger — silent until host app configures logging
165 """
166 return self._logger
168 @property
169 def config(self) -> CompilerConfig | None:
170 """
171 Get the current compiler configuration.
173 Returns:
174 CompilerConfig | None: Current configuration or None if not initialized
175 """
176 return self._config
178 # ////////////////////////////////////////////////
179 # PROJECT INITIALIZATION
180 # ////////////////////////////////////////////////
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.
194 Creates a CompilerConfig from provided parameters. This is a
195 convenience method for backward compatibility; can also set
196 config directly.
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
206 Raises:
207 ConfigurationError: If configuration is invalid
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 }
230 # Update configuration
231 self._config = CompilerConfig(**config_dict)
233 self._printer.success("Project configuration initialized successfully")
234 self._logger.info("Project configuration initialized successfully")
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
243 # ////////////////////////////////////////////////
244 # VERSION AND SETUP GENERATION
245 # ////////////////////////////////////////////////
247 def generate_version_file(self, name: str = "version_info.txt") -> None:
248 """
249 Generate version information file.
251 Uses the configured version information to generate a version file
252 at the specified path. Legacy method for backward compatibility.
254 Args:
255 name: Version file name (default: "version_info.txt")
257 Raises:
258 ConfigurationError: If project not initialized
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)
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)
272 self._printer.success(_MSG_VERSION_OK)
273 self._logger.info(_MSG_VERSION_OK)
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
282 def generate_setup_file(self, file_path: Path | str) -> None:
283 """
284 Generate setup.py file from template.
286 Creates a setup.py file using the template system. Legacy method
287 for backward compatibility.
289 Args:
290 file_path: Path where to create the setup.py file
292 Raises:
293 ConfigurationError: If project not initialized
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)
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 )
309 self._printer.success("Setup file generated successfully")
310 self._logger.info("Setup file generated successfully")
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
319 # ////////////////////////////////////////////////
320 # COMPILATION METHODS
321 # ////////////////////////////////////////////////
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.
329 Validates configuration, selects compiler if not specified, and
330 executes compilation. Sets _zip_needed based on compiler output type.
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
340 Raises:
341 ConfigurationError: If project not initialized
342 CompilationError: If compilation fails
344 Example:
345 >>> compiler.compile_project(console=False, compiler="PyInstaller")
346 """
347 try:
348 if not self._config:
349 raise ConfigurationError(_MSG_NOT_INITIALIZED)
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 )
361 self._printer.success(_MSG_COMPILED_OK)
362 self._logger.info(_MSG_COMPILED_OK)
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
371 def zip_compiled_project(self) -> None:
372 """
373 Create ZIP archive of compiled project.
375 Archives the compiled output if needed. Cx_Freeze output is
376 zipped; PyInstaller single-file output is not.
378 Raises:
379 ConfigurationError: If project not initialized
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)
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 )
395 if not zip_needed:
396 self._printer.info("ZIP not needed for this compilation type")
397 return
399 # Create ZIP archive via CompilerService
400 if self._compiler_service is None:
401 self._compiler_service = self._compiler_service_factory(self._config)
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 )
410 self._printer.success(_MSG_ZIP_OK)
411 self._logger.info(_MSG_ZIP_OK)
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
420 # ////////////////////////////////////////////////
421 # UPLOAD METHODS
422 # ////////////////////////////////////////////////
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.
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.
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")
443 Otherwise, uploads the compiled artifact (unchanged behavior).
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.
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)
458 import warnings # noqa: PLC0415
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 )
469 repo_dest = repo_destination or self._config.repo_destination
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 )
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 )
500 self._printer.success(f"Upload completed ({repo_dest})")
501 self._logger.info(f"Upload completed ({repo_dest})")
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
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.
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.
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.
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)
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")
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.
550 Args:
551 tag: Target tag (default: ``v<version>``).
552 release_destination: Override for ``config.release_destination``.
554 Returns:
555 ReleasePreflight: What the publication would do, once every local
556 check has passed.
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)
568 return PublishService.preflight_release(
569 self._config,
570 tag=tag or f"v{self._config.version}",
571 release_destination=release_destination,
572 )
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.
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.
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.
605 Returns:
606 str | None: The release URL on the platform path, ``None`` on the
607 file path.
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)
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
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.
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).
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.
664 Returns:
665 Path: The local ``repository/`` tree produced by tufup.
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
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 )
700 def init_release(self) -> bool:
701 """Initialize the TUF keys/repo from the current config.
703 Explicit action - never called by run_pipeline().
705 Returns:
706 bool: True if the init ran, False if the keys were already present
707 (skip).
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 )
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.
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.
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.
744 Returns:
745 Path: The local TUF repository directory.
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
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.
779 Generates ``update.py``, ``settings.py``, and copies ``root.json``
780 from the local TUF repository into ``output_dir``.
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()``.
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.
792 Returns:
793 List of generated file paths [settings.py, update.py, root.json].
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)
803 resolved_dir = (
804 Path(output_dir)
805 if output_dir is not None
806 else Path(self._config.main_file).parent
807 )
809 files = UpdaterService.generate(self._config, resolved_dir)
811 if patch_config:
812 self._config.include_files["files"].extend(str(f) for f in files)
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])
817 return files
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.
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.
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)
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
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)
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
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 )
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 )
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 )
897 current_phase = "version"
898 pipeline_error: Exception | None = None
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")
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")
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"
951 def _zip_cb(filename: str, progress: int) -> None:
952 """Update progress display during ZIP file creation.
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)
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")
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")
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")
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
1015 if pipeline_error:
1016 self._printer.error(str(pipeline_error))
1017 self._logger.error(str(pipeline_error))
1018 raise pipeline_error
1020 self._printer.success("Build pipeline finished")
1021 self._logger.info("Build pipeline finished")
1023 # ////////////////////////////////////////////////
1024 # PRIVATE HELPER METHODS
1025 # ////////////////////////////////////////////////
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 )
1035 def _zip_progress_callback(self, filename: str, progress: int) -> None:
1036 """
1037 Progress callback for ZIP archive creation.
1039 Logs progress at 10% intervals to reduce log verbosity.
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}")