"""`load_config` — the single entrypoint that materialises `Config` at startup. Implements the `composition_root_protocol` contract v1.0.0 (E-CC-CONF / AZ-269 / AZ-246). Precedence (highest -> lowest): 1. Environment variables (``env`` argument). 2. YAML files (``paths``), in order — later paths override earlier ones. 3. Documented defaults baked into the cross-cutting dataclasses. The returned `Config` is frozen end-to-end. Required env vars that fail to resolve raise `RequiredFieldMissingError` with the name of the offending variable and a pointer at ``.env.example``. """ from __future__ import annotations from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any, Final import yaml from gps_denied_onboard.config.schema import ( _COMPONENT_REGISTRY, Config, FcConfig, FdrConfig, GcsConfig, LogConfig, RequiredFieldMissingError, RuntimeConfig, _replace_block, _resolve_component_blocks, ) __all__ = ["ENV_KEY_MAP", "load_config"] # Env-var -> (block, field) mapping. The composition root reads env vars # through this table so the YAML path and the env path stay in sync. ENV_KEY_MAP: Final[dict[str, tuple[str, str]]] = { # Cross-cutting blocks "GPS_DENIED_FC_PROFILE": ("runtime", "fc_profile"), "GPS_DENIED_TIER": ("runtime", "tier"), "DB_URL": ("runtime", "db_url"), "CAMERA_CALIBRATION_PATH": ("runtime", "camera_calibration_path"), "INFERENCE_BACKEND": ("runtime", "inference_backend"), "TILE_CACHE_PATH": ("runtime", "tile_cache_path"), "LOG_LEVEL": ("log", "level"), "LOG_TIER": ("log", "tier"), "LOG_SINK": ("log", "sink"), "FDR_PATH": ("fdr", "path"), "FDR_QUEUE_SIZE": ("fdr", "queue_size"), # C8 FC + GCS adapter blocks (AZ-390) "FC_ADAPTER": ("fc", "adapter"), "FC_PORT_DEVICE": ("fc", "port_device"), "FC_PORT_BAUD": ("fc", "port_baud"), "FC_SIGNING_KEY_SOURCE": ("fc", "signing_key_source"), "FC_DEV_STATIC_SIGNING_KEY": ("fc", "dev_static_signing_key"), "FC_SIGNING_FAILURE_THRESHOLD": ("fc", "signing_failure_threshold"), "GCS_ADAPTER": ("gcs", "adapter"), "GCS_PORT_DEVICE": ("gcs", "port_device"), "GCS_PORT_BAUD": ("gcs", "port_baud"), "GCS_SUMMARY_RATE_HZ": ("gcs", "summary_rate_hz"), } # Env vars that MUST resolve to a non-empty value before `load_config` # can return (per AZ-263 AC-8 + AZ-269 AC-6). Missing values trigger # `RequiredFieldMissingError` with the variable name in the message. _REQUIRED_ENV_VARS: Final[tuple[str, ...]] = ( "GPS_DENIED_FC_PROFILE", "GPS_DENIED_TIER", "DB_URL", "CAMERA_CALIBRATION_PATH", "LOG_LEVEL", "LOG_SINK", "INFERENCE_BACKEND", "FDR_PATH", "TILE_CACHE_PATH", ) # Field-name -> python type. We coerce string env vars + raw YAML scalars # into the dataclass's declared types so `Config.runtime.tier` is always # `int` regardless of source. _FIELD_COERCIONS: Final[dict[str, type]] = { "tier": int, "queue_size": int, "level": str, "sink": str, "path": str, "fc_profile": str, "db_url": str, "camera_calibration_path": str, "inference_backend": str, "tile_cache_path": str, "overrun_policy": str, # C8 FC + GCS adapter coercions (AZ-390) "adapter": str, "port_device": str, "port_baud": int, "signing_key_source": str, "dev_static_signing_key": str, "signing_failure_threshold": int, "summary_rate_hz": float, } def _coerce_value(field_name: str, raw: Any) -> Any: target_type = _FIELD_COERCIONS.get(field_name) if target_type is None or isinstance(raw, target_type): return raw try: return target_type(raw) except (TypeError, ValueError) as exc: raise RequiredFieldMissingError( f"config field {field_name!r}: cannot coerce {raw!r} to {target_type.__name__} ({exc})" ) from exc def _load_yaml_files(paths: Sequence[Path]) -> dict[str, dict[str, Any]]: """Merge YAML files in order: later paths win for the same block + field.""" merged: dict[str, dict[str, Any]] = {} for path in paths: data = yaml.safe_load(path.read_text()) or {} if not isinstance(data, dict): raise RequiredFieldMissingError( f"YAML at {path} must be a mapping at the top level; got {type(data).__name__}" ) for block_name, block_value in data.items(): if not isinstance(block_value, dict): continue merged.setdefault(block_name, {}).update(block_value) return merged def _apply_env_overrides(layered: dict[str, dict[str, Any]], env: Mapping[str, str]) -> None: """Overlay env-var values on the per-block override dictionaries.""" for env_key, (block_name, field_name) in ENV_KEY_MAP.items(): if env_key not in env: continue layered.setdefault(block_name, {})[field_name] = env[env_key] def _check_required_env(env: Mapping[str, str]) -> None: """AC-6 + AZ-263 AC-8: missing required vars fail fast with a pointer.""" missing = [name for name in _REQUIRED_ENV_VARS if not env.get(name)] if missing: raise RequiredFieldMissingError( "Missing required environment variable(s): " + ", ".join(missing) + ". See `.env.example` for the documented set." ) def load_config( env: Mapping[str, str], paths: Sequence[Path] = (), *, require_env: bool = True, ) -> Config: """Build a frozen `Config` from env + YAML files + documented defaults. Precedence: env > YAML > defaults. `paths` may be empty; missing keys fall to the dataclass-declared defaults. """ if require_env: _check_required_env(env) yaml_overrides = _load_yaml_files(paths) if paths else {} _apply_env_overrides(yaml_overrides, env) runtime_block = _replace_block( RuntimeConfig(), {k: _coerce_value(k, v) for k, v in yaml_overrides.get("runtime", {}).items()}, ) log_block = _replace_block( LogConfig(), {k: _coerce_value(k, v) for k, v in yaml_overrides.get("log", {}).items()}, ) fdr_block = _replace_block( FdrConfig(), {k: _coerce_value(k, v) for k, v in yaml_overrides.get("fdr", {}).items()}, ) fc_block = _replace_block( FcConfig(), {k: _coerce_value(k, v) for k, v in yaml_overrides.get("fc", {}).items()}, ) gcs_block = _replace_block( GcsConfig(), {k: _coerce_value(k, v) for k, v in yaml_overrides.get("gcs", {}).items()}, ) component_blocks = _resolve_component_blocks() for slug, dataclass_type in _COMPONENT_REGISTRY.items(): block_overrides = yaml_overrides.get(slug, {}) if block_overrides: component_blocks[slug] = _replace_block( dataclass_type(), {k: _coerce_value(k, v) for k, v in block_overrides.items()}, ) return Config( runtime=runtime_block, log=log_block, fdr=fdr_block, fc=fc_block, gcs=gcs_block, components=component_blocks, )