Coverage for src / ezqt_app / services / config / config_service.py: 48.79%
184 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 07:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 07:07 +0000
1# ///////////////////////////////////////////////////////////////
2# SERVICES.CONFIG.CONFIG_SERVICE - Config service implementation
3# Project: ezqt_app
4# ///////////////////////////////////////////////////////////////
6"""Configuration service — full implementation (absorbs ConfigManager logic)."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13# Standard library imports
14import shutil
15import sys
16from importlib.resources import files as _pkg_files
17from pathlib import Path
18from typing import Any
20# Third-party imports
21import yaml
23# Local imports
24from ...domain.ports.config_service import ConfigServiceProtocol
25from ...utils.diagnostics import warn_user
26from ...utils.printer import get_printer
27from ...utils.runtime_paths import APP_PATH, get_bin_path
30# ///////////////////////////////////////////////////////////////
31# CLASSES
32# ///////////////////////////////////////////////////////////////
33class ConfigService(ConfigServiceProtocol):
34 """Modular configuration service for EzQt_App."""
36 def __init__(self):
37 self._config_cache: dict[str, Any] = {}
38 self._config_files: dict[str, Path] = {}
39 self._project_root: Path | None = None
41 # -----------------------------------------------------------
42 # Port methods (ConfigServiceProtocol)
43 # -----------------------------------------------------------
45 def set_project_root(self, project_root: Path | str) -> None:
46 """Set the active project root directory."""
47 self._project_root = (
48 project_root if isinstance(project_root, Path) else Path(project_root)
49 )
50 from ...utils.runtime_paths import _sync_bin_path_from_root
52 _sync_bin_path_from_root(self._project_root / "bin")
54 def load_config(
55 self, config_name: str, force_reload: bool = False
56 ) -> dict[str, Any]:
57 """Load a named configuration from the first matching path.
59 Parameters
60 ----------
61 config_name:
62 Configuration file name (without extension, e.g. ``"app"``).
63 force_reload:
64 Bypass cache and reload from disk.
66 Returns
67 -------
68 dict[str, Any]
69 Loaded configuration data, or empty dict on failure.
70 """
71 if not force_reload and config_name in self._config_cache: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 return self._config_cache[config_name]
74 config_file = self._resolve_config_file(config_name)
76 if not config_file:
77 warn_user(
78 code="config.service.missing_file",
79 user_message=f"No configuration file found for '{config_name}'",
80 log_message=f"No configuration file found for '{config_name}'",
81 )
82 get_printer().verbose_msg(
83 f"Searched paths: {self.get_config_paths(config_name)}"
84 )
85 return {}
87 try:
88 with open(config_file, encoding="utf-8") as f:
89 config_data = yaml.safe_load(f)
91 self._config_cache[config_name] = config_data
92 self._config_files[config_name] = config_file
94 get_printer().verbose_msg(
95 f"Configuration '{config_name}' loaded from: {config_file}"
96 )
97 return config_data
99 except Exception as e:
100 get_printer().error(f"Error loading '{config_name}': {e}")
101 return {}
103 def get_config_value(
104 self, config_name: str, key_path: str, default: Any = None
105 ) -> Any:
106 """Read a specific value from a configuration using dot-notation key path.
108 Parameters
109 ----------
110 config_name:
111 Configuration file name.
112 key_path:
113 Dot-separated path (e.g. ``"app.name"`` or ``"palette.dark"``).
114 default:
115 Value returned when key is absent.
116 """
117 config = self.load_config(config_name)
118 keys = key_path.split(".")
119 current = config
121 for key in keys:
122 if isinstance(current, dict) and key in current: 122 ↛ 125line 122 didn't jump to line 125 because the condition on line 122 was always true
123 current = current[key]
124 else:
125 return default
127 return current
129 def save_config(self, config_name: str, config_data: dict[str, Any]) -> bool:
130 """Persist a named configuration to the project directory.
132 Parameters
133 ----------
134 config_name:
135 Configuration file name.
136 config_data:
137 Data to serialise as YAML.
139 Returns
140 -------
141 bool
142 ``True`` if the write succeeded.
143 """
144 config_file: Path
145 if config_name in self._config_files:
146 # Keep writes consistent with the file originally loaded.
147 config_file = self._config_files[config_name]
148 config_file.parent.mkdir(parents=True, exist_ok=True)
149 else:
150 config_dir = get_bin_path() / "config"
151 config_dir.mkdir(parents=True, exist_ok=True)
152 config_file = config_dir / f"{config_name}.config.yaml"
154 try:
155 with open(config_file, "w", encoding="utf-8") as f:
156 yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
158 self._config_cache[config_name] = config_data
159 self._config_files[config_name] = config_file
161 get_printer().verbose_msg(
162 f"Configuration '{config_name}' saved: {config_file}"
163 )
164 return True
166 except Exception as e:
167 get_printer().error(f"Error saving '{config_name}': {e}")
168 return False
170 # -----------------------------------------------------------
171 # Implementation-specific methods
172 # -----------------------------------------------------------
174 def get_config_paths(self, config_name: str) -> list[Path]:
175 """Return candidate paths for *config_name* in priority order.
177 Parameters
178 ----------
179 config_name:
180 Logical configuration name (e.g. ``"app"``, ``"languages"``,
181 ``"theme"``). Files are resolved as ``<name>.config.yaml``.
182 """
183 config_file = f"{config_name}.config.yaml"
184 paths: list[Path] = []
186 # 1. Configured bin directory
187 paths.append(get_bin_path() / "config" / config_file)
189 # 3. Package resources — relative to cwd
190 paths.append(Path.cwd() / "ezqt_app" / "resources" / "config" / config_file)
192 # 4. Package resources — relative to APP_PATH
193 paths.append(APP_PATH / "resources" / "config" / config_file)
195 return paths
197 def copy_package_configs_to_project(self) -> bool:
198 """Copy package configuration files into the child project.
200 Returns
201 -------
202 bool
203 ``True`` if the operation succeeded.
204 """
205 if not self._project_root:
206 get_printer().error("[ConfigService] No project root defined")
207 return False
209 package_config_dir = self._find_package_config_dir()
211 if not package_config_dir:
212 get_printer().error(
213 f"[ConfigService] EzQt_App package not found. Tested paths: "
214 f"{[str(p) for p in [Path.cwd(), APP_PATH]]}"
215 )
216 return False
218 project_config_dir = get_bin_path() / "config"
219 project_config_dir.mkdir(parents=True, exist_ok=True)
221 copied_files: list[str] = []
223 try:
224 for config_file in package_config_dir.glob("*.yaml"):
225 target_file = project_config_dir / config_file.name
227 if target_file.exists():
228 get_printer().verbose_msg(f"Existing file, ignored: {target_file}")
229 continue
231 shutil.copy2(config_file, target_file)
232 copied_files.append(config_file.name)
233 get_printer().action(
234 f"[ConfigService] Configuration copied: {config_file.name}"
235 )
237 if copied_files:
238 get_printer().action(
239 "[ConfigService] "
240 f"{len(copied_files)} configurations copied to project"
241 )
243 return True
245 except Exception as e:
246 get_printer().error(f"Error copying configurations: {e}")
247 return False
249 def clear_cache(self) -> None:
250 """Invalidate the in-memory configuration cache."""
251 self._config_cache.clear()
252 self._config_files.clear()
253 get_printer().verbose_msg("Configuration cache cleared")
255 def get_loaded_configs(self) -> dict[str, Path]:
256 """Return a snapshot of currently cached configuration paths."""
257 return self._config_files.copy()
259 # -----------------------------------------------------------
260 # Private helpers
261 # -----------------------------------------------------------
263 def _resolve_config_file(self, config_name: str) -> Path | None:
264 """Return the first existing path for *config_name*, or ``None``."""
265 for path in self.get_config_paths(config_name):
266 if path.exists():
267 return path
268 return None
270 def _find_package_config_dir(self) -> Path | None:
271 """Locate the ``resources/config`` directory inside the installed package."""
272 package_root = _get_installed_package_root()
273 if package_root is not None:
274 installed_config_dir = package_root / "resources" / "config"
275 if installed_config_dir.exists():
276 return installed_config_dir
278 # 1. Walk up from cwd looking for an ezqt_app package dir
279 current = Path.cwd()
280 while current.parent != current:
281 candidate = current / "ezqt_app"
282 if candidate.exists() and (candidate / "resources" / "config").exists():
283 config_dir = candidate / "resources" / "config"
284 get_printer().verbose_msg(f"EzQt_App package found: {candidate}")
285 get_printer().verbose_msg(f"Configuration directory: {config_dir}")
286 return config_dir
287 current = current.parent
289 # 2. Search sys.path entries
290 for path in sys.path:
291 candidate = Path(path) / "ezqt_app"
292 if candidate.exists() and (candidate / "resources" / "config").exists():
293 return candidate / "resources" / "config"
295 # 3. Fallback — legacy known paths
296 for fallback in [
297 Path.cwd() / "ezqt_app" / "resources" / "config",
298 APP_PATH / "resources" / "config",
299 APP_PATH / "ezqt_app" / "resources" / "config",
300 ]:
301 if fallback.exists():
302 get_printer().verbose_msg(
303 f"Configuration directory found (fallback): {fallback}"
304 )
305 return fallback
307 return None
310# ///////////////////////////////////////////////////////////////
311# SINGLETONS / FUNCTIONS
312# ///////////////////////////////////////////////////////////////
315def _get_installed_package_root() -> Path | None:
316 """Return installed ``ezqt_app`` package root when discoverable."""
317 try:
318 import importlib.util
320 spec = importlib.util.find_spec("ezqt_app")
321 if spec is not None: 321 ↛ 337line 321 didn't jump to line 337 because the condition on line 321 was always true
322 if spec.submodule_search_locations: 322 ↛ 328line 322 didn't jump to line 328 because the condition on line 322 was always true
323 location = next(iter(spec.submodule_search_locations), None)
324 if location: 324 ↛ 328line 324 didn't jump to line 328 because the condition on line 324 was always true
325 candidate = Path(location)
326 if candidate.exists(): 326 ↛ 328line 326 didn't jump to line 328 because the condition on line 326 was always true
327 return candidate
328 if spec.origin:
329 candidate = Path(spec.origin).resolve().parent
330 if candidate.exists():
331 return candidate
332 except Exception as e:
333 get_printer().verbose_msg(
334 f"Could not resolve installed package root for ezqt_app: {e}"
335 )
337 return None
340def _resource_candidates(resource_path: str) -> list[Path]:
341 """Build candidate paths for a package resource in priority order."""
342 rel_path = Path(resource_path.replace("\\", "/"))
343 candidates: list[Path] = []
345 package_root = _get_installed_package_root()
346 if package_root is not None: 346 ↛ 349line 346 didn't jump to line 349 because the condition on line 346 was always true
347 candidates.append(package_root / rel_path)
349 candidates.extend(
350 [
351 APP_PATH / "ezqt_app" / rel_path,
352 APP_PATH / rel_path,
353 Path.cwd() / "ezqt_app" / rel_path,
354 ]
355 )
356 return candidates
359# ///////////////////////////////////////////////////////////////
360# FUNCTIONS
361# ///////////////////////////////////////////////////////////////
362def get_config_service() -> ConfigService:
363 """Return the singleton configuration service instance."""
364 from .._registry import ServiceRegistry
366 return ServiceRegistry.get(ConfigService, ConfigService)
369def get_package_resource(resource_path: str) -> Path:
370 """Return the filesystem path of an installed package resource.
372 Parameters
373 ----------
374 resource_path:
375 Resource path relative to the ``ezqt_app`` package root.
377 Returns
378 -------
379 Path
380 Resolved path to the resource.
381 """
382 for candidate in _resource_candidates(resource_path): 382 ↛ 386line 382 didn't jump to line 386 because the loop on line 382 didn't complete
383 if candidate.exists(): 383 ↛ 382line 383 didn't jump to line 382 because the condition on line 383 was always true
384 return candidate
386 try:
387 pkg_candidate = Path(str(_pkg_files("ezqt_app").joinpath(resource_path)))
388 if pkg_candidate.exists():
389 return pkg_candidate
390 except Exception as e:
391 get_printer().verbose_msg(
392 f"importlib.resources lookup failed for '{resource_path}': {e}"
393 )
395 package_root = _get_installed_package_root()
396 if package_root is not None:
397 return package_root / Path(resource_path.replace("\\", "/"))
398 return APP_PATH / "ezqt_app" / Path(resource_path.replace("\\", "/"))
401def get_package_resource_content(resource_path: str) -> str:
402 """Return the decoded UTF-8 content of an installed package resource.
404 Parameters
405 ----------
406 resource_path:
407 Resource path relative to the ``ezqt_app`` package root.
409 Returns
410 -------
411 str
412 Resource file content.
414 Raises
415 ------
416 FileNotFoundError
417 If the resource cannot be located by any strategy.
418 """
419 try:
420 return (
421 _pkg_files("ezqt_app").joinpath(resource_path).read_text(encoding="utf-8")
422 )
423 except Exception:
424 file_path = get_package_resource(resource_path)
425 if file_path.exists():
426 with open(file_path, encoding="utf-8") as f:
427 return f.read()
428 raise FileNotFoundError(f"Resource not found: {resource_path}") from None