Coverage for src / ezqt_app / services / application / file_service.py: 8.08%

240 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-26 07:07 +0000

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

2# SERVICES.APPLICATION.FILE_SERVICE - File generation service 

3# Project: ezqt_app 

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

5 

6"""File and resource generation service for EzQt_App projects.""" 

7 

8from __future__ import annotations 

9 

10# /////////////////////////////////////////////////////////////// 

11# IMPORTS 

12# /////////////////////////////////////////////////////////////// 

13# Standard library imports 

14import shutil 

15import subprocess 

16from pathlib import Path 

17 

18# Local imports 

19from ...domain.errors import ( 

20 InvalidOverwritePolicyError, 

21 MissingPackageResourceError, 

22 ResourceCompilationError, 

23) 

24from ...utils.printer import get_printer 

25from ...utils.runtime_paths import APP_PATH 

26 

27# Root of the installed ezqt_app package — used to locate bundled resources. 

28# Resolves correctly whether the package is installed (site-packages) or run 

29# in-place from the src/ layout. 

30_PKG_ROOT: Path = Path(__file__).parent.parent.parent 

31 

32 

33# /////////////////////////////////////////////////////////////// 

34# CLASSES 

35# /////////////////////////////////////////////////////////////// 

36class FileService: 

37 """Handles file and resource generation for EzQt_App projects. 

38 

39 Manages generation of: 

40 - Asset directories 

41 - Configuration files (YAML) 

42 - Theme files (QSS) 

43 - Translation files (.ts) 

44 - Resource files (.qrc, _rc.py) 

45 - Project templates 

46 """ 

47 

48 _ERROR_CODES: dict[str, str] = { 

49 "invalid_overwrite_policy": "resources.invalid_overwrite_policy", 

50 "missing_yaml": "resources.missing_yaml", 

51 "missing_theme": "resources.missing_theme", 

52 "missing_translations": "resources.missing_translations", 

53 "qrc_compilation_failed": "resources.qrc_compilation_failed", 

54 } 

55 

56 def __init__( 

57 self, 

58 base_path: Path | None = None, 

59 bin_path: Path | None = None, 

60 verbose: bool = False, 

61 overwrite_policy: str = "ask", 

62 ) -> None: 

63 self.base_path: Path = base_path or APP_PATH 

64 self._bin: Path = bin_path or (self.base_path / "bin") 

65 self._qrc_file: str = "" 

66 self._resources_module_file: str = "" 

67 self._overwrite_policy = overwrite_policy.lower().strip() 

68 if self._overwrite_policy not in {"ask", "skip", "force"}: 

69 raise InvalidOverwritePolicyError( 

70 code=self._error_code("invalid_overwrite_policy"), 

71 message=f"Unsupported overwrite policy: {overwrite_policy}", 

72 context={"supported": ["ask", "skip", "force"]}, 

73 ) 

74 self.printer = get_printer(verbose) 

75 

76 # ----------------------------------------------------------- 

77 # Error Code 

78 # ----------------------------------------------------------- 

79 

80 def _error_code(self, key: str) -> str: 

81 """Return a codified resource error code for a known key.""" 

82 code = self._ERROR_CODES.get(key) 

83 if code is not None: 

84 return code 

85 slug = key.strip().lower().replace(" ", "_") 

86 return f"resources.{slug}" 

87 

88 # ----------------------------------------------------------- 

89 # Overwrite handling 

90 # ----------------------------------------------------------- 

91 

92 def _should_write(self, target_path: Path) -> bool: 

93 """Return whether a target file should be written according to policy.""" 

94 if not target_path.exists(): 

95 return True 

96 

97 if self._overwrite_policy == "force": 

98 return True 

99 

100 if self._overwrite_policy == "skip": 

101 self.printer.verbose_msg(f"Skipping existing file: {target_path}") 

102 return False 

103 

104 # Default "ask": prompt interactively, otherwise skip for safety. 

105 try: 

106 import click 

107 

108 if click.get_current_context(silent=True) is not None: 

109 return bool(click.confirm(f"Overwrite {target_path}?", default=False)) 

110 except Exception as e: 

111 self.printer.verbose_msg( 

112 f"Could not prompt for overwrite decision ({target_path}): {e}" 

113 ) 

114 

115 self.printer.verbose_msg(f"Skipping existing file (ask policy): {target_path}") 

116 return False 

117 

118 # ----------------------------------------------------------- 

119 # High-level orchestrators 

120 # ----------------------------------------------------------- 

121 

122 def setup_project( 

123 self, 

124 mk_theme: bool = True, 

125 mk_config: bool = True, 

126 mk_translations: bool = True, 

127 build_resources: bool = True, 

128 ) -> bool: 

129 """Create directories and generate all assets.""" 

