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

179 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-01 00:22 +0000

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

2# PYTHON_API - Python API interface for EzCompiler 

3# Project: ezcompiler 

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

5 

6""" 

7Python API interface - High-level Python API for EzCompiler. 

8 

9This module provides the EzCompiler class that orchestrates project compilation, 

10version generation, setup file creation, artifact zipping, and repository upload 

11using the service layer. 

12 

13Interfaces layer can use all log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL). 

14""" 

15 

16from __future__ import annotations 

17 

18# /////////////////////////////////////////////////////////////// 

19# IMPORTS 

20# /////////////////////////////////////////////////////////////// 

21# Standard library imports 

22from collections.abc import Callable 

23from pathlib import Path 

24from typing import TYPE_CHECKING, Any, Literal 

25 

26if TYPE_CHECKING: 26 ↛ 27line 26 didn't jump to line 27 because the condition on line 26 was never true

27 import logging 

28 

29 from ezplog.handlers.wizard.dynamic import StageConfig 

30 from ezplog.lib_mode import _LazyPrinter 

31 

32# Third-party imports 

33from ezplog.lib_mode import get_logger, get_printer 

34 

35# Local imports 

36from ..services import ( 

37 CompilerService, 

38 PipelineService, 

39 TemplateService, 

40 UploaderService, 

41) 

42from ..shared import CompilationResult, CompilerConfig 

43from ..shared.exceptions import ( 

44 CompilationError, 

45 ConfigurationError, 

46 TemplateError, 

47 UploadError, 

48 VersionError, 

49 ZipError, 

50) 

51 

52# /////////////////////////////////////////////////////////////// 

53# CLASSES 

54# /////////////////////////////////////////////////////////////// 

55 

56 

57class EzCompiler: 

58 """ 

59 Main orchestration class for project compilation and distribution. 

60 

61 Coordinates project compilation using modular compilers, version file 

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

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

64 

65 Attributes: 

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

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

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

69 

70 Example: 

71 >>> config = CompilerConfig(...) 

72 >>> compiler = EzCompiler(config) 

73 >>> compiler.compile_project() 

74 >>> compiler.zip_compiled_project() 

75 >>> compiler.upload_to_repo("disk", "releases") 

76 """ 

77 

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

79 # INITIALIZATION 

80 # //////////////////////////////////////////////// 

81 

82 def __init__( 

83 self, 

84 config: CompilerConfig | None = None, 

85 compiler_service_factory: ( 

86 Callable[[CompilerConfig], CompilerService] | None 

87 ) = None, 

88 template_service: TemplateService | None = None, 

89 uploader_service: UploaderService | None = None, 

90 pipeline_service: PipelineService | None = None, 

91 ) -> None: 

92 """ 

93 Initialize the EzCompiler orchestrator. 

94 

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

96 passive proxies that produce no output until the host application 

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

98 application-level concern. 

99 

100 Args: 

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

102 compiler_service_factory: Optional factory for CompilerService (for testing) 

103 template_service: Optional TemplateService instance (for testing) 

104 uploader_service: Optional UploaderService instance (for testing) 

105 pipeline_service: Optional PipelineService instance (for testing) 

106 """ 

107 # Configuration management 

108 self._config = config 

109 

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

111 self._printer: _LazyPrinter = get_printer() 

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

113 

114 # Service instances 

115 self._compiler_service_factory = compiler_service_factory or CompilerService 

116 self._compiler_service: CompilerService | None = None 

117 self._template_service = template_service or TemplateService() 

118 self._uploader_service = uploader_service or UploaderService() 

119 self._pipeline_service = pipeline_service or PipelineService() 

120 

121 # Compilation state 

122 self._compilation_result: CompilationResult | None = None 

123 

124 # //////////////////////////////////////////////// 

125 # LOGGING ACCESSOR PROPERTIES 

126 # //////////////////////////////////////////////// 

127 

128 @property 

129 def printer(self) -> _LazyPrinter: 

130 """ 

131 Get the console printer proxy. 

132 

133 Returns: 

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

135 """ 

136 return self._printer 

137 

138 @property 

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

140 """ 

141 Get the stdlib logger. 

142 

143 Returns: 

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

145 """ 

146 return self._logger 

