Source code for corvix.web.runtime_config
"""Runtime ``AppConfig`` cache and dashboard selection helpers.
AppConfig is expensive to produce (YAML read + full parse). We cache the
result at module level and invalidate it only when the config file's mtime
changes. A plain stat() per request is orders of magnitude cheaper than a
full YAML re-parse, so we still detect on-disk edits without re-reading
unconditionally on every HTTP request.
The three cache fields live in a single mutable object so they can be
updated without ``global`` statements (which ruff PLW0603 flags).
"""
from __future__ import annotations
import logging
import signal
from os import environ
from pathlib import Path
from litestar.exceptions import HTTPException
from corvix.config import AppConfig, DashboardSpec, available_dashboards, load_config
[docs]
logger = logging.getLogger(__name__)
[docs]
class _ConfigCache:
"""Mutable container for the module-level AppConfig cache."""
[docs]
config: AppConfig | None = None
[docs]
path: str | None = None
[docs]
mtime: float | None = None
[docs]
_config_cache = _ConfigCache()
[docs]
def _clear_config_cache() -> None:
"""Discard the cached AppConfig so the next request reloads from disk."""
_config_cache.config = None
_config_cache.path = None
_config_cache.mtime = None
logger.info("Config cache cleared; config will be reloaded on the next request.")
[docs]
def _load_runtime_config() -> AppConfig:
"""Return the cached AppConfig, re-parsing from disk only when the file changes.
Config is read from the path in the ``CORVIX_CONFIG`` environment variable
(default: ``corvix.yaml``). The file's mtime is checked on every call; the
YAML is only re-parsed when either the path or the mtime differs from the
last successful load, eliminating redundant I/O on every request.
"""
config_path = Path(environ.get("CORVIX_CONFIG", "corvix.yaml"))
config_path_str = str(config_path)
if not config_path.exists():
msg = f"Config file '{config_path}' does not exist."
raise HTTPException(status_code=500, detail=msg)
try:
mtime = config_path.stat().st_mtime
except OSError as error:
msg = f"Unable to read config at '{config_path}': {error}"
raise HTTPException(status_code=500, detail=msg) from error
if _config_cache.config is not None and _config_cache.path == config_path_str and _config_cache.mtime == mtime:
return _config_cache.config
try:
config = load_config(config_path)
except ValueError as error:
msg = f"Invalid config at '{config_path}': {error}"
raise HTTPException(status_code=500, detail=msg) from error
except OSError as error:
msg = f"Unable to read config at '{config_path}': {error}"
raise HTTPException(status_code=500, detail=msg) from error
_config_cache.config = config
_config_cache.path = config_path_str
_config_cache.mtime = mtime
logger.debug("Config loaded from '%s' (mtime=%.3f).", config_path, mtime)
return config
[docs]
def _install_sighup_handler() -> None:
"""Register a SIGHUP handler that clears the config cache.
Sending ``SIGHUP`` to the server process forces the config to be reloaded
from disk on the next request without restarting the process::
kill -HUP <pid>
The handler is a no-op on platforms that do not support SIGHUP (e.g. Windows).
"""
try:
signal.signal(signal.SIGHUP, lambda _sig, _frame: _clear_config_cache())
logger.debug("SIGHUP handler installed for config reload.")
except (AttributeError, OSError):
pass # SIGHUP is not available on all platforms (e.g. Windows)
_install_sighup_handler()
[docs]
def _select_dashboard(
dashboards: list[DashboardSpec],
selected_name: str | None,
) -> DashboardSpec:
available = available_dashboards(dashboards)
if selected_name is None:
return available[0]
for dashboard in available:
if dashboard.name == selected_name:
return dashboard
msg = f"Dashboard '{selected_name}' not found."
raise HTTPException(status_code=404, detail=msg)
[docs]
def _dashboard_names(dashboards: list[DashboardSpec]) -> list[str]:
available = available_dashboards(dashboards)
return [dashboard.name for dashboard in available]