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

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

2# PIPELINE_SERVICE - Build pipeline orchestration helpers 

3# Project: ezcompiler 

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

5 

6""" 

7Pipeline service - Compilation, ZIP and upload orchestration. 

8 

9This service extracts the compile->zip->upload workflow from interfaces 

10so the orchestration logic remains reusable and testable. 

11""" 

12 

13from __future__ import annotations 

14 

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 

25 

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 

35 

36# /////////////////////////////////////////////////////////////// 

37# CONSTANTS 

38# /////////////////////////////////////////////////////////////// 

39 

40_logger = logging.getLogger(__name__) 

41 

42# Frame fields: stay meaningful even in file mode (iss_path is provided). 

43_FRAME_FIELDS = frozenset({"enabled", "iss_path", "output_dir", "iscc_path"}) 

44 

45# /////////////////////////////////////////////////////////////// 

46# FUNCTIONS 

47# /////////////////////////////////////////////////////////////// 

48 

49 

50def _warn_ignored_options(installer: InstallerConfig) -> None: 

51 """Warn about typed fields left meaningless when ``iss_path`` is set. 

52 

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 ) 

74 

75 

76# /////////////////////////////////////////////////////////////// 

77# CLASSES 

78# /////////////////////////////////////////////////////////////// 

79 

80 

81class PipelineService: 

82 """Service that coordinates compile, zip and upload stages.""" 

83 

84 def __init__( 

85 self, 

86 compiler_service_factory: ( 

87 Callable[[CompilerConfig], CompilerService] | None 

88 ) = None, 

89 ) -> None: 

90 """Initialise the pipeline service. 

91 

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 ) 

100 

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 

117 

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 

132 

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 

144 

145 compiler_service._zip_artifact( 

146 output_path=str(config.zip_file_path), 

147 progress_callback=progress_callback, 

148 ) 

149 return True 

150 

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. 

162 

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 

171 

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 

237 

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 ) 

251 

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 ) 

258 

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. 

264 

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. 

268 

269 Args: 

270 config: Configuration (supplies installer, zip_file_path, paths). 

271 

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). 

276 

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 ) 

291 

292 zip_path = Path(config.zip_file_path) 

293 zip_asset = zip_path if zip_path.is_file() else None 

294 

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 

302 

303 @staticmethod 

304 def assemble_release_dir(config: CompilerConfig) -> Path: 

305 """Assemble the release directory holding the zip and the installer. 

306 

307 Layout (cleaned on every run):: 

308 

309 release/ 

310 ├── <App>.zip (if the file exists) 

311 └── <App>-<version>-setup.exe (if installer.enabled=True) 

312 

313 The TUF tree (metadata/ + targets/) stays in tufup_repo_dir and is 

314 pushed straight to the update backend by upload(). 

315 

316 Artifact resolution is shared by both publication paths (see 

317 ``resolve_publishable_assets``): an enabled but missing installer 

318 fails here too. 

319 

320 Args: 

321 config: Configuration (supplies output_folder and zip_file_path). 

322 

323 Returns: 

324 Path: The assembled ``release/`` directory. 

325 """ 

326 installer_exe, zip_path = PipelineService.resolve_publishable_assets(config) 

327 

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) 

332 

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) 

337 

338 return release_dir 

339 

340 @staticmethod 

341 def stage_versioned_assets(config: CompilerConfig) -> list[Path]: 

342 """Resolve the publishable artifacts and version the zip name. 

343 

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. 

349 

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. 

354 

355 Versioned copies accumulate in ``dist/`` (one per version); they are 

356 never cleaned up automatically. 

357 

358 Args: 

359 config: Configuration (supplies project_name, version, paths). 

360 

361 Returns: 

362 list[Path]: The existing assets — installer first, then zip. 

363 

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) 

369 

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) 

380 

381 return assets 

382 

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" 

392 

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 ) 

415 

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 

424 

425 output_dir = config.installer.output_dir or ( 

426 config.output_folder.parent / "installer" 

427 ) 

428 

429 if config.installer.iss_path is not None: 

430 _warn_ignored_options(config.installer) 

431 

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 )