147 

148 @property 

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

150 """ 

151 Get the current compiler configuration. 

152 

153 Returns: 

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

155 """ 

156 return self._config 

157 

158 # //////////////////////////////////////////////// 

159 # PROJECT INITIALIZATION 

160 # //////////////////////////////////////////////// 

161 

162 def init_project( 

163 self, 

164 version: str, 

165 project_name: str, 

166 main_file: str, 

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

168 output_folder: Path | str, 

169 **kwargs: Any, 

170 ) -> None: 

171 """ 

172 Initialize project configuration. 

173 

174 Creates a CompilerConfig from provided parameters. This is a 

175 convenience method for backward compatibility; can also set 

176 config directly. 

177 

178 Args: 

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

180 project_name: Project name 

181 main_file: Path to main Python file 

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

183 output_folder: Output directory path 

184 **kwargs: Additional config options 

185 

186 Raises: 

187 ConfigurationError: If configuration is invalid 

188 

189 Example: 

190 >>> compiler = EzCompiler() 

191 >>> compiler.init_project( 

192 ... version="1.0.0", 

193 ... project_name="MyApp", 

194 ... main_file="main.py", 

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

196 ... output_folder="dist" 

197 ... ) 

198 """ 

199 try: 

200 # Create configuration from parameters 

201 config_dict: dict[str, Any] = { 

202 "version": version, 

203 "project_name": project_name, 

204 "main_file": main_file, 

205 "include_files": include_files, 

206 "output_folder": str(output_folder), 

207 **kwargs, 

208 } 

209 

210 # Update configuration 

211 self._config = CompilerConfig(**config_dict) 

212 

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

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

215 

216 except ConfigurationError: 

217 raise 

218 except Exception as e: 

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

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

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

222 

223 # //////////////////////////////////////////////// 

224 # VERSION AND SETUP GENERATION 

225 # //////////////////////////////////////////////// 

226 

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

228 """ 

229 Generate version information file. 

230 

231 Uses the configured version information to generate a version file 

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

233 

234 Args: 

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

236 

237 Raises: 

238 ConfigurationError: If project not initialized 

239 

240 Note: 

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

242 """ 

243 try: 

244 if not self._config: 

245 raise ConfigurationError( 

246 "Project not initialized. Call init_project() first." 

247 ) 

248 

249 # Generate using TemplateService 

250 config_dict = self._config.to_dict() 

251 version_file_path = Path(name) 

252 self._template_service.generate_version_file(config_dict, version_file_path) 

253 

254 self._printer.success("Version file generated successfully") 

255 self._logger.info("Version file generated successfully") 

256 

257 except (ConfigurationError, VersionError, TemplateError): 

258 raise 

259 except Exception as e: 

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

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

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

263 

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

265 """ 

266 Generate setup.py file from template. 

267 

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

269 for backward compatibility. 

270 

271 Args: 

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

273 

274 Raises: 

275 ConfigurationError: If project not initialized 

276 

277 Note: 

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

279 """ 

280 try: 

281 if not self._config: 

282 raise ConfigurationError( 

283 "Project not initialized. Call init_project() first." 

284 ) 

285 

286 # Generate using TemplateService 

287 config_dict = self._config.to_dict() 

288 output_path = Path(file_path) 

289 self._template_service.generate_setup_file( 

290 config_dict, output_path=output_path 

291 ) 

292 

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

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

295 

296 except (ConfigurationError, TemplateError): 

297 raise 

298 except Exception as e: 

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

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

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

302 

303 # //////////////////////////////////////////////// 

304 # COMPILATION METHODS 

305 # //////////////////////////////////////////////// 

306 

307 def compile_project( 

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

309 ) -> None: 

310 """ 

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

312 

313 Validates configuration, selects compiler if not specified, and 

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

315 

316 Args: 

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

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

319 - "Cx_Freeze": Creates directory with dependencies 

320 - "PyInstaller": Creates single executable 

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

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

323 

324 Raises: 

325 ConfigurationError: If project not initialized 

326 CompilationError: If compilation fails 

327 

328 Example: 

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

330 """ 

331 try: 

332 if not self._config: 

333 raise ConfigurationError( 

334 "Project not initialized. Call init_project() first." 

335 ) 

