"""Config dataclasses for E-CC-CONF (AZ-269 / AZ-246). The outer `Config` aggregates one frozen nested dataclass per top-level config block. Cross-cutting blocks (`log`, `fdr`, `runtime`) live here; per-component blocks live with their own component epic and are registered into `Config.components` via `register_component_block`. Public surface frozen by `_docs/02_document/contracts/shared_config/composition_root_protocol.md` v1.0.0. """ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass, field, fields, is_dataclass, replace from typing import Any, Final __all__ = [ "Config", "ConfigError", "FdrConfig", "LogConfig", "RequiredFieldMissingError", "RuntimeConfig", "register_component_block", ] class ConfigError(RuntimeError): """Base class for all config-loader errors that should reach the caller.""" class RequiredFieldMissingError(ConfigError): """Raised when a required configuration value is absent from env + YAML + defaults. Always names the missing variable and points at the runtime helper that documents the full set (``.env.example``). """ @dataclass(frozen=True) class LogConfig: """Cross-cutting logging block (E-CC-LOG).""" level: str = "INFO" tier: int = 1 sink: str = "console" @dataclass(frozen=True) class FdrConfig: """Cross-cutting Flight Data Recorder block (E-CC-FDR-CLIENT / AZ-247). ``queue_size`` is the documented default capacity for every producer. ``per_producer_capacity`` carries per-producer overrides keyed by producer slug (consumed by AZ-273 ``make_fdr_client``); blocks that omit a producer fall back to ``queue_size``. """ queue_size: int = 4096 overrun_policy: str = "drop_oldest" path: str = "/var/lib/gps-denied/fdr" per_producer_capacity: Mapping[str, int] = field(default_factory=dict) @dataclass(frozen=True) class RuntimeConfig: """Top-level runtime descriptors that don't belong to a single component.""" fc_profile: str = "ardupilot_plane" tier: int = 1 db_url: str = "" camera_calibration_path: str = "" inference_backend: str = "pytorch_fp16" tile_cache_path: str = "/var/lib/gps-denied/tiles" # Documented defaults for cross-cutting blocks ONLY. Per-component defaults # live with their own component epic. The registry below is the single # source of truth so two components cannot silently claim the same key. _DEFAULT_BLOCKS: Final[dict[str, type]] = { "log": LogConfig, "fdr": FdrConfig, "runtime": RuntimeConfig, } # Registry for per-component nested dataclasses. Each component epic # calls ``register_component_block("c5_state", C5StateConfig)`` from its # package import path; the composition root drives those imports before # calling ``load_config``. _COMPONENT_REGISTRY: dict[str, type] = {} def register_component_block(slug: str, dataclass_type: type) -> None: """Register a per-component frozen dataclass under its component slug.""" if not is_dataclass(dataclass_type): raise TypeError( f"register_component_block({slug!r}, ...): block must be a dataclass; " f"got {dataclass_type!r}" ) existing = _COMPONENT_REGISTRY.get(slug) if existing is not None and existing is not dataclass_type: raise ConfigError( f"duplicate component config registration for slug {slug!r}: " f"{existing!r} vs {dataclass_type!r}" ) _COMPONENT_REGISTRY[slug] = dataclass_type def _resolve_default_blocks() -> dict[str, Any]: """Instantiate every documented cross-cutting block with its defaults.""" return {name: cls() for name, cls in _DEFAULT_BLOCKS.items()} def _resolve_component_blocks() -> dict[str, Any]: """Instantiate every registered per-component block with its defaults.""" return {slug: cls() for slug, cls in _COMPONENT_REGISTRY.items()} @dataclass(frozen=True) class Config: """Outer composition-root config (frozen end-to-end). Components consume only their own slice via ``config.components[slug]``; the runtime / log / fdr cross-cutting blocks are read directly via attribute access by the composition root. """ runtime: RuntimeConfig = field(default_factory=RuntimeConfig) log: LogConfig = field(default_factory=LogConfig) fdr: FdrConfig = field(default_factory=FdrConfig) components: Mapping[str, Any] = field(default_factory=dict) @classmethod def with_blocks(cls, **blocks: Any) -> Config: """Build a `Config` from a flat name-to-instance map. Cross-cutting names (``log``, ``fdr``, ``runtime``) become attributes; every other key is treated as a component slug and goes into ``components``. """ runtime = blocks.pop("runtime", RuntimeConfig()) log = blocks.pop("log", LogConfig()) fdr = blocks.pop("fdr", FdrConfig()) return cls(runtime=runtime, log=log, fdr=fdr, components=dict(blocks)) def _block_field_names(block: Any) -> tuple[str, ...]: return tuple(f.name for f in fields(block)) def _replace_block(block: Any, overrides: Mapping[str, Any]) -> Any: """Return ``replace(block, **overrides)`` after filtering unknown keys.""" if not overrides: return block known = set(_block_field_names(block)) filtered = {k: v for k, v in overrides.items() if k in known} if not filtered: return block return replace(block, **filtered)