Coverage for src / ezqt_app / services / application / file_service.py: 64.67%
240 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 13:12 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 13:12 +0000
1# ///////////////////////////////////////////////////////////////
2# SERVICES.APPLICATION.FILE_SERVICE - File generation service
3# Project: ezqt_app
4# ///////////////////////////////////////////////////////////////
6"""File and resource generation service for EzQt_App projects."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13# Standard library imports
14import shutil
15import subprocess # nosec B404
16from pathlib import Path
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
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
33# ///////////////////////////////////////////////////////////////
34# CLASSES
35# ///////////////////////////////////////////////////////////////
36class FileService:
37 """Handles file and resource generation for EzQt_App projects.
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 """
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 }
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)
76 # -----------------------------------------------------------
77 # Error Code
78 # -----------------------------------------------------------
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}"
88 # -----------------------------------------------------------
89 # Overwrite handling
90 # -----------------------------------------------------------
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
97 if self._overwrite_policy == "force":
98 return True
100 if self._overwrite_policy == "skip":
101 self.printer.verbose_msg(f"Skipping existing file: {target_path}")
102 return False
104 # Default "ask": prompt interactively, otherwise skip for safety.
105 try:
106 import click
108 if click.get_current_context(silent=True) is not None: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
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 )
115 self.printer.verbose_msg(f"Skipping existing file (ask policy): {target_path}")
116 return False
118 # -----------------------------------------------------------
119 # High-level orchestrators
120 # -----------------------------------------------------------
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
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
166 # -----------------------------------------------------------
167 # Directory / file creation
168 # -----------------------------------------------------------
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 ]
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)
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])
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: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true
198 yaml_package = _PKG_ROOT / "resources" / "config" / "app.config.yaml"
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 )
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): 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 return target_path
211 shutil.copy2(yaml_package, target_path)
212 self.printer.action("[Initializer] Generated YAML config file.")
213 return target_path
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: 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 theme_package = _PKG_ROOT / "resources" / "themes"
220 if not theme_package.exists(): 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
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 )
227 target_path = self._bin / "themes"
229 try:
230 target_path.mkdir(parents=True, exist_ok=True)
232 if theme_package.is_file():
233 if theme_package.name == "qtstrap.qss": 233 ↛ 238line 233 didn't jump to line 238 because the condition on line 233 was always true
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): 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true
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 )
272 if copied_files: 272 ↛ 276line 272 didn't jump to line 276 because the condition on line 272 was always true
273 self.printer.action("[Initializer] Generated QSS theme files.")
274 return True
276 existing = list(target_path.glob("*.qss"))
277 if existing:
278 self.printer.info("[Initializer] QSS theme files already exist.")
279 return True
281 self.printer.warning(
282 "[Initializer] No QSS theme files were copied successfully."
283 )
284 return False
286 except Exception as e:
287 self.printer.error(f"Error copying theme files: {e}")
288 return False
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: 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true
295 translations_package = _PKG_ROOT / "resources" / "translations"
297 if not translations_package.exists(): 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true
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 )
304 target_path = self._bin / "translations"
306 try:
307 target_path.mkdir(parents=True, exist_ok=True)
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): 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true
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 )
323 if any(target_path.glob("*.ts")): 323 ↛ 327line 323 didn't jump to line 327 because the condition on line 323 was always true
324 self.printer.action("[Initializer] Generated translation files.")
325 return True
327 self.printer.warning(
328 "[Initializer] No translation files were copied successfully."
329 )
330 return False
332 except Exception as e:
333 self.printer.error(f"Error copying translation files: {e}")
334 return False
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 ]
344 def _add_qresource(directory: Path) -> None:
345 if directory.exists(): 345 ↛ exitline 345 didn't return from function '_add_qresource' because the condition on line 345 was always true
346 for file_path in directory.rglob("*"):
347 if file_path.is_file(): 347 ↛ 346line 347 didn't jump to line 346 because the condition on line 347 was always true
348 relative_path = file_path.relative_to(self._bin).as_posix()
349 qrc_content.append(f" <file>{relative_path}</file>")
351 _add_qresource(self._bin / "fonts")
352 _add_qresource(self._bin / "images")
353 _add_qresource(self._bin / "icons")
354 _add_qresource(self._bin / "themes")
356 qrc_content.extend([" </qresource>", "</RCC>"])
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))
363 self._qrc_file = str(qrc_file_path)
364 self.printer.action("[Initializer] Generated QRC file from bin folder content.")
365 return True
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
373 # resources_rc.py is a derived build artifact: always regenerate it.
374 try:
375 subprocess.run( # nosec B603 B607
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
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(): 413 ↛ exitline 413 didn't return from function 'purge_rc_py' because the condition on line 413 was always true
414 rc_py_path.unlink()
415 self.printer.warning("[Initializer] Purged resources_rc.py file.")
417 def make_app_icons_py(self) -> None:
418 """Generate typed accessor modules for user-compiled icon and image resources.
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"}
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(): 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true
434 continue
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}"))
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: 454 ↛ 458line 454 didn't jump to line 458 because the condition on line 454 was always true
455 for attr, qt_path in attrs:
456 lines.append(f' {attr}: str = "{qt_path}"')
457 else:
458 lines.append(" pass")
459 lines.append("")
461 out_file = self._bin / out_name
462 with open(out_file, "w", encoding="utf-8") as f:
463 f.write("\n".join(lines))
465 if attrs: 465 ↛ 470line 465 didn't jump to line 470 because the condition on line 465 was always true
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).")
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: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 main_template = _PKG_ROOT / "resources" / "templates" / "main.py.template"
477 if not main_template.exists(): 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true
478 self.printer.warning(f"Main template not found at {main_template}")
479 return
481 main_file = self.base_path / "main.py"
482 if not self._should_write(main_file): 482 ↛ 483line 482 didn't jump to line 483 because the condition on line 482 was never true
483 return
485 shutil.copy2(main_template, main_file)
486 self.printer.action("[Initializer] Generated main.py file.")
488 # -----------------------------------------------------------
489 # Accessors
490 # -----------------------------------------------------------
492 def get_bin_path(self) -> Path:
493 """Return the ``bin/`` directory path."""
494 return self._bin
496 def get_qrc_file(self) -> str:
497 """Return the generated QRC file path."""
498 return self._qrc_file
500 def get_resources_module_file(self) -> str:
501 """Return the generated resources module file path."""
502 return self._resources_module_file