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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Python API interface - High-level Python API for EzCompiler.
9This module provides the EzCompiler class that orchestrates project compilation,
10version generation, setup file creation, artifact zipping, and repository upload
11using the service layer.
13Interfaces layer can use all log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL).
14"""
16from __future__ import annotations
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
26if TYPE_CHECKING: 26 ↛ 27line 26 didn't jump to line 27 because the condition on line 26 was never true
27 import logging
29 from ezplog.handlers.wizard.dynamic import StageConfig
30 from ezplog.lib_mode import _LazyPrinter
32# Third-party imports
33from ezplog.lib_mode import get_logger, get_printer
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)
52# ///////////////////////////////////////////////////////////////
53# CLASSES
54# ///////////////////////////////////////////////////////////////
57class EzCompiler:
58 """
59 Main orchestration class for project compilation and distribution.
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.
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
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 """
78 # ////////////////////////////////////////////////
79 # INITIALIZATION
80 # ////////////////////////////////////////////////
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.
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.
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
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__)
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()
121 # Compilation state
122 self._compilation_result: CompilationResult | None = None
124 # ////////////////////////////////////////////////
125 # LOGGING ACCESSOR PROPERTIES
126 # ////////////////////////////////////////////////
128 @property
129 def printer(self) -> _LazyPrinter:
130 """
131 Get the console printer proxy.
133 Returns:
134 _LazyPrinter: Lazy printer — silent until host app initializes Ezpl
135 """
136 return self._printer
138 @property
139 def logger(self) -> logging.Logger:
140 """
141 Get the stdlib logger.
143 Returns:
144 logging.Logger: Stdlib logger — silent until host app configures logging
145 """
146 return self._logger
148 @property
149 def config(self) -> CompilerConfig | None:
150 """
151 Get the current compiler configuration.
153 Returns:
154 CompilerConfig | None: Current configuration or None if not initialized
155 """
156 return self._config
158 # ////////////////////////////////////////////////
159 # PROJECT INITIALIZATION
160 # ////////////////////////////////////////////////
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.
174 Creates a CompilerConfig from provided parameters. This is a
175 convenience method for backward compatibility; can also set
176 config directly.
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
186 Raises:
187 ConfigurationError: If configuration is invalid
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 }
210 # Update configuration
211 self._config = CompilerConfig(**config_dict)
213 self._printer.success("Project configuration initialized successfully")
214 self._logger.info("Project configuration initialized successfully")
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
223 # ////////////////////////////////////////////////
224 # VERSION AND SETUP GENERATION
225 # ////////////////////////////////////////////////
227 def generate_version_file(self, name: str = "version_info.txt") -> None:
228 """
229 Generate version information file.
231 Uses the configured version information to generate a version file
232 at the specified path. Legacy method for backward compatibility.
234 Args:
235 name: Version file name (default: "version_info.txt")
237 Raises:
238 ConfigurationError: If project not initialized
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 )
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)
254 self._printer.success("Version file generated successfully")
255 self._logger.info("Version file generated successfully")
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
264 def generate_setup_file(self, file_path: Path | str) -> None:
265 """
266 Generate setup.py file from template.
268 Creates a setup.py file using the template system. Legacy method
269 for backward compatibility.
271 Args:
272 file_path: Path where to create the setup.py file
274 Raises:
275 ConfigurationError: If project not initialized
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 )
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 )
293 self._printer.success("Setup file generated successfully")
294 self._logger.info("Setup file generated successfully")
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
303 # ////////////////////////////////////////////////
304 # COMPILATION METHODS
305 # ////////////////////////////////////////////////
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.
313 Validates configuration, selects compiler if not specified, and
314 executes compilation. Sets _zip_needed based on compiler output type.
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
324 Raises:
325 ConfigurationError: If project not initialized
326 CompilationError: If compilation fails
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 )
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 )
347 self._printer.success("Project compiled successfully")
348 self._logger.info("Project compiled successfully")
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
357 def zip_compiled_project(self) -> None:
358 """
359 Create ZIP archive of compiled project.
361 Archives the compiled output if needed. Cx_Freeze output is
362 zipped; PyInstaller single-file output is not.
364 Raises:
365 ConfigurationError: If project not initialized
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 )
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 )
383 if not zip_needed:
384 self._printer.info("ZIP not needed for this compilation type")
385 return
387 # Create ZIP archive via CompilerService
388 if self._compiler_service is None:
389 self._compiler_service = self._compiler_service_factory(self._config)
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 )
398 self._printer.success("ZIP archive created successfully")
399 self._logger.info("ZIP archive created successfully")
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
408 # ////////////////////////////////////////////////
409 # UPLOAD METHODS
410 # ////////////////////////////////////////////////
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.
421 Uploads the compiled artifact (ZIP or directory) to the specified
422 repository using the appropriate uploader (disk or server).
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
429 Raises:
430 ConfigurationError: If project not initialized
431 EzCompilerError: If upload structure is invalid
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 )
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 )
452 self._printer.success(f"Project uploaded successfully to {structure}")
453 self._logger.info(f"Project uploaded successfully to {structure}")
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
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.
475 Executes version generation, compilation, optional ZIP creation,
476 and optional upload in sequence with a DynamicLayeredProgress display.
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
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
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 )
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 )
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 )
517 current_phase = "version"
518 pipeline_error: Exception | None = None
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")
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")
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"
556 def _zip_cb(filename: str, progress: int) -> None:
557 """Update progress display during ZIP file creation.
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)
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")
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")
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
614 if pipeline_error:
615 self._printer.error(str(pipeline_error))
616 self._logger.error(str(pipeline_error))
617 raise pipeline_error
619 self._printer.success("Build pipeline finished")
620 self._logger.info("Build pipeline finished")
622 # ////////////////////////////////////////////////
623 # PRIVATE HELPER METHODS
624 # ////////////////////////////////////////////////
626 def _zip_progress_callback(self, filename: str, progress: int) -> None:
627 """
628 Progress callback for ZIP archive creation.
630 Logs progress at 10% intervals to reduce log verbosity.
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}")