124 lines
3.8 KiB
Python
124 lines
3.8 KiB
Python
"""Single-source config loader for all Sanad subsystems.
|
|
|
|
Each subsystem (core, voice, motion, dashboard) has its own JSON file at
|
|
`config/<subsystem>_config.json`. This module loads them on demand, caches
|
|
the result, and exposes helpers for pulling nested sections.
|
|
|
|
Usage:
|
|
from Project.Sanad.core.config_loader import load, get
|
|
|
|
cfg = load("voice") # full voice config dict
|
|
threshold = get("voice", "barge_in.threshold", 500)
|
|
rates = get("voice", "sanad_voice", {}) # whole section
|
|
|
|
Why JSON (not TOML/YAML): standard library only, editable in any text
|
|
editor, commented via "_comment" keys. No third-party dep.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
log = logging.getLogger("config_loader")
|
|
|
|
# Resolved at first-load time (avoids circular import with config.py)
|
|
_BASE_DIR: Path | None = None
|
|
_CONFIG_DIR: Path | None = None
|
|
|
|
_CACHE: dict[str, dict[str, Any]] = {}
|
|
_LOCK = threading.Lock()
|
|
|
|
|
|
def _resolve_dirs() -> tuple[Path, Path]:
|
|
"""Find Sanad's root and config/ directory (lazy + cached)."""
|
|
global _BASE_DIR, _CONFIG_DIR
|
|
if _BASE_DIR is not None and _CONFIG_DIR is not None:
|
|
return _BASE_DIR, _CONFIG_DIR
|
|
here = Path(__file__).resolve().parent # Sanad/core
|
|
base = here.parent # Sanad/
|
|
_BASE_DIR = base
|
|
_CONFIG_DIR = base / "config"
|
|
return _BASE_DIR, _CONFIG_DIR
|
|
|
|
|
|
def _strip_comments(d: Any) -> Any:
|
|
"""Remove top-level "_comment"/"_description" keys — noise for callers."""
|
|
if isinstance(d, dict):
|
|
return {
|
|
k: _strip_comments(v) for k, v in d.items()
|
|
if not (isinstance(k, str) and k.startswith("_"))
|
|
}
|
|
if isinstance(d, list):
|
|
return [_strip_comments(x) for x in d]
|
|
return d
|
|
|
|
|
|
def load(subsystem: str) -> dict[str, Any]:
|
|
"""Load + cache config/<subsystem>_config.json.
|
|
|
|
Returns a dict with all leading-underscore keys stripped. Missing
|
|
file returns an empty dict (callers supply their own defaults via
|
|
`get(..., default)`).
|
|
"""
|
|
with _LOCK:
|
|
if subsystem in _CACHE:
|
|
return _CACHE[subsystem]
|
|
|
|
_, cfg_dir = _resolve_dirs()
|
|
path = cfg_dir / f"{subsystem}_config.json"
|
|
if not path.exists():
|
|
log.warning("config file missing: %s — using empty dict", path)
|
|
_CACHE[subsystem] = {}
|
|
return _CACHE[subsystem]
|
|
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
log.error("config file %s unreadable: %s", path, exc)
|
|
_CACHE[subsystem] = {}
|
|
return _CACHE[subsystem]
|
|
|
|
cleaned = _strip_comments(raw)
|
|
_CACHE[subsystem] = cleaned
|
|
return cleaned
|
|
|
|
|
|
def get(subsystem: str, dotted_key: str, default: Any = None) -> Any:
|
|
"""Fetch a nested key. Supports dotted-paths: 'barge_in.threshold'."""
|
|
cfg = load(subsystem)
|
|
parts = dotted_key.split(".")
|
|
cur: Any = cfg
|
|
for p in parts:
|
|
if not isinstance(cur, dict) or p not in cur:
|
|
return default
|
|
cur = cur[p]
|
|
return cur
|
|
|
|
|
|
def section(subsystem: str, name: str) -> dict[str, Any]:
|
|
"""Convenience — load one top-level section, always returning a dict.
|
|
|
|
Example: `section("voice", "sanad_voice")` → dict of that section.
|
|
"""
|
|
s = get(subsystem, name, {})
|
|
return s if isinstance(s, dict) else {}
|
|
|
|
|
|
def reload(subsystem: str | None = None) -> None:
|
|
"""Drop cached config so next load() re-reads from disk."""
|
|
with _LOCK:
|
|
if subsystem is None:
|
|
_CACHE.clear()
|
|
else:
|
|
_CACHE.pop(subsystem, None)
|
|
|
|
|
|
def config_dir() -> Path:
|
|
"""Absolute path to Sanad/config/."""
|
|
_, d = _resolve_dirs()
|
|
return d
|