336 

337 # Create compiler service and compile 

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

339 self._compilation_result = self._compiler_service.compile( 

340 console=console, 

341 compiler=compiler, # type: ignore[arg-type] 

342 ) 

343 

344 self._printer.success("Project compiled successfully") 

345 self._logger.info("Project compiled successfully") 

346 

347 except (ConfigurationError, CompilationError): 

348 raise 

349 except Exception as e: 

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

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

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

353 

354 def zip_compiled_project(self) -> None: 

355 """ 

356 Create ZIP archive of compiled project. 

357 

358 Archives the compiled output if needed. Cx_Freeze output is 

359 zipped; PyInstaller single-file output is not. 

360 

361 Raises: 

362 ConfigurationError: If project not initialized 

363 

364 Note: 

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

366 """ 

367 try: 

368 if not self._config: 

369 raise ConfigurationError( 

370 "Project not initialized. Call init_project() first." 

371 ) 

372 

373 # Check if ZIP is needed from compilation result 

374 zip_needed = ( 

375 self._compilation_result.zip_needed 

376 if self._compilation_result 

377 else self._config.zip_needed 

378 ) 

379 

380 if not zip_needed: 

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

382 return 

383 

384 # Create ZIP archive via CompilerService 

385 if self._compiler_service is None: 

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

387 

388 self._pipeline_service.zip_artifact( 

389 config=self._config, 

390 compiler_service=self._compiler_service, 

391 compilation_result=self._compilation_result, 

392 progress_callback=self._zip_progress_callback, 

393 ) 

394 

395 self._printer.success("ZIP archive created successfully") 

396 self._logger.info("ZIP archive created successfully") 

397 

398 except (ConfigurationError, ZipError): 

399 raise 

400 except Exception as e: 

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

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

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

404 

405 # //////////////////////////////////////////////// 

406 # UPLOAD METHODS 

407 # //////////////////////////////////////////////// 

408 

409 def upload_to_repo( 

410 self, 

411 structure: Literal["server", "disk"], 

412 repo_path: Path | str, 

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

414 ) -> None: 

415 """ 

416 Upload compiled project to repository. 

417 

418 Uploads the compiled artifact (ZIP or directory) to the specified 

419 repository using the appropriate uploader (disk or server). 

420 

421 Args: 

422 structure: Upload type - "server" for HTTP/HTTPS, "disk" for local 

423 repo_path: Repository path or server URL 

424 upload_config: Additional uploader configuration options 

425 

426 Raises: 

427 ConfigurationError: If project not initialized 

428 EzCompilerError: If upload structure is invalid 

429 

430 Example: 

431 >>> compiler.upload_to_repo("disk", "releases/") 

432 >>> compiler.upload_to_repo("server", "https://example.com/upload") 

433 """ 

434 try: 

435 if not self._config: 

436 raise ConfigurationError( 

437 "Project not initialized. Call init_project() first." 

438 ) 

439 

440 # Perform upload using UploaderService 

441 self._pipeline_service.upload_artifact( 

442 config=self._config, 

443 structure=structure, 

444 destination=str(repo_path), 

445 compilation_result=self._compilation_result, 

446 upload_config=upload_config, 

447 ) 

448 

449 self._printer.success(f"Project uploaded successfully to {structure}") 

450 self._logger.info(f"Project uploaded successfully to {structure}") 

451 

452 except (ConfigurationError, UploadError): 

453 raise 

454 except Exception as e: 

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

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

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

458 

459 def run_pipeline( 

460 self, 

461 console: bool = True, 

462 compiler: str | None = None, 

463 skip_zip: bool = False, 

464 skip_upload: bool = False, 

465 upload_structure: Literal["server", "disk"] | None = None, 

466 upload_destination: str | None = None, 

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

468 ) -> None: 

