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

181 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-27 06:49 +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 

43from ..shared.compiler_config import CompilerConfig 

44from ..shared.exceptions import ( 

45 CompilationError, 

46 ConfigurationError, 

47 TemplateError, 

48 UploadError, 

49 VersionError, 

50) 

51from ..shared.exceptions.utils.zip_exceptions import ZipError 

52 

53# /////////////////////////////////////////////////////////////// 

54# CLASSES 

55# /////////////////////////////////////////////////////////////// 

56 

57 

58class EzCompiler: 

59 """ 

60 Main orchestration class for project compilation and distribution. 

61 

62 Coordinates project compilation using modular compilers, version file 

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

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

65 

66 Attributes: 

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

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

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

70 

71 Example: 

72 >>> config = CompilerConfig(...) 

73 >>> compiler = EzCompiler(config) 

74 >>> compiler.compile_project() 

75 >>> compiler.zip_compiled_project() 

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

77 """ 

78 

79 # //////////////////////////////////////////////// 

80 # INITIALIZATION 

81 # //////////////////////////////////////////////// 

82 

83 def __init__( 

84 self, 

85 config: CompilerConfig | None = None, 

86 compiler_service_factory: ( 

87 Callable[[CompilerConfig], CompilerService] | None 

88 ) = None, 

89 template_service: TemplateService | None = None, 

90 uploader_service: UploaderService | None = None, 

91 pipeline_service: PipelineService | None = None, 

92 ) -> None: 

93 """ 

94 Initialize the EzCompiler orchestrator. 

95 

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

97 passive proxies that produce no output until the host application 

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

99 application-level concern. 

100 

101 Args: 

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

103 compiler_service_factory: Optional factory for CompilerService (for testing) 

104 template_service: Optional TemplateService instance (for testing) 

105 uploader_service: Optional UploaderService instance (for testing) 

106 pipeline_service: Optional PipelineService instance (for testing) 

107 """ 

108 # Configuration management 

109 self._config = config 

110 

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

112 self._printer: _LazyPrinter = get_printer() 

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

114 

115 # Service instances 

116 self._compiler_service_factory = compiler_service_factory or CompilerService 

117 self._compiler_service: CompilerService | None = None 

118 self._template_service = template_service or TemplateService() 

119 self._uploader_service = uploader_service or UploaderService() 

120 self._pipeline_service = pipeline_service or PipelineService() 

121 

122 # Compilation state 

123 self._compilation_result: CompilationResult | None = None 

124 

125 # //////////////////////////////////////////////// 

126 # LOGGING ACCESSOR PROPERTIES 

127 # //////////////////////////////////////////////// 

128 

129 @property 

130 def printer(self) -> _LazyPrinter: 

131 """ 

132 Get the console printer proxy. 

133 

134 Returns: 

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

136 """ 

137 return self._printer 

138 

139 @property 

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

141 """ 

142 Get the stdlib logger. 

143 

144 Returns: 

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

146 """ 

147 return self._logger 

148 

149 @property 

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

151 """ 

152 Get the current compiler configuration. 

153 

154 Returns: 

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

156 """ 

157 return self._config 

158 

159 # //////////////////////////////////////////////// 

160 # PROJECT INITIALIZATION 

161 # //////////////////////////////////////////////// 

162 

163 def init_project( 

164 self, 

165 version: str, 

166 project_name: str, 

167 main_file: str, 

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

169 output_folder: Path | str, 

170 **kwargs: Any, 

171 ) -> None: 

172 """ 

173 Initialize project configuration. 

174 

175 Creates a CompilerConfig from provided parameters. This is a 

176 convenience method for backward compatibility; can also set 

177 config directly. 

178 

179 Args: 

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

181 project_name: Project name 

182 main_file: Path to main Python file 

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

184 output_folder: Output directory path 

185 **kwargs: Additional config options 

186 

187 Raises: 

188 ConfigurationError: If configuration is invalid 

189 

190 Example: 

191 >>> compiler = EzCompiler() 

192 >>> compiler.init_project( 

193 ... version="1.0.0", 

194 ... project_name="MyApp", 

195 ... main_file="main.py", 

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

197 ... output_folder="dist" 

198 ... ) 

199 """ 

200 try: 

201 # Create configuration from parameters 

202 config_dict: dict[str, Any] = { 

203 "version": version, 

204 "project_name": project_name, 

205 "main_file": main_file, 

206 "include_files": include_files, 

207 "output_folder": str(output_folder), 

208 **kwargs, 

209 } 

210 

211 # Update configuration 

212 self._config = CompilerConfig(**config_dict) 

213 

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

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

216 

217 except ConfigurationError: 

