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
« 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# ///////////////////////////////////////////////////////////////
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
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
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
53# ///////////////////////////////////////////////////////////////
54# CLASSES
55# ///////////////////////////////////////////////////////////////
58class EzCompiler:
59 """
60 Main orchestration class for project compilation and distribution.
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.
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
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 """
79 # ////////////////////////////////////////////////
80 # INITIALIZATION
81 # ////////////////////////////////////////////////
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.
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.
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
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__)
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()
122 # Compilation state
123 self._compilation_result: CompilationResult | None = None
125 # ////////////////////////////////////////////////
126 # LOGGING ACCESSOR PROPERTIES
127 # ////////////////////////////////////////////////
129 @property
130 def printer(self) -> _LazyPrinter:
131 """
132 Get the console printer proxy.
134 Returns:
135 _LazyPrinter: Lazy printer — silent until host app initializes Ezpl
136 """
137 return self._printer
139 @property
140 def logger(self) -> logging.Logger:
141 """
142 Get the stdlib logger.
144 Returns:
145 logging.Logger: Stdlib logger — silent until host app configures logging
146 """
147 return self._logger
149 @property
150 def config(self) -> CompilerConfig | None:
151 """
152 Get the current compiler configuration.
154 Returns:
155 CompilerConfig | None: Current configuration or None if not initialized
156 """
157 return self._config
159 # ////////////////////////////////////////////////
160 # PROJECT INITIALIZATION
161 # ////////////////////////////////////////////////
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.
175 Creates a CompilerConfig from provided parameters. This is a
176 convenience method for backward compatibility; can also set
177 config directly.
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
187 Raises:
188 ConfigurationError: If configuration is invalid
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 }
211 # Update configuration
212 self._config = CompilerConfig(**config_dict)
214 self._printer.success("Project configuration initialized successfully")
215 self._logger.info("Project configuration initialized successfully")
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
224 # ////////////////////////////////////////////////
225 # VERSION AND SETUP GENERATION
226 # ////////////////////////////////////////////////
228 def generate_version_file(self, name: str = "version_info.txt") -> None:
229 """
230 Generate version information file.
232 Uses the configured version information to generate a version file
233 at the specified path. Legacy method for backward compatibility.
235 Args:
236 name: Version file name (default: "version_info.txt")
238 Raises:
239 ConfigurationError: If project not initialized
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 )
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)
255 self._printer.success("Version file generated successfully")
256 self._logger.info("Version file generated successfully")
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
265 def generate_setup_file(self, file_path: Path | str) -> None:
266 """
267 Generate setup.py file from template.
269 Creates a setup.py file using the template system. Legacy method
270 for backward compatibility.
272 Args:
273 file_path: Path where to create the setup.py file
275 Raises:
276 ConfigurationError: If project not initialized
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 )
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 )
294 self._printer.success("Setup file generated successfully")
295 self._logger.info("Setup file generated successfully")
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
304 # ////////////////////////////////////////////////
305 # COMPILATION METHODS
306 # ////////////////////////////////////////////////
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.
314 Validates configuration, selects compiler if not specified, and
315 executes compilation. Sets _zip_needed based on compiler output type.
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
325 Raises:
326 ConfigurationError: If project not initialized
327 CompilationError: If compilation fails
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 )
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 )
345 self._printer.success("Project compiled successfully")
346 self._logger.info("Project compiled successfully")
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
355 def zip_compiled_project(self) -> None:
356 """
357 Create ZIP archive of compiled project.
359 Archives the compiled output if needed. Cx_Freeze output is
360 zipped; PyInstaller single-file output is not.
362 Raises:
363 ConfigurationError: If project not initialized
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 )
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 )
381 if not zip_needed:
382 self._printer.info("ZIP not needed for this compilation type")
383 return
385 # Create ZIP archive via CompilerService
386 if self._compiler_service is None:
387 self._compiler_service = self._compiler_service_factory(self._config)
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 )
396 self._printer.success("ZIP archive created successfully")
397 self._logger.info("ZIP archive created successfully")
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
406 # ////////////////////////////////////////////////
407 # UPLOAD METHODS
408 # ////////////////////////////////////////////////
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.
419 Uploads the compiled artifact (ZIP or directory) to the specified
420 repository using the appropriate uploader (disk or server).
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
427 Raises:
428 ConfigurationError: If project not initialized
429 EzCompilerError: If upload structure is invalid
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 )
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 )
450 self._printer.success(f"Project uploaded successfully to {structure}")
451 self._logger.info(f"Project uploaded successfully to {structure}")
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
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.
473 Executes version generation, compilation, optional ZIP creation,
474 and optional upload in sequence with a DynamicLayeredProgress display.
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
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
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 )
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 )
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 )
512 current_phase = "version"
513 pipeline_error: Exception | None = None
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")
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")
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"
551 def _zip_cb(filename: str, progress: int) -> None:
552 """Update progress display during ZIP file creation.
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)
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")
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")
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
609 if pipeline_error:
610 self._printer.error(str(pipeline_error))
611 self._logger.error(str(pipeline_error))
612 raise pipeline_error
614 self._printer.success("Build pipeline finished")
615 self._logger.info("Build pipeline finished")
617 # ////////////////////////////////////////////////
618 # PRIVATE HELPER METHODS
619 # ////////////////////////////////////////////////
621 def _zip_progress_callback(self, filename: str, progress: int) -> None:
622 """
623 Progress callback for ZIP archive creation.
625 Logs progress at 10% intervals to reduce log verbosity.
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}")