e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
130 lines
5.0 KiB
Python
130 lines
5.0 KiB
Python
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
from threading import RLock
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import yaml
|
|
|
|
from ..services.config.loader import get_runtime_settings_dir
|
|
|
|
|
|
class ConfigManager:
|
|
"""
|
|
Minimal runtime YAML manager for `data/user/settings/main.yaml`.
|
|
|
|
The long-lived configuration model is now:
|
|
- `data/user/settings/model_catalog.json` for providers and credentials
|
|
- `data/user/settings/*.json` / `*.yaml` for runtime behavior
|
|
"""
|
|
|
|
_instance: Optional["ConfigManager"] = None
|
|
_lock = RLock()
|
|
|
|
def __new__(cls, project_root: Optional[Path] = None):
|
|
if cls._instance is None:
|
|
with cls._lock:
|
|
if cls._instance is None:
|
|
cls._instance = super(ConfigManager, cls).__new__(cls)
|
|
cls._instance._initialized = False
|
|
return cls._instance
|
|
|
|
def __init__(self, project_root: Optional[Path] = None):
|
|
if getattr(self, "_initialized", False):
|
|
return
|
|
|
|
self.project_root = project_root or Path(__file__).parent.parent.parent
|
|
self.config_path = get_runtime_settings_dir(self.project_root) / "main.yaml"
|
|
self._config_cache: Dict[str, Any] = {}
|
|
self._last_mtime: float = 0.0
|
|
self._initialized = True
|
|
|
|
def _read_yaml(self) -> Dict[str, Any]:
|
|
if not self.config_path.exists():
|
|
return {}
|
|
with open(self.config_path, "r", encoding="utf-8") as handle:
|
|
return yaml.safe_load(handle) or {}
|
|
|
|
def _deep_update(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
|
for key, value in source.items():
|
|
if isinstance(value, dict) and isinstance(target.get(key), dict):
|
|
self._deep_update(target[key], value)
|
|
else:
|
|
target[key] = value
|
|
|
|
def load_config(self, force_reload: bool = False) -> Dict[str, Any]:
|
|
with self._lock:
|
|
if not self.config_path.exists():
|
|
self._config_cache = {}
|
|
self._last_mtime = 0
|
|
return {}
|
|
|
|
current_mtime = self.config_path.stat().st_mtime
|
|
if not self._config_cache or force_reload or current_mtime > self._last_mtime:
|
|
self._config_cache = self._read_yaml()
|
|
self._last_mtime = current_mtime
|
|
|
|
return yaml.safe_load(yaml.safe_dump(self._config_cache, sort_keys=False)) or {}
|
|
|
|
def save_config(self, config: Dict[str, Any]) -> bool:
|
|
with self._lock:
|
|
current = self.load_config(force_reload=True)
|
|
self._deep_update(current, config)
|
|
|
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
yaml_str = yaml.safe_dump(
|
|
current,
|
|
default_flow_style=False,
|
|
allow_unicode=True,
|
|
sort_keys=False,
|
|
)
|
|
|
|
fd, tmp_path = tempfile.mkstemp(prefix="main.yaml.", dir=str(self.config_path.parent))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
|
|
tmp.write(yaml_str)
|
|
tmp.flush()
|
|
os.fsync(tmp.fileno())
|
|
os.replace(tmp_path, self.config_path)
|
|
self._config_cache = current
|
|
self._last_mtime = self.config_path.stat().st_mtime
|
|
return True
|
|
finally:
|
|
if os.path.exists(tmp_path):
|
|
try:
|
|
os.remove(tmp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
def get_env_info(self) -> Dict[str, str]:
|
|
return {"model": self._runtime_key_values().get("LLM_MODEL", "")}
|
|
|
|
def validate_required_env(self, keys: List[str]) -> Dict[str, List[str]]:
|
|
values = self._runtime_key_values()
|
|
missing = [key for key in keys if not values.get(key)]
|
|
return {"missing": missing}
|
|
|
|
def _runtime_key_values(self) -> Dict[str, str]:
|
|
from deeptutor.services.config.model_catalog import ModelCatalogService
|
|
from deeptutor.services.config.runtime_settings import RuntimeSettingsService
|
|
|
|
settings_dir = get_runtime_settings_dir(self.project_root)
|
|
catalog_service = ModelCatalogService(settings_dir / "model_catalog.json")
|
|
catalog = catalog_service.load()
|
|
llm_profile = catalog_service.get_active_profile(catalog, "llm") or {}
|
|
llm_model = catalog_service.get_active_model(catalog, "llm") or {}
|
|
system = RuntimeSettingsService.get_instance(settings_dir).load_system()
|
|
return {
|
|
"BACKEND_PORT": str(system["backend_port"]),
|
|
"FRONTEND_PORT": str(system["frontend_port"]),
|
|
"LLM_BINDING": str(llm_profile.get("binding") or ""),
|
|
"LLM_MODEL": str(llm_model.get("model") or ""),
|
|
"LLM_API_KEY": str(llm_profile.get("api_key") or ""),
|
|
"LLM_HOST": str(llm_profile.get("base_url") or ""),
|
|
}
|
|
|
|
@classmethod
|
|
def reset_for_tests(cls) -> None:
|
|
with cls._lock:
|
|
cls._instance = None
|