130 try: 

131 self.make_assets_binaries() 

132 self.generate_all_assets( 

133 mk_theme=mk_theme, 

134 mk_config=mk_config, 

135 mk_translations=mk_translations, 

136 build_resources=build_resources, 

137 ) 

138 return True 

139 except Exception as e: 

140 self.printer.error(f"Error setting up project: {e}") 

141 return False 

142 

143 def generate_all_assets( 

144 self, 

145 mk_theme: bool = True, 

146 mk_config: bool = True, 

147 mk_translations: bool = True, 

148 build_resources: bool = True, 

149 ) -> bool: 

150 """Generate all required assets (YAML, QSS, translations, QRC, RC).""" 

151 try: 

152 self.make_assets_binaries() 

153 if mk_config: 

154 self.make_yaml_from_package() 

155 if mk_theme: 

156 self.make_qss_from_package() 

157 if mk_translations: 

158 self.make_translations_from_package() 

159 if build_resources and self.make_qrc(): 

160 self.make_rc_py() 

161 return True 

162 except Exception as e: 

163 self.printer.error(f"Error generating assets: {e}") 

164 return False 

165 

166 # ----------------------------------------------------------- 

167 # Directory / file creation 

168 # ----------------------------------------------------------- 

169 

170 def make_assets_binaries(self, verbose: bool = False) -> None: 

171 """Create the standard binary directory tree under ``bin/``.""" 

172 paths_to_make: list[Path] = [ 

173 self._bin, 

174 self._bin / "fonts", 

175 self._bin / "images", 

176 self._bin / "icons", 

177 self._bin / "themes", 

178 self._bin / "config", 

179 self._bin / "translations", 

180 ] 

181 

182 created_paths = [] 

183 for path in paths_to_make: 

184 if not path.exists(): 

185 path.mkdir(parents=True, exist_ok=True) 

186 created_paths.append(path) 

187 

188 if created_paths: 

189 self.printer.action( 

190 f"[Initializer] Generated assets directories: {len(created_paths)} directories" 

191 ) 

192 if verbose: 

193 self.printer.list_items([d.name for d in created_paths]) 

194 

195 def make_yaml_from_package(self, yaml_package: Path | None = None) -> Path | None: 

196 """Copy the package ``app.config.yaml`` into ``bin/config/``.""" 

197 if yaml_package is None: 

198 yaml_package = _PKG_ROOT / "resources" / "config" / "app.config.yaml" 

199 

200 if not yaml_package.exists(): 

201 raise MissingPackageResourceError( 

202 code=self._error_code("missing_yaml"), 

203 message=f"YAML file not found at {yaml_package}", 

204 context={"resource": str(yaml_package)}, 

205 ) 

206 

207 target_path = self._bin / "config" / "app.config.yaml" 

208 target_path.parent.mkdir(parents=True, exist_ok=True) 

209 if not self._should_write(target_path): 

210 return target_path 

211 shutil.copy2(yaml_package, target_path) 

212 self.printer.action("[Initializer] Generated YAML config file.") 

213 return target_path 

214 

215 def make_qss_from_package(self, theme_package: Path | None = None) -> bool: 

216 """Copy QSS theme files from the package into ``bin/themes/``.""" 

217 if theme_package is None: 

218 theme_package = _PKG_ROOT / "resources" / "themes" 

219 

220 if not theme_package.exists(): 

221 raise MissingPackageResourceError( 

222 code=self._error_code("missing_theme"), 

223 message=f"Theme resource not found at {theme_package}", 

224 context={"resource": str(theme_package)}, 

225 ) 

226 

227 target_path = self._bin / "themes" 

228 

229 try: 

230 target_path.mkdir(parents=True, exist_ok=True) 

231 

232 if theme_package.is_file(): 

233 if theme_package.name == "qtstrap.qss": 

234 self.printer.verbose_msg( 

235 f"Skipping unnecessary theme file: {theme_package.name}" 

236 ) 

237 return False 

238 try: 

239 target_file = target_path / theme_package.name 

240 if not self._should_write(target_file): 

241 return True 

242 shutil.copy2(theme_package, target_file) 

243 self.printer.action("[Initializer] Generated QSS theme files.") 

244 return True 

245 except Exception as e: 

246 self.printer.warning( 

247 f"Failed to copy theme file {theme_package.name}: {e}" 

248 ) 

249 return False 

250 else: 

251 copied_files = [] 

252 for theme_file in theme_package.glob("*.qss"): 

253 if theme_file.name == "qtstrap.qss": 

254 self.printer.verbose_msg( 

255 f"Skipping unnecessary theme file: {theme_file.name}" 

256 ) 

257 continue 

258 try: 

259 target_file = target_path / theme_file.name 

