Open raw ↗"""Configuration manager for HAD Digital MVP.
Reads config.json with sensible defaults. All settings can be overridden
via environment variables with the HAD_ prefix.
"""
import json
import os
import sys
from pathlib import Path
def get_bundle_dir() -> Path:
"""Get read-only bundle directory (where static assets, rules, guidance live)."""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS)
return Path(__file__).resolve().parent
def get_data_dir() -> Path:
"""Get writable persistent data directory (where SQLite DB, logs live)."""
if getattr(sys, "frozen", False):
exe_dir = Path(sys.executable).resolve().parent
test_file = exe_dir / ".write_test"
try:
test_file.touch()
test_file.unlink()
return exe_dir / "data"
except (PermissionError, OSError):
local_appdata = os.environ.get("LOCALAPPDATA")
if local_appdata:
return Path(local_appdata) / "HAD Digital" / "data"
return Path.home() / ".had_digital" / "data"
return Path(__file__).resolve().parent / "data"
BUNDLE_DIR = get_bundle_dir()
DATA_DIR = get_data_dir()
BASE_DIR = BUNDLE_DIR
DEFAULT_CONFIG = {
"app_name": "HAD Digital MVP",
"version": "1.0.0",
"host": "127.0.0.1",
"port": 8080,
"debug": False,
"database": {
"path": str(DATA_DIR / "had.db"),
"wal_mode": True,
},
"session": {
"cookie_name": "HAD_SESSION",
"max_age_seconds": 86400, # 24 hours
"secret_env": "HAD_SESSION_SECRET",
},
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
"x_content_type_options": "nosniff",
"x_frame_options": "DENY",
"referrer_policy": "strict-origin-when-cross-origin",
},
"ctcae": {
"rules_path": str(BUNDLE_DIR / "data" / "ctcae_rules.json"),
},
"guidance": {
"path": str(BUNDLE_DIR / "guidance" / "guidance.json"),
},
"alerting": {
"urgent_delay_minutes": 30,
"emergency_delay_minutes": 0,
},
"ai": {
"adapter": "stub",
"glm_api_key_env": "GLM_API_KEY",
"glm_model": "glm-4-flash",
"glm_endpoint": "https://open.bigmodel.cn/api/paas/v4/chat/completions",
},
"demo": {
"auto_seed": True,
},
}
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into base."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
class ConfigManager:
"""Singleton configuration manager."""
_instance = None
_config = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def load(self, config_path: str | None = None) -> dict:
"""Load configuration from file, merged with defaults."""
if self._config is not None:
return self._config
config = DEFAULT_CONFIG.copy()
# Load from file if exists
if config_path is None:
if (DATA_DIR / "config.json").exists():
config_path = str(DATA_DIR / "config.json")
else:
config_path = str(BUNDLE_DIR / "config.json")
if os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
file_config = json.load(f)
config = _deep_merge(config, file_config)
except (json.JSONDecodeError, OSError):
pass # Use defaults if file is malformed
# Environment variable overrides
env_overrides = {
"HAD_HOST": ("host", str),
"HAD_PORT": ("port", int),
"HAD_DEBUG": ("debug", lambda x: x.lower() in ("true", "1", "yes")),
"HAD_DB_PATH": (("database", "path"), str),
"HAD_SESSION_SECRET": (("session", "secret_value"), str),
"HAD_AI_ADAPTER": (("ai", "adapter"), str),
"GLM_API_KEY": (("ai", "glm_api_key_value"), str),
}
for env_var, (key_path, converter) in env_overrides.items():
value = os.environ.get(env_var)
if value is not None:
try:
converted = converter(value)
if isinstance(key_path, tuple):
config[key_path[0]][key_path[1]] = converted
else:
config[key_path] = converted
except (ValueError, KeyError):
pass
self._config = config
return config
def get(self, *keys, default=None):
"""Get a nested config value by keys."""
if self._config is None:
self.load()
current = self._config
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return default
return current
def reload(self):
"""Force reload configuration."""
self._config = None
return self.load()
# Global singleton
config = ConfigManager()