469 """ 

470 Run the full build pipeline with visual progress tracking. 

471 

472 Executes version generation, compilation, optional ZIP creation, 

473 and optional upload in sequence with a DynamicLayeredProgress display. 

474 

475 Args: 

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

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

478 skip_zip: Skip ZIP archive creation 

479 skip_upload: Skip upload step 

480 upload_structure: Upload type ("server" or "disk") 

481 upload_destination: Upload destination path or URL 

482 upload_config: Additional uploader configuration 

483 

484 Raises: 

485 ConfigurationError: If project not initialized 

486 CompilationError: If compilation fails 

487 VersionError: If version file generation fails 

488 ZipError: If ZIP creation fails 

489 UploadError: If upload fails 

490 

491 Example: 

492 >>> compiler = EzCompiler(config) 

493 >>> compiler.run_pipeline(console=False, skip_upload=True) 

494 """ 

495 if not self._config: 

496 raise ConfigurationError( 

497 "Project not initialized. Call init_project() first." 

498 ) 

499 

500 # Determine which optional stages to include 

501 should_zip = not skip_zip and self._config.zip_needed 

502 should_upload = not skip_upload and ( 

503 upload_structure is not None or self._config.repo_needed 

504 ) 

505 

506 # Build stages 

507 stages: list[StageConfig] = PipelineService.build_stages( # type: ignore[assignment] 

508 self._config, should_zip=should_zip, should_upload=should_upload 

509 ) 

510 

511 current_phase = "version" 

512 pipeline_error: Exception | None = None 

513 

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

515 try: 

516 # Version file 

517 current_phase = "version" 

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

519 config_dict = self._config.to_dict() 

520 version_file_path = Path(self._config.version_filename) 

521 self._template_service.generate_version_file( 

522 config_dict, version_file_path 

523 ) 

524 self._logger.info("Version file generated successfully") 

525 dlp.complete_layer("version") 

526 

527 # Compilation 

528 current_phase = "compile" 

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

530 self._compiler_service, self._compilation_result = ( 

531 self._pipeline_service.compile_project( 

532 config=self._config, 

533 console=console, 

534 compiler=compiler, 

535 ) 

536 ) 

537 self._logger.info("Project compiled successfully") 

538 dlp.complete_layer("compile") 

539 

540 # ZIP 

541 zip_needed = ( 

542 self._compilation_result.zip_needed 

543 if self._compilation_result 

544 else self._config.zip_needed 

545 ) 

546 if should_zip: 

547 if zip_needed: 

548 current_phase = "zip" 

549 

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

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

552 

553 Args: 

554 filename: The name of the file being compressed. 

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

556 """ 

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

558 

559 self._pipeline_service.zip_artifact( 

560 config=self._config, 

561 compiler_service=self._compiler_service, 

562 compilation_result=self._compilation_result, 

563 progress_callback=_zip_cb, 

564 ) 

565 self._logger.info("ZIP archive created successfully") 

566 dlp.complete_layer("zip") 

567 else: 

568 # Stage was added but not needed at runtime 

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

570 dlp.complete_layer("zip") 

571 

572 # Upload 

573 if should_upload: 

574 current_phase = "upload" 

575 structure = upload_structure or self._config.upload_structure 

576 destination = upload_destination or ( 

577 self._config.server_url 

578 if structure == "server" 

579 else self._config.repo_path 

580 ) 

581 dlp.update_layer("upload", 0, f"Uploading to {destination}...") 

582 self._pipeline_service.upload_artifact( 

583 config=self._config, 

584 structure=structure, 

585 destination=str(destination), 

586 compilation_result=self._compilation_result, 

587 upload_config=upload_config, 

588 ) 

589 self._logger.info(f"Upload completed ({structure})") 

590 dlp.complete_layer("upload") 

591 

592 except ( 

593 ConfigurationError, 

594 CompilationError, 

595 TemplateError, 

596 VersionError, 

597 UploadError, 

598 ZipError, 

599 ) as e: 

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

601 dlp.emergency_stop(str(e)) 

602 pipeline_error = e 

603 except Exception as e: 

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

605 dlp.emergency_stop(str(e)) 

606 pipeline_error = e 

607 

608 if pipeline_error: 

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

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

611 raise pipeline_error 

612 

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

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

615 

616 # //////////////////////////////////////////////// 

617 # PRIVATE HELPER METHODS 

618 # //////////////////////////////////////////////// 

619 

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

621 """ 

622 Progress callback for ZIP archive creation. 

623 

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

625 

626 Args: 

627 filename: Current file being zipped 

628 progress: Progress percentage (0-100) 

629 """ 

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

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

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