Step 1 — defhelper: SX-defined page data helpers replace Python helpers. (defhelper name (params) body) in .sx files, using existing IO primitives (query, action, service). Loaded into OCaml kernel as pure SX defines. Step 2 — SX config: app-config.sx replaces app-config.yaml with (defconfig) form. (env-get "VAR") resolves secrets from environment. Kebab-to-underscore aliasing ensures backward compatibility with all 174 config consumers. Also: SXTP protocol spec (applications/sxtp/spec.sx), docs article, sx_nav move/delete modes, reactive-runtime moved to geography. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
176 lines
5.4 KiB
Python
176 lines
5.4 KiB
Python
# shared/config.py — SX-first config loader with YAML fallback
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from types import MappingProxyType
|
|
from typing import Any, Optional
|
|
import copy
|
|
|
|
# Default config paths (override with APP_CONFIG_FILE)
|
|
_DEFAULT_YAML_PATH = os.environ.get(
|
|
"APP_CONFIG_FILE",
|
|
os.path.join(os.getcwd(), "config/app-config.yaml"),
|
|
)
|
|
_DEFAULT_SX_PATH = os.environ.get(
|
|
"APP_CONFIG_SX_FILE",
|
|
os.path.join(os.getcwd(), "config/app-config.sx"),
|
|
)
|
|
|
|
# Module state
|
|
_init_lock = asyncio.Lock()
|
|
_data_frozen: Any = None # read-only view (mappingproxy / tuples / frozensets)
|
|
_data_plain: Any = None # plain builtins for pretty-print / logging
|
|
|
|
# ---------------- utils ----------------
|
|
def _freeze(obj: Any) -> Any:
|
|
"""Deep-freeze containers to read-only equivalents."""
|
|
if isinstance(obj, dict):
|
|
return MappingProxyType({k: _freeze(v) for k, v in obj.items()})
|
|
if isinstance(obj, list):
|
|
return tuple(_freeze(v) for v in obj)
|
|
if isinstance(obj, set):
|
|
return frozenset(_freeze(v) for v in obj)
|
|
if isinstance(obj, tuple):
|
|
return tuple(_freeze(v) for v in obj)
|
|
return obj
|
|
|
|
|
|
def _sx_to_dict(expr: Any) -> Any:
|
|
"""Convert parsed SX config values to plain Python dicts/lists.
|
|
|
|
- Keyword keys become strings (kebab-case preserved, also aliased to
|
|
underscore form for backward compatibility with YAML consumers).
|
|
- (env-get "VAR") calls are resolved to os.environ.
|
|
- Lists become plain Python lists.
|
|
- Everything else passes through as-is.
|
|
"""
|
|
from shared.sx.types import Keyword, Symbol
|
|
|
|
# (env-get "VAR") → os.environ.get("VAR")
|
|
if isinstance(expr, list) and len(expr) == 2:
|
|
head = expr[0]
|
|
if isinstance(head, Symbol) and head.name == "env-get":
|
|
var_name = str(expr[1])
|
|
return os.environ.get(var_name)
|
|
|
|
# dict with keyword keys
|
|
if isinstance(expr, dict):
|
|
result: dict[str, Any] = {}
|
|
for k, v in expr.items():
|
|
key = k if isinstance(k, str) else str(k)
|
|
val = _sx_to_dict(v)
|
|
result[key] = val
|
|
# Alias kebab-case → underscore for backward compat
|
|
underscore = key.replace("-", "_")
|
|
if underscore != key:
|
|
result[underscore] = val
|
|
return result
|
|
|
|
if isinstance(expr, list):
|
|
# Check for (env-get ...) first (already handled above for len==2)
|
|
return [_sx_to_dict(item) for item in expr]
|
|
|
|
if isinstance(expr, tuple):
|
|
return [_sx_to_dict(item) for item in expr]
|
|
|
|
if isinstance(expr, Keyword):
|
|
return str(expr)
|
|
|
|
if isinstance(expr, Symbol):
|
|
name = expr.name
|
|
if name == "true":
|
|
return True
|
|
if name == "false":
|
|
return False
|
|
if name == "nil":
|
|
return None
|
|
return name
|
|
|
|
return expr
|
|
|
|
|
|
def _load_sx_config(path: str) -> dict:
|
|
"""Load an SX config file and return a plain dict.
|
|
|
|
Expects a single (defconfig name body) form.
|
|
"""
|
|
from shared.sx.parser import parse_all
|
|
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
source = f.read()
|
|
|
|
exprs = parse_all(source)
|
|
for expr in exprs:
|
|
if (isinstance(expr, list) and len(expr) >= 3
|
|
and hasattr(expr[0], 'name') and expr[0].name == "defconfig"):
|
|
# (defconfig name {body})
|
|
body = expr[2]
|
|
return _sx_to_dict(body)
|
|
|
|
raise ValueError(f"No (defconfig ...) form found in {path}")
|
|
|
|
|
|
# ---------------- API ----------------
|
|
async def init_config(path: Optional[str] = None, *, force: bool = False) -> None:
|
|
"""
|
|
Load config and cache both a frozen (read-only) and a plain copy.
|
|
|
|
Prefers SX config (app-config.sx) when available, falls back to YAML.
|
|
Idempotent; pass force=True to reload.
|
|
"""
|
|
global _data_frozen, _data_plain
|
|
|
|
if _data_frozen is not None and not force:
|
|
return
|
|
|
|
async with _init_lock:
|
|
if _data_frozen is not None and not force:
|
|
return
|
|
|
|
# Try SX first, then YAML
|
|
sx_path = path if (path and path.endswith(".sx")) else _DEFAULT_SX_PATH
|
|
yaml_path = path if (path and not path.endswith(".sx")) else _DEFAULT_YAML_PATH
|
|
|
|
if os.path.exists(sx_path):
|
|
raw = _load_sx_config(sx_path)
|
|
elif os.path.exists(yaml_path):
|
|
import yaml
|
|
with open(yaml_path, "r", encoding="utf-8") as f:
|
|
raw = yaml.safe_load(f)
|
|
else:
|
|
raise FileNotFoundError(
|
|
f"No config found: tried {sx_path} and {yaml_path}")
|
|
|
|
_data_plain = raw
|
|
_data_frozen = _freeze(raw)
|
|
|
|
def config() -> Any:
|
|
"""
|
|
Return the read-only (frozen) config. Call init_config() first.
|
|
"""
|
|
if _data_frozen is None:
|
|
raise RuntimeError("init_config() has not been awaited yet.")
|
|
return _data_frozen
|
|
|
|
def as_plain() -> Any:
|
|
"""
|
|
Return a deep copy of the plain config for safe external use/pretty printing.
|
|
"""
|
|
if _data_plain is None:
|
|
raise RuntimeError("init_config() has not been awaited yet.")
|
|
return copy.deepcopy(_data_plain)
|
|
|
|
def pretty() -> str:
|
|
"""
|
|
Pretty string for logging. Uses YAML if available, else pprint.
|
|
"""
|
|
if _data_plain is None:
|
|
raise RuntimeError("init_config() has not been awaited yet.")
|
|
try:
|
|
import yaml
|
|
return yaml.safe_dump(_data_plain, sort_keys=False, allow_unicode=True)
|
|
except ImportError:
|
|
import pprint
|
|
return pprint.pformat(_data_plain)
|