260 if not self._should_write(target_file): 

261 continue 

262 shutil.copy2(theme_file, target_file) 

263 copied_files.append(theme_file.name) 

264 self.printer.verbose_msg( 

265 f"Copied theme file: {theme_file.name}" 

266 ) 

267 except Exception as e: 

268 self.printer.warning( 

269 f"Failed to copy theme file {theme_file.name}: {e}" 

270 ) 

271 

272 if copied_files: 

273 self.printer.action("[Initializer] Generated QSS theme files.") 

274 return True 

275 

276 existing = list(target_path.glob("*.qss")) 

277 if existing: 

278 self.printer.info("[Initializer] QSS theme files already exist.") 

279 return True 

280 

281 self.printer.warning( 

282 "[Initializer] No QSS theme files were copied successfully." 

283 ) 

284 return False 

285 

286 except Exception as e: 

287 self.printer.error(f"Error copying theme files: {e}") 

288 return False 

289 

290 def make_translations_from_package( 

291 self, translations_package: Path | None = None 

292 ) -> bool: 

293 """Copy ``.ts`` translation files from the package into ``bin/translations/``.""" 

294 if translations_package is None: 

295 translations_package = _PKG_ROOT / "resources" / "translations" 

296 

297 if not translations_package.exists(): 

298 raise MissingPackageResourceError( 

299 code=self._error_code("missing_translations"), 

300 message=f"Translations resource not found at {translations_package}", 

301 context={"resource": str(translations_package)}, 

302 ) 

303 

304 target_path = self._bin / "translations" 

305 

306 try: 

307 target_path.mkdir(parents=True, exist_ok=True) 

308 

309 for translation_file in translations_package.glob("*.ts"): 

310 try: 

311 target_file = target_path / translation_file.name 

312 if not self._should_write(target_file): 

313 continue 

314 shutil.copy2(translation_file, target_file) 

315 self.printer.verbose_msg( 

316 f"Copied translation file: {translation_file.name}" 

317 ) 

318 except Exception as e: 

319 self.printer.warning( 

320 f"Failed to copy translation file {translation_file.name}: {e}" 

321 ) 

322 

323 if any(target_path.glob("*.ts")): 

324 self.printer.action("[Initializer] Generated translation files.") 

325 return True 

326 

327 self.printer.warning( 

328 "[Initializer] No translation files were copied successfully." 

329 ) 

330 return False 

331 

332 except Exception as e: 

333 self.printer.error(f"Error copying translation files: {e}") 

334 return False 

335 

336 def make_qrc(self) -> bool: 

337 """Generate a ``resources.qrc`` file from the ``bin/`` directory contents.""" 

338 qrc_content = [ 

339 '<?xml version="1.0" encoding="UTF-8"?>', 

340 "<RCC>", 

341 ' <qresource prefix="/">', 

342 ] 

343 

344 def _add_qresource(directory: Path) -> None: 

345 if directory.exists(): 

346 for file_path in directory.rglob("*"): 

347 if file_path.is_file(): 

348 relative_path = file_path.relative_to(self._bin).as_posix() 

349 qrc_content.append(f" <file>{relative_path}</file>") 

350 

351 _add_qresource(self._bin / "fonts") 

352 _add_qresource(self._bin / "images") 

353 _add_qresource(self._bin / "icons") 

354 _add_qresource(self._bin / "themes") 

355 

356 qrc_content.extend([" </qresource>", "</RCC>"]) 

357 

358 qrc_file_path = self._bin / "resources.qrc" 

359 # resources.qrc is a derived build artifact: always refresh it. 

360 with open(qrc_file_path, "w", encoding="utf-8") as f: 

361 f.write("\n".join(qrc_content)) 

362 

363 self._qrc_file = str(qrc_file_path) 

364 self.printer.action("[Initializer] Generated QRC file from bin folder content.") 

365 return True 

366 

367 def make_rc_py(self) -> None: 

368 """Compile the QRC file to a Python resource module via ``pyside6-rcc``.""" 

369 if not self._qrc_file: 

370 self.printer.warning("[Initializer] No QRC file") 

371 return 

372 

373 # resources_rc.py is a derived build artifact: always regenerate it. 

374 try: 

375 subprocess.run( 

376 ["pyside6-rcc", self._qrc_file, "-o", "resources_rc.py"], # noqa: S607 

377 cwd=self._bin, 

378 check=True, 

379 capture_output=True, 

380 ) 

381 self.printer.qrc_compilation_result(True) 

382 except subprocess.CalledProcessError as e: 

383 stderr_text = ( 

384 e.stderr.decode("utf-8", errors="replace") 

385 if isinstance(e.stderr, bytes | bytearray) 

386 else str(e.stderr or "") 

387 ) 

