Coverage for src/ezcompiler/services/pipeline_service.py: 95.24%
111 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# PIPELINE_SERVICE - Build pipeline orchestration helpers
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""
7Pipeline service - Compilation, ZIP and upload orchestration.
9This service extracts the compile->zip->upload workflow from interfaces
10so the orchestration logic remains reusable and testable.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19import dataclasses as _dc
20import logging
21import shutil
22from collections.abc import Callable
23from pathlib import Path
24from typing import Any, Literal, cast
26# Local imports
27from ..shared import CompilationResult, CompilerConfig
28from ..shared._installer_config import InstallerConfig
29from ..shared.exceptions import ReleaseError
30from .compiler_service import CompilerService
31from .installer_service import InstallerService
32from .release_service import ReleaseService
33from .tuf_service import TufService
34from .uploader_service import UploaderService
36# ///////////////////////////////////////////////////////////////
37# CONSTANTS
38# ///////////////////////////////////////////////////////////////
40_logger = logging.getLogger(__name__)
42# Frame fields: stay meaningful even in file mode (iss_path is provided).
43_FRAME_FIELDS = frozenset({"enabled", "iss_path", "output_dir", "iscc_path"})
45# ///////////////////////////////////////////////////////////////
46# FUNCTIONS
47# ///////////////////////////////////////////////////////////////
50def _warn_ignored_options(installer: InstallerConfig) -> None:
51 """Warn about typed fields left meaningless when ``iss_path`` is set.
53 In file mode, ISCC compiles the user's own ``.iss`` file directly: every
54 typed field besides the frame ones (``enabled``/``iss_path``/
55 ``output_dir``/``iscc_path``) is silently ignored. Only fields the user
56 actually set (differing from the dataclass default) are named, so an
57 untouched default is not flagged as a false positive.
58 """
59 defaults = InstallerConfig()
60 changed = [
61 f.name
62 for f in _dc.fields(installer)
63 if f.name not in _FRAME_FIELDS
64 and getattr(installer, f.name) != getattr(defaults, f.name)
65 ]
66 if changed:
67 _logger.warning(
68 "installer.iss_path is set: the following options are ignored "
69 "because ISCC compiles that file directly instead of one "
70 "generated from config: %s. Run `generate iss --force` to "
71 "regenerate the script from config and pick them up.",
72 ", ".join(sorted(changed)),
73 )
76# ///////////////////////////////////////////////////////////////
77# CLASSES
78# ///////////////////////////////////////////////////////////////
81class PipelineService:
82 """Service that coordinates compile, zip and upload stages."""
84 def __init__(
85 self,
86 compiler_service_factory: (
87 Callable[[CompilerConfig], CompilerService] | None
88 ) = None,
89 ) -> None:
90 """Initialise the pipeline service.
92 Args:
93 compiler_service_factory: Optional factory to create a CompilerService
94 from a CompilerConfig. Defaults to ``CompilerService`` constructor.
95 Inject a custom factory in tests to avoid triggering real compilation.
96 """
97 self._compiler_service_factory: Callable[[CompilerConfig], CompilerService] = (
98 compiler_service_factory or CompilerService
99 )
101 def compile_project(
102 self,
103 config: CompilerConfig,
104 console: bool = True,
105 compiler: str | None = None,
106 ) -> tuple[CompilerService, CompilationResult]:
107 """Compile a project and return service + result."""
108 compiler_service = self._compiler_service_factory(config)
109 compilation_result = compiler_service.compile(
110 console=console,
111 compiler=cast(
112 Literal["Cx_Freeze", "PyInstaller", "Nuitka"] | None,
113 compiler,
114 ),
115 )
116 return compiler_service, compilation_result
118 def reuse_build(
119 self,
120 config: CompilerConfig,
121 compiler: str | None = None,
122 ) -> tuple[CompilerService, CompilationResult]:
123 """Reuse an existing build (skip compilation) and return service + result."""
124 compiler_service = self._compiler_service_factory(config)
125 compilation_result = compiler_service.use_existing_build(
126 compiler=cast(
127 Literal["Cx_Freeze", "PyInstaller", "Nuitka"] | None,
128 compiler,
129 ),
130 )
131 return compiler_service, compilation_result
133 def zip_artifact(
134 self,
135 config: CompilerConfig,
136 compiler_service: CompilerService,
137 compilation_result: CompilationResult | None,
138 progress_callback: Callable[[str, int], None] | None = None,
139 ) -> bool:
140 """Create ZIP artifact when required and return True when created."""
141 zip_needed = compilation_result.zip_needed if compilation_result else True
142 if not zip_needed:
143 return False
145 compiler_service._zip_artifact(
146 output_path=str(config.zip_file_path),
147 progress_callback=progress_callback,
148 )
149 return True
151 @staticmethod
152 def build_stages(
153 config: CompilerConfig,
154 should_zip: bool = False,
155 should_upload: bool = False,
156 should_release: bool = False,
157 should_installer: bool = False,
158 should_build: bool = True,
159 ) -> list[dict[str, Any]]:
160 """
161 Build the stage list for dynamic_layered_progress.
163 Args:
164 config: Compiler configuration (used for display labels)
165 should_zip: Whether a ZIP stage should be included
166 should_upload: Whether an upload stage should be included
167 should_release: Whether a TUF release stage should be included
168 should_installer: Whether an installer stage should be included
169 should_build: Whether version + compile stages run; when False a
170 single "reuse existing build" stage replaces them
172 Returns:
173 list[dict]: Stage configuration list ready for dynamic_layered_progress
174 """
175 stages: list[dict[str, Any]] = [
176 {
177 "name": "main",
178 "type": "main",
179 "description": f"Building {config.project_name} v{config.version}",
180 },
181 ]
182 if should_build:
183 stages += [
184 {
185 "name": "version",
186 "type": "spinner",
187 "description": "Generating version file",
188 },
189 {
190 "name": "compile",
191 "type": "spinner",
192 "description": f"Compiling with {config.compiler}",
193 },
194 ]
195 else:
196 stages.append(
197 {
198 "name": "compile",
199 "type": "spinner",
200 "description": f"Reusing existing build in {config.output_folder}",
201 }
202 )
203 if should_zip:
204 stages.append(
205 {
206 "name": "zip",
207 "type": "progress",
208 "description": "Creating ZIP archive",
209 "total": 100,
210 }
211 )
212 if should_installer:
213 stages.append(
214 {
215 "name": "installer",
216 "type": "spinner",
217 "description": "Building installer",
218 }
219 )
220 if should_release:
221 stages.append(
222 {
223 "name": "release",
224 "type": "spinner",
225 "description": "Building TUF release",
226 }
227 )
228 if should_upload:
229 stages.append(
230 {
231 "name": "upload",
232 "type": "spinner",
233 "description": "Uploading artifacts",
234 }
235 )
236 return stages
238 def upload_artifact(
239 self,
240 config: CompilerConfig,
241 structure: str,
242 destination: str,
243 compilation_result: CompilationResult | None,
244 upload_config: dict[str, Any] | None = None,
245 ) -> None:
246 """Upload project artifact to a destination."""
247 zip_needed = compilation_result.zip_needed if compilation_result else True
248 source_file = (
249 str(config.zip_file_path) if zip_needed else str(config.output_folder)
250 )
252 UploaderService.upload(
253 source_path=Path(source_file),
254 upload_type=cast(Literal["disk", "server"], structure),
255 destination=destination,
256 upload_config=upload_config,
257 )
259 @staticmethod
260 def resolve_publishable_assets(
261 config: CompilerConfig,
262 ) -> tuple[Path | None, Path | None]:
263 """Resolve the publishable artifacts, with the same contract everywhere.
265 Both publication paths (platform and files) rely on this resolution:
266 an enabled but missing installer fails whatever the destination,
267 instead of being published silently to disk/server/r2.
269 Args:
270 config: Configuration (supplies installer, zip_file_path, paths).
272 Returns:
273 tuple: ``(installer, zip)``, each element being ``None`` when it
274 is not expected (installer disabled) or absent (single-file
275 compilation without a zip).
277 Raises:
278 ReleaseError: If the enabled installer is missing, or if no
279 publishable artifact exists.
280 """
281 installer_exe = PipelineService._installer_exe_path(config)
282 if installer_exe is not None and not installer_exe.is_file():
283 # Installer enabled but missing: failed installer build or a
284 # misaligned version. Publishing without it would be an
285 # incomplete release, irreversible on the platform side.
286 raise ReleaseError(
287 f"Installer not found: {installer_exe} "
288 "(installer.enabled = true). "
289 "Run the build pipeline again before publishing."
290 )
292 zip_path = Path(config.zip_file_path)
293 zip_asset = zip_path if zip_path.is_file() else None
295 if installer_exe is None and zip_asset is None:
296 raise ReleaseError(
297 "No publishable artifact in "
298 f"{config.output_folder.parent}. "
299 "Run `ezcompiler compile` first."
300 )
301 return installer_exe, zip_asset
303 @staticmethod
304 def assemble_release_dir(config: CompilerConfig) -> Path:
305 """Assemble the release directory holding the zip and the installer.
307 Layout (cleaned on every run)::
309 release/
310 ├── <App>.zip (if the file exists)
311 └── <App>-<version>-setup.exe (if installer.enabled=True)
313 The TUF tree (metadata/ + targets/) stays in tufup_repo_dir and is
314 pushed straight to the update backend by upload().
316 Artifact resolution is shared by both publication paths (see
317 ``resolve_publishable_assets``): an enabled but missing installer
318 fails here too.
320 Args:
321 config: Configuration (supplies output_folder and zip_file_path).
323 Returns:
324 Path: The assembled ``release/`` directory.
325 """
326 installer_exe, zip_path = PipelineService.resolve_publishable_assets(config)
328 release_dir = config.output_folder.parent / "release"
329 if release_dir.exists(): 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true
330 shutil.rmtree(release_dir)
331 release_dir.mkdir(parents=True)
333 if zip_path is not None: 333 ↛ 335line 333 didn't jump to line 335 because the condition on line 333 was always true
334 shutil.copy2(zip_path, release_dir / zip_path.name)
335 if installer_exe is not None: 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true
336 shutil.copy2(installer_exe, release_dir / installer_exe.name)
338 return release_dir
340 @staticmethod
341 def stage_versioned_assets(config: CompilerConfig) -> list[Path]:
342 """Resolve the publishable artifacts and version the zip name.
344 ``config.zip_file_path`` is ``dist/<Project>.zip`` — without a version.
345 Publishing that name as is would produce assets indistinguishable
346 between two releases, so the zip is copied to
347 ``<Project>-<version>.zip``. The copy is idempotent: running the
348 method again changes nothing.
350 A project without an installer (``installer.enabled = False``) is a
351 legitimate case: the list then holds the zip alone. An enabled but
352 missing installer, however, is an error. The zip stays optional: a
353 single-file compilation does not produce one.
355 Versioned copies accumulate in ``dist/`` (one per version); they are
356 never cleaned up automatically.
358 Args:
359 config: Configuration (supplies project_name, version, paths).
361 Returns:
362 list[Path]: The existing assets — installer first, then zip.
364 Raises:
365 ReleaseError: If the enabled installer is missing, or if no
366 publishable artifact exists.
367 """
368 installer_exe, zip_path = PipelineService.resolve_publishable_assets(config)
370 assets: list[Path] = []
371 if installer_exe is not None:
372 assets.append(installer_exe)
373 if zip_path is not None: 373 ↛ 381line 373 didn't jump to line 381 because the condition on line 373 was always true
374 versioned = zip_path.with_name(
375 f"{config.project_name}-{config.version}{zip_path.suffix}"
376 )
377 if versioned != zip_path: 377 ↛ 379line 377 didn't jump to line 379 because the condition on line 377 was always true
378 shutil.copy2(zip_path, versioned)
379 assets.append(versioned)
381 return assets
383 @staticmethod
384 def _installer_exe_path(config: CompilerConfig) -> Path | None:
385 """Expected installer path, or None when the installer is disabled."""
386 if not config.installer.enabled:
387 return None
388 installer_dir = config.installer.output_dir or (
389 config.output_folder.parent / "installer"
390 )
391 return installer_dir / f"{config.project_name}-{config.version}-setup.exe"
393 @staticmethod
394 def release_artifact(
395 config: CompilerConfig,
396 compilation_result: CompilationResult | None, # noqa: ARG004
397 *,
398 required: bool = False,
399 ) -> Path:
400 """Build the local TUF repo from output_folder. Never publishes."""
401 repo_dir = TufService.repo_dir(config)
402 keys_dir = TufService.keys_dir(config)
403 return ReleaseService.release_and_publish(
404 bundle_dir=config.output_folder,
405 app_name=config.project_name,
406 version=config.version,
407 repo_dir=repo_dir,
408 publish=False,
409 releaser_config={
410 "keys_dir": keys_dir,
411 "expiration_days": config.tuf_expiration_days,
412 },
413 required=required,
414 )
416 @staticmethod
417 def build_installer(
418 config: CompilerConfig,
419 compilation_result: CompilationResult | None, # noqa: ARG004
420 ) -> Path | None:
421 """Build the Inno Setup installer when installer.enabled=True."""
422 if not config.installer.enabled:
423 return None
425 output_dir = config.installer.output_dir or (
426 config.output_folder.parent / "installer"
427 )
429 if config.installer.iss_path is not None:
430 _warn_ignored_options(config.installer)
432 return InstallerService.build_installer(
433 bundle_dir=config.output_folder,
434 app_name=config.project_name,
435 version=config.version,
436 output_dir=output_dir,
437 installer_config=config.installer,
438 company_name=config.company_name,
439 icon=config.icon,
440 main_file=config.main_file,
441 )