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

1# /////////////////////////////////////////////////////////////// 

2# COMPILER_SERVICE - Compilation orchestration service 

3# Project: ezcompiler 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Compiler service - Compilation orchestration service for EzCompiler. 

8 

9This module provides the CompilerService class that orchestrates project 

10compilation using different compiler backends (Cx_Freeze, PyInstaller, Nuitka). 

11 

12Services layer can use WARNING and ERROR log levels. 

13""" 

14 

15from __future__ import annotations 

16 

17# /////////////////////////////////////////////////////////////// 

18# IMPORTS 

19# /////////////////////////////////////////////////////////////// 

20# Standard library imports 

21import sys 

22from collections.abc import Callable 

23from pathlib import Path 

24from typing import Literal, cast 

25 

26# Third-party imports 

27from InquirerPy.resolver import prompt 

28 

29from .._types import CompilerPort 

30 

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 

37 

38# /////////////////////////////////////////////////////////////// 

39# TYPE ALIASES 

40# /////////////////////////////////////////////////////////////// 

41 

42_CompilerName = Literal["Cx_Freeze", "PyInstaller", "Nuitka"] 

43 

44# /////////////////////////////////////////////////////////////// 

45# CLASSES 

46# /////////////////////////////////////////////////////////////// 

47 

48 

49class CompilerService: 

50 """ 

51 Compilation orchestration service. 

52 

53 Orchestrates project compilation using different compiler backends. 

54 Handles compiler selection, validation, and execution. 

55 

56 Attributes: 

57 _config: CompilerConfig instance with project settings 

58 

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 """ 

66 

67 # //////////////////////////////////////////////// 

68 # INITIALIZATION 

69 # //////////////////////////////////////////////// 

70 

71 def __init__(self, config: CompilerConfig) -> None: 

72 """ 

73 Initialize the compiler service. 

74 

75 Args: 

76 config: CompilerConfig instance with project settings 

77 

78 Raises: 

79 ConfigurationError: If config is None or invalid 

80 """ 

81 if not config: 

82 raise ConfigurationError("CompilerConfig is required") 

83 

84 self._config = config 

85 self._compiler_instance: CompilerPort | None = None 

86 

87 # //////////////////////////////////////////////// 

88 # COMPILATION METHODS 

89 # //////////////////////////////////////////////// 

90 

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. 

98 

99 Validates configuration, selects compiler if not specified, and 

100 executes compilation. Returns result with zip_needed flag. 

101 

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 

109 

110 Returns: 

111 CompilationResult: Result with zip_needed flag and compiler instance 

112 

113 Raises: 

114 ConfigurationError: If project not initialized 

115 CompilationError: If compilation fails 

116 

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) 

126 

127 # Validate compiler choice 

128 if not validate_compiler_name(compiler_choice): 

129 raise CompilationError(f"Invalid compiler: {compiler_choice}") 

130 

131 # Ensure output directory exists (moved out of CompilerConfig to avoid side effects) 

132 self._config.output_folder.mkdir(parents=True, exist_ok=True) 

133 

134 # Create and execute compiler 

135 self._compiler_instance = self._create_compiler(compiler_choice) 

136 self._compiler_instance.compile(console=console) 

137 

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 

149 

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. 

156 

157 Resolves the compiler (without running it) so downstream stages 

158 (zip, installer, release) can resume from an already compiled 

159 output folder. 

160 

161 Args: 

162 compiler: Compiler used for the previous build, or None to use 

163 config.compiler (or prompt when unset) 

164 

165 Returns: 

166 CompilationResult: Result describing the existing build 

167 

168 Raises: 

169 CompilationError: If output_folder is missing or empty, or the 

170 compiler name is invalid 

171 

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 ) 

182 

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}") 

186 

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 ) 

193 

194 return CompilationResult( 

195 zip_needed=zip_needed, 

196 compiler_name=compiler_choice, 

197 compiler_instance=self._compiler_instance, 

198 ) 

199 

200 # //////////////////////////////////////////////// 

201 # PRIVATE HELPER METHODS 

202 # //////////////////////////////////////////////// 

203 

204 def _determine_compiler(self, compiler: _CompilerName | None) -> str: 

205 """ 

206 Determine which compiler to use. 

207 

208 Args: 

209 compiler: Explicit compiler choice or None/auto 

210 

211 Returns: 

212 str: Compiler name to use 

213 

214 Note: 

215 Priority: explicit choice > config.compiler > interactive prompt 

216 """ 

217 # Use explicit choice if provided 

218 if compiler: 

219 return compiler 

220 

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 

224 

225 # Interactive prompt for user choice 

226 return self._choose_compiler_interactively() 

227 

228 def _choose_compiler_interactively( 

229 self, argv_flags: list[str] | None = None 

230 ) -> str: 

231 """ 

232 Prompt user to choose a compiler interactively. 

233 

234 Checks command-line flags first, then prompts if needed. 

235 

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. 

239 

240 Returns: 

241 str: Chosen compiler name 

242 

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" 

255 

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 ] 

266 

267 result = prompt(questions) 

268 return cast(str, result["compiler"]) 

269 

270 except Exception as e: 

271 raise CompilationError(f"Failed to choose compiler: {e}") from e 

272 

273 def _create_compiler(self, compiler_name: str) -> CompilerPort: 

274 """ 

275 Create compiler instance for the specified compiler. 

276 

277 Args: 

278 compiler_name: Name of the compiler to create 

279 

280 Returns: 

281 CompilerPort: Compiler instance 

282 

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 ) 

290 

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. 

298 

299 Args: 

300 output_path: Path for the output ZIP file 

301 progress_callback: Optional callback(filename, progress) for progress updates 

302 

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 ) 

311 

312 # //////////////////////////////////////////////// 

313 # PROPERTIES 

314 # //////////////////////////////////////////////// 

315 

316 @property 

317 def compiler_instance(self) -> CompilerPort | None: 

318 """ 

319 Get the current compiler instance. 

320 

321 Returns: 

322 CompilerPort | None: Current compiler instance or None if not compiled yet 

323 """ 

324 return self._compiler_instance