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
« 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# ///////////////////////////////////////////////////////////////
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, 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=compiler, # type: ignore[arg-type]
342 )
344 self._printer.success("Project compiled successfully")
345 self._logger.info("Project compiled successfully")
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
354 def zip_compiled_project(self) -> None:
355 """
356 Create ZIP archive of compiled project.
358 Archives the compiled output if needed. Cx_Freeze output is
359 zipped; PyInstaller single-file output is not.
361 Raises:
362 ConfigurationError: If project not initialized
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 )
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 )
380 if not zip_needed:
381 self._printer.info("ZIP not needed for this compilation type")
382 return
384 # Create ZIP archive via CompilerService
385 if self._compiler_service is None:
386 self._compiler_service = self._compiler_service_factory(self._config)
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 )
395 self._printer.success("ZIP archive created successfully")
396 self._logger.info("ZIP archive created successfully")
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
405 # ////////////////////////////////////////////////
406 # UPLOAD METHODS
407 # ////////////////////////////////////////////////
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.
418 Uploads the compiled artifact (ZIP or directory) to the specified
419 repository using the appropriate uploader (disk or server).
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
426 Raises:
427 ConfigurationError: If project not initialized
428 EzCompilerError: If upload structure is invalid
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 )
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 )
449 self._printer.success(f"Project uploaded successfully to {structure}")
450 self._logger.info(f"Project uploaded successfully to {structure}")
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
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.
472 Executes version generation, compilation, optional ZIP creation,
473 and optional upload in sequence with a DynamicLayeredProgress display.
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
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
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 )
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 )
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 )
511 current_phase = "version"
512 pipeline_error: Exception | None = None
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")
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")
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"
550 def _zip_cb(filename: str, progress: int) -> None:
551 """Update progress display during ZIP file creation.
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)
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")
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")
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
608 if pipeline_error:
609 self._printer.error(str(pipeline_error))
610 self._logger.error(str(pipeline_error))
611 raise pipeline_error
613 self._printer.success("Build pipeline finished")
614 self._logger.info("Build pipeline finished")
616 # ////////////////////////////////////////////////
617 # PRIVATE HELPER METHODS
618 # ////////////////////////////////////////////////
620 def _zip_progress_callback(self, filename: str, progress: int) -> None:
621 """
622 Progress callback for ZIP archive creation.
624 Logs progress at 10% intervals to reduce log verbosity.
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}")