218 raise 

219 except Exception as e: 

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

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

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

223 

224 # //////////////////////////////////////////////// 

225 # VERSION AND SETUP GENERATION 

226 # //////////////////////////////////////////////// 

227 

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

229 """ 

230 Generate version information file. 

231 

232 Uses the configured version information to generate a version file 

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

234 

235 Args: 

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

237 

238 Raises: 

239 ConfigurationError: If project not initialized 

240 

241 Note: 

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

243 """ 

244 try: 

245 if not self._config: 

246 raise ConfigurationError( 

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

248 ) 

249 

250 # Generate using TemplateService 

251 config_dict = self._config.to_dict() 

252 version_file_path = Path(name) 

253 self._template_service.generate_version_file(config_dict, version_file_path) 

254 

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

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

257 

258 except (ConfigurationError, VersionError, TemplateError): 

259 raise 

260 except Exception as e: 

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

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

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

264 

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

266 """ 

267 Generate setup.py file from template. 

268 

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

270 for backward compatibility. 

271 

272 Args: 

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

274 

275 Raises: 

276 ConfigurationError: If project not initialized 

277 

278 Note: 

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

280 """ 

281 try: 

282 if not self._config: 

283 raise ConfigurationError( 

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

285 ) 

286 

287 # Generate using TemplateService 

288 config_dict = self._config.to_dict() 

289 output_path = Path(file_path) 

290 self._template_service.generate_setup_file( 

291 config_dict, output_path=output_path 

292 ) 

293 

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

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

296 

297 except (ConfigurationError, TemplateError): 

298 raise 

299 except Exception as e: 

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

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

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

303 

304 # //////////////////////////////////////////////// 

305 # COMPILATION METHODS 

306 # //////////////////////////////////////////////// 

307 

308 def compile_project( 

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

310 ) -> None: 

311 """ 

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

313 

314 Validates configuration, selects compiler if not specified, and 

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

316 

317 Args: 

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

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

320 - "Cx_Freeze": Creates directory with dependencies 

321 - "PyInstaller": Creates single executable 

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

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

324 

325 Raises: 

326 ConfigurationError: If project not initialized 

327 CompilationError: If compilation fails 

328 

329 Example: 

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

331 """ 

332 try: 

333 if not self._config: 

334 raise ConfigurationError( 

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

336 ) 

337 

338 # Create compiler service and compile 

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

340 self._compilation_result = self._compiler_service.compile( 

341 console=console, 

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

343 ) 

344 

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

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

347 

348 except (ConfigurationError, CompilationError): 

349 raise 

350 except Exception as e: 

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

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

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

354 

355 def zip_compiled_project(self) -> None: 

356 """ 

357 Create ZIP archive of compiled project. 

358 

359 Archives the compiled output if needed. Cx_Freeze output is 

360 zipped; PyInstaller single-file output is not. 

361 

362 Raises: 

363 ConfigurationError: If project not initialized 

364 

365 Note: 

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

367 """ 

368 try: 

369 if not self._config: 

370 raise ConfigurationError( 

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

372 ) 

373 

374 # Check if ZIP is needed from compilation result 

375 zip_needed = ( 

376 self._compilation_result.zip_needed 

377 if self._compilation_result 

378 else self._config.zip_needed 

379 ) 

380 

381 if not zip_needed: 

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

383 return 

384 

385 # Create ZIP archive via CompilerService 

386 if self._compiler_service is None: 

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

388 

389 self._pipeline_service.zip_artifact( 

390 config=self._config, 

391 compiler_service=self._compiler_service, 

392 compilation_result=self._compilation_result, 

393 progress_callback=self._zip_progress_callback, 

394 ) 

395 

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

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

398 

399 except (ConfigurationError, ZipError): 

400 raise 

401 except Exception as e: 

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

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

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

405 

406 # //////////////////////////////////////////////// 

407 # UPLOAD METHODS 

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

409 

410 def upload_to_repo( 

411 self, 

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

413 repo_path: Path | str, 

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

415 ) -> None: 

416 """ 

417 Upload compiled project to repository. 

418 

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

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

421 

422 Args: 

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

424 repo_path: Repository path or server URL 

425 upload_config: Additional uploader configuration options 

426 

427 Raises: 

428 ConfigurationError: If project not initialized 

429 EzCompilerError: If upload structure is invalid 

430 

431 Example: 

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

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

434 """ 

435 try: 

436 if not self._config: 

437 raise ConfigurationError( 

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

439 ) 

440 

441 # Perform upload using UploaderService 

