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

179 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-03 15:46 +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, cast 

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=cast( 

342 Literal["Cx_Freeze", "PyInstaller", "Nuitka", "auto"] | None, 

343 compiler, 

344 ), 

345 ) 

346 

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

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

349 

350 except (ConfigurationError, CompilationError): 

351 raise 

352 except Exception as e: 

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

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

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

356 

357 def zip_compiled_project(self) -> None: 

358 """ 

359 Create ZIP archive of compiled project. 

360 

361 Archives the compiled output if needed. Cx_Freeze output is 

362 zipped; PyInstaller single-file output is not. 

363 

364 Raises: 

365 ConfigurationError: If project not initialized 

366 

367 Note: 

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

369 """ 

370 try: 

371 if not self._config: 

372 raise ConfigurationError( 

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

374 ) 

375 

376 # Check if ZIP is needed from compilation result 

377 zip_needed = ( 

378 self._compilation_result.zip_needed 

379 if self._compilation_result 

380 else self._config.zip_needed 

381 ) 

382 

383 if not zip_needed: 

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

385 return 

386 

387 # Create ZIP archive via CompilerService 

388 if self._compiler_service is None: 

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

390 

391 self._pipeline_service.zip_artifact( 

392 config=self._config, 

393 compiler_service=self._compiler_service, 

394 compilation_result=self._compilation_result, 

395 progress_callback=self._zip_progress_callback, 

396 ) 

397 

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

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

400 

401 except (ConfigurationError, ZipError): 

402 raise 

403 except Exception as e: 

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

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

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

407 

408 # //////////////////////////////////////////////// 

409 # UPLOAD METHODS 

410 # //////////////////////////////////////////////// 

411 

412 def upload_to_repo( 

413 self, 

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

415 repo_path: Path | str, 

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

417 ) -> None: 

418 """ 

419 Upload compiled project to repository. 

420 

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

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

423 

424 Args: 

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

426 repo_path: Repository path or server URL 

427 upload_config: Additional uploader configuration options 

428 

429 Raises: 

430 ConfigurationError: If project not initialized 

431 EzCompilerError: If upload structure is invalid 

432 

433 Example: 

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

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

436 """ 

437 try: 

438 if not self._config: 

439 raise ConfigurationError( 

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

441 ) 

442 

443 # Perform upload using UploaderService 

444 self._pipeline_service.upload_artifact( 

445 config=self._config, 

446 structure=structure, 

447 destination=str(repo_path), 

448 compilation_result=self._compilation_result, 

449 upload_config=upload_config, 

450 ) 

451 

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

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

454 

455 except (ConfigurationError, UploadError): 

456 raise 

457 except Exception as e: 

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

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

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

461 

462 def run_pipeline( 

463 self, 

464 console: bool = True, 

465 compiler: str | None = None, 

466 skip_zip: bool = False, 

467 skip_upload: bool = False, 

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

469 upload_destination: str | None = None, 

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

471 ) -> None: 

472 """ 

473 Run the full build pipeline with visual progress tracking. 

474 

475 Executes version generation, compilation, optional ZIP creation, 

476 and optional upload in sequence with a DynamicLayeredProgress display. 

477 

478 Args: 

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

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

481 skip_zip: Skip ZIP archive creation 

482 skip_upload: Skip upload step 

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

484 upload_destination: Upload destination path or URL 

485 upload_config: Additional uploader configuration 

486 

487 Raises: 

488 ConfigurationError: If project not initialized 

489 CompilationError: If compilation fails 

490 VersionError: If version file generation fails 

491 ZipError: If ZIP creation fails 

492 UploadError: If upload fails 

493 

494 Example: 

495 >>> compiler = EzCompiler(config) 

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

497 """ 

498 if not self._config: 

499 raise ConfigurationError( 

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

501 ) 

502 

503 # Determine which optional stages to include 

504 should_zip = not skip_zip and self._config.zip_needed 

505 should_upload = not skip_upload and ( 

506 upload_structure is not None or self._config.repo_needed 

507 ) 

508 

509 # Build stages 

510 stages: list[StageConfig] = cast( 

511 list["StageConfig"], 

512 PipelineService.build_stages( 

513 self._config, should_zip=should_zip, should_upload=should_upload 

514 ), 

515 ) 

516 

517 current_phase = "version" 

518 pipeline_error: Exception | None = None 

519 

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

521 try: 

522 # Version file 

523 current_phase = "version" 

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

525 config_dict = self._config.to_dict() 

526 version_file_path = Path(self._config.version_filename) 

527 self._template_service.generate_version_file( 

528 config_dict, version_file_path 

529 ) 

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

531 dlp.complete_layer("version") 

532 

533 # Compilation 

534 current_phase = "compile" 

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

536 self._compiler_service, self._compilation_result = ( 

537 self._pipeline_service.compile_project( 

538 config=self._config, 

539 console=console, 

540 compiler=compiler, 

541 ) 

542 ) 

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

544 dlp.complete_layer("compile") 

545 

546 # ZIP 

547 zip_needed = ( 

548 self._compilation_result.zip_needed 

549 if self._compilation_result 

550 else self._config.zip_needed 

551 ) 

552 if should_zip: 

553 if zip_needed: 

554 current_phase = "zip" 

555 

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

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

558 

559 Args: 

560 filename: The name of the file being compressed. 

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

562 """ 

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

564 

565 self._pipeline_service.zip_artifact( 

566 config=self._config, 

567 compiler_service=self._compiler_service, 

568 compilation_result=self._compilation_result, 

569 progress_callback=_zip_cb, 

570 ) 

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

572 dlp.complete_layer("zip") 

573 else: 

574 # Stage was added but not needed at runtime 

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

576 dlp.complete_layer("zip") 

577 

578 # Upload 

579 if should_upload: 

580 current_phase = "upload" 

581 structure = upload_structure or self._config.upload_structure 

582 destination = upload_destination or ( 

583 self._config.server_url 

584 if structure == "server" 

585 else self._config.repo_path 

586 ) 

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

588 self._pipeline_service.upload_artifact( 

589 config=self._config, 

590 structure=structure, 

591 destination=str(destination), 

592 compilation_result=self._compilation_result, 

593 upload_config=upload_config, 

594 ) 

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

596 dlp.complete_layer("upload") 

597 

598 except ( 

599 ConfigurationError, 

600 CompilationError, 

601 TemplateError, 

602 VersionError, 

603 UploadError, 

604 ZipError, 

605 ) as e: 

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

607 dlp.emergency_stop(str(e)) 

608 pipeline_error = e 

609 except Exception as e: 

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

611 dlp.emergency_stop(str(e)) 

612 pipeline_error = e 

613 

614 if pipeline_error: 

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

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

617 raise pipeline_error 

618 

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

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

621 

622 # //////////////////////////////////////////////// 

623 # PRIVATE HELPER METHODS 

624 # //////////////////////////////////////////////// 

625 

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

627 """ 

628 Progress callback for ZIP archive creation. 

629 

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

631 

632 Args: 

633 filename: Current file being zipped 

634 progress: Progress percentage (0-100) 

635 """ 

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

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

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