Coverage for src/ezcompiler/services/compiler_service.py: 93.26%
71 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 16:37 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 16:37 +0000
1# ///////////////////////////////////////////////////////////////
2# COMPILER_SERVICE - Compilation orchestration service
3# Project: ezcompiler
4# ///////////////////////////////////////////////////////////////
6"""
7Compiler service - Compilation orchestration service for EzCompiler.
9This module provides the CompilerService class that orchestrates project
10compilation using different compiler backends (Cx_Freeze, PyInstaller, Nuitka).
12Services layer can use WARNING and ERROR log levels.
13"""
15from __future__ import annotations
17# ///////////////////////////////////////////////////////////////
18# IMPORTS
19# ///////////////////////////////////////////////////////////////
20# Standard library imports
21import sys
22from collections.abc import Callable
23from pathlib import Path
24from typing import Literal, cast
26# Third-party imports
27from InquirerPy.resolver import prompt
29from .._types import CompilerPort
31# Local imports
32from ..adapters import CompilerFactory
33from ..shared import CompilationResult, CompilerConfig
34from ..shared.exceptions import CompilationError, ConfigurationError
35from ..utils import CompilerUtils, ZipUtils
36from ..utils.validators import validate_compiler_name
38# ///////////////////////////////////////////////////////////////
39# TYPE ALIASES
40# ///////////////////////////////////////////////////////////////
42_CompilerName = Literal["Cx_Freeze", "PyInstaller", "Nuitka"]
44# ///////////////////////////////////////////////////////////////
45# CLASSES
46# ///////////////////////////////////////////////////////////////
49class CompilerService:
50 """
51 Compilation orchestration service.
53 Orchestrates project compilation using different compiler backends.
54 Handles compiler selection, validation, and execution.
56 Attributes:
57 _config: CompilerConfig instance with project settings
59 Example:
60 >>> config = CompilerConfig(...)
61 >>> service = CompilerService(config)
62 >>> result = service.compile(console=True, compiler="PyInstaller")
63 >>> print(result.zip_needed)
64 False
65 """
67 # ////////////////////////////////////////////////
68 # INITIALIZATION
69 # ////////////////////////////////////////////////
71 def __init__(self, config: CompilerConfig) -> None:
72 """
73 Initialize the compiler service.
75 Args:
76 config: CompilerConfig instance with project settings
78 Raises:
79 ConfigurationError: If config is None or invalid
80 """
81 if not config:
82 raise ConfigurationError("CompilerConfig is required")
84 self._config = config
85 self._compiler_instance: CompilerPort | None = None
87 # ////////////////////////////////////////////////
88 # COMPILATION METHODS
89 # ////////////////////////////////////////////////
91 def compile(
92 self,
93 console: bool = True,
94 compiler: _CompilerName | None = None,
95 ) -> CompilationResult:
96 """
97 Compile the project using specified or auto-selected compiler.
99 Validates configuration, selects compiler if not specified, and
100 executes compilation. Returns result with zip_needed flag.
102 Args:
103 console: Whether to show console window (default: True)
104 compiler: Compiler to use or None for auto-selection
105 - "Cx_Freeze": Creates directory with dependencies
106 - "PyInstaller": Creates single executable
107 - "Nuitka": Creates standalone folder or single executable
108 - None: Use config compiler, or prompt user if none set
110 Returns:
111 CompilationResult: Result with zip_needed flag and compiler instance
113 Raises:
114 ConfigurationError: If project not initialized
115 CompilationError: If compilation fails
117 Example:
118 >>> service = CompilerService(config)
119 >>> result = service.compile(console=False, compiler="PyInstaller")
120 >>> if result.zip_needed:
121 ... # Create ZIP archive
122 """
123 try:
124 # Determine compiler choice
125 compiler_choice = self._determine_compiler(compiler)
127 # Validate compiler choice
128 if not validate_compiler_name(compiler_choice):
129 raise CompilationError(f"Invalid compiler: {compiler_choice}")
131 # Ensure output directory exists (moved out of CompilerConfig to avoid side effects)
132 self._config.output_folder.mkdir(parents=True, exist_ok=True)
134 # Create and execute compiler
135 self._compiler_instance = self._create_compiler(compiler_choice)
136 self._compiler_instance.compile(console=console)
138 return CompilationResult(
139 zip_needed=self._compiler_instance.zip_needed,
140 compiler_name=compiler_choice,
141 compiler_instance=self._compiler_instance,
142 )
143 except CompilationError:
144 raise
145 except ConfigurationError:
146 raise
147 except Exception as e:
148 raise CompilationError(f"Compilation failed: {str(e)}") from e
150 def use_existing_build(
151 self,
152 compiler: _CompilerName | None = None,
153 ) -> CompilationResult:
154 """
155 Reuse a previous build from output_folder instead of compiling.
157 Resolves the compiler (without running it) so downstream stages
158 (zip, installer, release) can resume from an already compiled
159 output folder.
161 Args:
162 compiler: Compiler used for the previous build, or None to use
163 config.compiler (or prompt when unset)
165 Returns:
166 CompilationResult: Result describing the existing build
168 Raises:
169 CompilationError: If output_folder is missing or empty, or the
170 compiler name is invalid
172 Example:
173 >>> service = CompilerService(config)
174 >>> result = service.use_existing_build(compiler="Cx_Freeze")
175 """
176 output_folder = Path(self._config.output_folder)
177 if not output_folder.is_dir() or not any(output_folder.iterdir()):
178 raise CompilationError(
179 f"No existing build found in {output_folder}. "
180 "Run the pipeline without --skip-build first."
181 )
183 compiler_choice = self._determine_compiler(compiler)
184 if not validate_compiler_name(compiler_choice): 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true
185 raise CompilationError(f"Invalid compiler: {compiler_choice}")
187 self._compiler_instance = self._create_compiler(compiler_choice)
188 # Mirror the compilers' runtime rule: Cx_Freeze always produces a
189 # folder; PyInstaller/Nuitka only skip zipping in --onefile mode.
190 zip_needed = (
191 compiler_choice == "Cx_Freeze" or not CompilerUtils.check_onefile_mode()
192 )
194 return CompilationResult(
195 zip_needed=zip_needed,
196 compiler_name=compiler_choice,
197 compiler_instance=self._compiler_instance,
198 )
200 # ////////////////////////////////////////////////
201 # PRIVATE HELPER METHODS
202 # ////////////////////////////////////////////////
204 def _determine_compiler(self, compiler: _CompilerName | None) -> str:
205 """
206 Determine which compiler to use.
208 Args:
209 compiler: Explicit compiler choice or None/auto
211 Returns:
212 str: Compiler name to use
214 Note:
215 Priority: explicit choice > config.compiler > interactive prompt
216 """
217 # Use explicit choice if provided
218 if compiler:
219 return compiler
221 # Use config compiler if set
222 if self._config.compiler: 222 ↛ 226line 222 didn't jump to line 226 because the condition on line 222 was always true
223 return self._config.compiler
225 # Interactive prompt for user choice
226 return self._choose_compiler_interactively()
228 def _choose_compiler_interactively(
229 self, argv_flags: list[str] | None = None
230 ) -> str:
231 """
232 Prompt user to choose a compiler interactively.
234 Checks command-line flags first, then prompts if needed.
236 Args:
237 argv_flags: List of CLI flags to check. Defaults to sys.argv.
238 Inject a custom list in tests to avoid reading global state.
240 Returns:
241 str: Chosen compiler name
243 Raises:
244 CompilationError: If selection fails
245 """
246 flags = argv_flags if argv_flags is not None else sys.argv
247 try:
248 # Check command line arguments first
249 if "-cxf" in flags:
250 return "Cx_Freeze"
251 elif "-pyi" in flags:
252 return "PyInstaller"
253 elif "-nka" in flags:
254 return "Nuitka"
256 # Prompt user for choice
257 questions = [
258 {
259 "type": "list",
260 "name": "compiler",
261 "message": "Which compiler to use?",
262 "choices": ["Cx_Freeze", "PyInstaller", "Nuitka"],
263 "default": "Cx_Freeze",
264 }
265 ]
267 result = prompt(questions)
268 return cast(str, result["compiler"])
270 except Exception as e:
271 raise CompilationError(f"Failed to choose compiler: {e}") from e
273 def _create_compiler(self, compiler_name: str) -> CompilerPort:
274 """
275 Create compiler instance for the specified compiler.
277 Args:
278 compiler_name: Name of the compiler to create
280 Returns:
281 CompilerPort: Compiler instance
283 Raises:
284 CompilationError: If compiler name is unsupported
285 """
286 return CompilerFactory.create_compiler(
287 config=self._config,
288 compiler_name=compiler_name,
289 )
291 def _zip_artifact(
292 self,
293 output_path: str | Path,
294 progress_callback: Callable[[str, int], None] | None = None,
295 ) -> None:
296 """
297 Create ZIP archive of the compiled output.
299 Args:
300 output_path: Path for the output ZIP file
301 progress_callback: Optional callback(filename, progress) for progress updates
303 Raises:
304 ZipError: If ZIP creation fails
305 """
306 ZipUtils.create_zip_archive(
307 source_path=self._config.output_folder,
308 output_path=output_path,
309 progress_callback=progress_callback,
310 )
312 # ////////////////////////////////////////////////
313 # PROPERTIES
314 # ////////////////////////////////////////////////
316 @property
317 def compiler_instance(self) -> CompilerPort | None:
318 """
319 Get the current compiler instance.
321 Returns:
322 CompilerPort | None: Current compiler instance or None if not compiled yet
323 """
324 return self._compiler_instance