From 02c40d3e2fdda6a1f5be0c834ec1c0002738e9a7 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Tue, 5 Aug 2025 15:12:34 +0000 Subject: [PATCH] Implement configuration loader --- pyproject.toml | 1 + src/py_dvt_ate/app/config.py | 83 +++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c0bdde5..b6f6921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dev = [ "pytest-asyncio>=0.21", "ruff>=0.1", "mypy>=1.0", + "types-PyYAML>=6.0", ] [project.scripts] diff --git a/src/py_dvt_ate/app/config.py b/src/py_dvt_ate/app/config.py index 007a21f..c5e4a47 100644 --- a/src/py_dvt_ate/app/config.py +++ b/src/py_dvt_ate/app/config.py @@ -4,8 +4,11 @@ This module defines Pydantic models for all configuration sections. Configuration can be loaded from YAML files and validated at runtime. """ -from typing import Literal +import os +from pathlib import Path +from typing import Any, Literal +import yaml from pydantic import BaseModel, Field @@ -117,3 +120,81 @@ class AppConfig(BaseModel): logging: LoggingConfig = Field(default_factory=LoggingConfig) dashboard: DashboardConfig = Field(default_factory=DashboardConfig) api: APIConfig = Field(default_factory=APIConfig) + + +def _apply_env_overrides(config_dict: dict[str, Any]) -> None: + """Apply environment variable overrides to config dictionary. + + Environment variables follow the pattern: PYDVTATE__{SECTION}__{KEY} + For nested keys, use double underscores: PYDVTATE__{SECTION}__{SUBSECTION}__{KEY} + + Examples: + PYDVTATE__INSTRUMENTS__BACKEND=pyvisa + PYDVTATE__PHYSICS__UPDATE_RATE_HZ=50.0 + PYDVTATE__SIMULATOR__HOST=192.168.1.100 + """ + prefix = "PYDVTATE__" + + for env_key, env_value in os.environ.items(): + if not env_key.startswith(prefix): + continue + + # Remove prefix and split into parts + key_parts = env_key[len(prefix) :].lower().split("__") + + # Navigate/create nested structure + current = config_dict + for part in key_parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + + # Set the final value + final_key = key_parts[-1] + # Try to parse as YAML to handle types (int, float, bool, etc.) + try: + current[final_key] = yaml.safe_load(env_value) + except yaml.YAMLError: + # If parsing fails, use as string + current[final_key] = env_value + + +def load_config(config_path: str | Path | None = None) -> AppConfig: + """Load configuration from YAML file with environment variable overrides. + + Args: + config_path: Path to YAML configuration file. If None, uses defaults only. + + Returns: + Validated AppConfig instance. + + Raises: + FileNotFoundError: If config_path is provided but does not exist. + yaml.YAMLError: If YAML file is malformed. + pydantic.ValidationError: If configuration is invalid. + + Environment Variables: + Configuration can be overridden using environment variables with the + pattern PYDVTATE__{SECTION}__{KEY}. For example: + PYDVTATE__INSTRUMENTS__BACKEND=pyvisa + PYDVTATE__PHYSICS__UPDATE_RATE_HZ=50.0 + """ + # Start with empty dict (will use Pydantic defaults) + config_dict: dict[str, Any] = {} + + # Load from YAML file if provided + if config_path is not None: + path = Path(config_path) + if not path.exists(): + raise FileNotFoundError(f"Configuration file not found: {config_path}") + + with path.open("r") as f: + loaded = yaml.safe_load(f) + if loaded is not None: + config_dict = loaded + + # Apply environment variable overrides + _apply_env_overrides(config_dict) + + # Validate and return + return AppConfig(**config_dict)