388 stdout_text = ( 

389 e.stdout.decode("utf-8", errors="replace") 

390 if isinstance(e.stdout, bytes | bytearray) 

391 else str(e.stdout or "") 

392 ) 

393 raise ResourceCompilationError( 

394 code=self._error_code("qrc_compilation_failed"), 

395 message="QRC compilation failed", 

396 context={ 

397 "details": str(e), 

398 "qrc_file": self._qrc_file, 

399 "stderr": stderr_text, 

400 "stdout": stdout_text, 

401 }, 

402 ) from e 

403 except FileNotFoundError as e: 

404 raise ResourceCompilationError( 

405 code=self._error_code("qrc_compilation_failed"), 

406 message="pyside6-rcc not found in PATH — install PySide6 or add it to your PATH", 

407 context={"tool": "pyside6-rcc", "qrc_file": self._qrc_file}, 

408 ) from e 

409 

410 def purge_rc_py(self) -> None: 

411 """Remove the generated ``resources_rc.py`` file.""" 

412 rc_py_path = self._bin / "resources_rc.py" 

413 if rc_py_path.exists(): 

414 rc_py_path.unlink() 

415 self.printer.warning("[Initializer] Purged resources_rc.py file.") 

416 

417 def make_app_icons_py(self) -> None: 

418 """Generate typed accessor modules for user-compiled icon and image resources. 

419 

420 Produces ``bin/app_icons.py`` (``AppIcons`` class) and 

421 ``bin/app_images.py`` (``AppImages`` class) from the files present in 

422 ``bin/icons/`` and ``bin/images/`` respectively. Each file becomes a 

423 class attribute whose value is the corresponding Qt resource path 

424 (e.g. ``:/icons/my_icon.png``). 

425 """ 

426 image_exts = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".ico", ".webp"} 

427 

428 for dir_name, class_name, prefix, out_name in [ 

429 ("icons", "AppIcons", ":/icons", "app_icons.py"), 

430 ("images", "AppImages", ":/images", "app_images.py"), 

431 ]: 

432 source_dir = self._bin / dir_name 

433 if not source_dir.exists(): 

434 continue 

435 

436 attrs: list[tuple[str, str]] = [] 

437 for file_path in sorted(source_dir.rglob("*")): 

438 if file_path.is_file() and file_path.suffix.lower() in image_exts: 

439 rel = file_path.relative_to(source_dir).as_posix() 

440 parts = list(file_path.relative_to(source_dir).parts) 

441 parts[-1] = Path(parts[-1]).stem 

442 attr = "_".join( 

443 p.replace("-", "_").replace(".", "_").replace(" ", "_") 

444 for p in parts 

445 ) 

446 attrs.append((attr, f"{prefix}/{rel}")) 

447 

448 lines = [ 

449 "# Auto-generated by ezqt_app — do not edit manually.", 

450 f"class {class_name}:", 

451 f' """Typed accessor for compiled Qt resources under ``{prefix}/``."""', 

452 "", 

453 ] 

454 if attrs: 

455 for attr, qt_path in attrs: 

456 lines.append(f' {attr}: str = "{qt_path}"') 

457 else: 

458 lines.append(" pass") 

459 lines.append("") 

460 

461 out_file = self._bin / out_name 

462 with open(out_file, "w", encoding="utf-8") as f: 

463 f.write("\n".join(lines)) 

464 

465 if attrs: 

466 self.printer.action( 

467 f"[Initializer] Generated {out_name} ({len(attrs)} entries)." 

468 ) 

469 else: 

470 self.printer.verbose_msg(f"[Initializer] Generated {out_name} (empty).") 

471 

472 def make_main_from_template(self, main_template: Path | None = None) -> None: 

473 """Copy the ``main.py`` project template into ``base_path``.""" 

474 if main_template is None: 

475 main_template = _PKG_ROOT / "resources" / "templates" / "main.py.template" 

476 

477 if not main_template.exists(): 

478 self.printer.warning(f"Main template not found at {main_template}") 

479 return 

480 

481 main_file = self.base_path / "main.py" 

482 if not self._should_write(main_file): 

483 return 

484 

485 shutil.copy2(main_template, main_file) 

486 self.printer.action("[Initializer] Generated main.py file.") 

487 

488 # ----------------------------------------------------------- 

489 # Accessors 

490 # ----------------------------------------------------------- 

491 

492 def get_bin_path(self) -> Path: 

493 """Return the ``bin/`` directory path.""" 

494 return self._bin 

495 

496 def get_qrc_file(self) -> str: 

497 """Return the generated QRC file path.""" 

498 return self._qrc_file 

499 

500 def get_resources_module_file(self) -> str: 

501 """Return the generated resources module file path.""" 

502 return self._resources_module_file