442 self._pipeline_service.upload_artifact( 

443 config=self._config, 

444 structure=structure, 

445 destination=str(repo_path), 

446 compilation_result=self._compilation_result, 

447 upload_config=upload_config, 

448 ) 

449 

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

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

452 

453 except (ConfigurationError, UploadError): 

454 raise 

455 except Exception as e: 

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

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

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

459 

460 def run_pipeline( 

461 self, 

462 console: bool = True, 

463 compiler: str | None = None, 

464 skip_zip: bool = False, 

465 skip_upload: bool = False, 

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

467 upload_destination: str | None = None, 

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

469 ) -> None: 

470 """ 

471 Run the full build pipeline with visual progress tracking. 

472 

473 Executes version generation, compilation, optional ZIP creation, 

474 and optional upload in sequence with a DynamicLayeredProgress display. 

475 

476 Args: 

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

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

479 skip_zip: Skip ZIP archive creation 

480 skip_upload: Skip upload step 

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

482 upload_destination: Upload destination path or URL 

483 upload_config: Additional uploader configuration 

484 

485 Raises: 

486 ConfigurationError: If project not initialized 

487 CompilationError: If compilation fails 

488 VersionError: If version file generation fails 

489 ZipError: If ZIP creation fails 

490 UploadError: If upload fails 

491 

492 Example: 

493 >>> compiler = EzCompiler(config) 

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

495 """ 

496 if not self._config: 

497 raise ConfigurationError( 

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

499 ) 

500 

501 # Determine which optional stages to include 

502 should_zip = not skip_zip and self._config.zip_needed 

503 should_upload = not skip_upload and ( 

504 upload_structure is not None or self._config.repo_needed 

505 ) 

506 

507 # Build stages 

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

509 self._config, should_zip=should_zip, should_upload=should_upload 

510 ) 

511 

512 current_phase = "version" 

513 pipeline_error: Exception | None = None 

514 

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

516 try: 

517 # Version file 

518 current_phase = "version" 

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

520 config_dict = self._config.to_dict() 

521 version_file_path = Path(self._config.version_filename) 

522 self._template_service.generate_version_file( 

523 config_dict, version_file_path 

524 ) 

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

526 dlp.complete_layer("version") 

527 

528 # Compilation 

529 current_phase = "compile" 

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

531 self._compiler_service, self._compilation_result = ( 

532 self._pipeline_service.compile_project( 

533 config=self._config, 

534 console=console, 

535 compiler=compiler, 

536 ) 

537 ) 

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

539 dlp.complete_layer("compile") 

540 

541 # ZIP 

542 zip_needed = ( 

543 self._compilation_result.zip_needed 

544 if self._compilation_result 

545 else self._config.zip_needed 

546 ) 

547 if should_zip: 

548 if zip_needed: 

549 current_phase = "zip" 

550 

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

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

553 

554 Args: 

555 filename: The name of the file being compressed. 

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

557 """ 

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

559 

560 self._pipeline_service.zip_artifact( 

561 config=self._config, 

562 compiler_service=self._compiler_service, 

563 compilation_result=self._compilation_result, 

564 progress_callback=_zip_cb, 

565 ) 

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

567 dlp.complete_layer("zip") 

568 else: 

569 # Stage was added but not needed at runtime 

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

571 dlp.complete_layer("zip") 

572 

573 # Upload 

574 if should_upload: 

575 current_phase = "upload" 

576 structure = upload_structure or self._config.upload_structure 

577 destination = upload_destination or ( 

578 self._config.server_url 

579 if structure == "server" 

580 else self._config.repo_path 

581 ) 

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

583 self._pipeline_service.upload_artifact( 

584 config=self._config, 

585 structure=structure, 

586 destination=str(destination), 

587 compilation_result=self._compilation_result, 

588 upload_config=upload_config, 

589 ) 

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

591 dlp.complete_layer("upload") 

592 

593 except ( 

594 ConfigurationError, 

595 CompilationError, 

596 TemplateError, 

597 VersionError, 

598 UploadError, 

599 ZipError, 

600 ) as e: 

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

602 dlp.emergency_stop(str(e)) 

603 pipeline_error = e 

604 except Exception as e: 

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

606 dlp.emergency_stop(str(e)) 

607 pipeline_error = e 

608 

609 if pipeline_error: 

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

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

612 raise pipeline_error 

613 

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

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

616 

617 # //////////////////////////////////////////////// 

618 # PRIVATE HELPER METHODS 

619 # //////////////////////////////////////////////// 

620 

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

622 """ 

623 Progress callback for ZIP archive creation. 

624 

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

626 

627 Args: 

628 filename: Current file being zipped 

629 progress: Progress percentage (0-100) 

630 """ 

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

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

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