import os from collections.abc import Mapping from pathlib import Path import yaml from dotenv import dotenv_values from .model import ( AppConfig, AppSectionConfig, DockerConfig, HttpConfig, LoggingConfig, MetricsConfig, OtelConfig, SandboxConfig, SecurityConfig, TracingConfig, ) ROOT_DIR = Path(__file__).resolve().parents[2] DEFAULT_CONFIG_PATH = ROOT_DIR / 'config' / 'app.yaml' DEFAULT_DOTENV_PATH = ROOT_DIR / '.env' class ConfigError(ValueError): pass def load_config( config_path: Path | str | None = None, env_path: Path | str | None = None, environ: Mapping[str, str] | None = None, ) -> AppConfig: yaml_data = _load_yaml(_path_or_default(config_path, DEFAULT_CONFIG_PATH)) env_values = _load_env(_path_or_default(env_path, DEFAULT_DOTENV_PATH), environ) app_section = _section(yaml_data, 'app') http_section = _section(yaml_data, 'http') logging_section = _section(yaml_data, 'logging') metrics_section = _section(yaml_data, 'metrics') tracing_section = _section(yaml_data, 'tracing') docker_section = _section(yaml_data, 'docker') sandbox_section = _section(yaml_data, 'sandbox') security_section = _section(yaml_data, 'security') logging_output = _yaml_or_env_choice( logging_section, 'output', 'logging.output', env_values, {'stdout', 'file', 'otel'}, 'APP_LOGGING_OUTPUT', ) logging_format = _yaml_or_env_choice( logging_section, 'format', 'logging.format', env_values, {'text', 'json'}, 'APP_LOGGING_FORMAT', ) metrics_enabled = _yaml_or_env_bool( metrics_section, 'enabled', 'metrics.enabled', env_values, 'APP_METRICS_ENABLED', ) tracing_enabled = _yaml_or_env_bool( tracing_section, 'enabled', 'tracing.enabled', env_values, 'APP_TRACING_ENABLED', ) return AppConfig( app=AppSectionConfig( name=_yaml_or_env_str( app_section, 'name', 'app.name', env_values, 'APP_NAME' ), env=_yaml_or_env_str(app_section, 'env', 'app.env', env_values, 'APP_ENV'), ), http=HttpConfig( host=_yaml_or_env_str( http_section, 'host', 'http.host', env_values, 'APP_HTTP_HOST', ), port=_yaml_or_env_int( http_section, 'port', 'http.port', env_values, 'APP_HTTP_PORT', ), ), logging=LoggingConfig( level=_yaml_or_env_str( logging_section, 'level', 'logging.level', env_values, 'APP_LOGGING_LEVEL', ), output=logging_output, format=logging_format, file_path=( None if logging_output != 'file' else _yaml_or_env_str( logging_section, 'file_path', 'logging.file_path', env_values, 'APP_LOGGING_FILE_PATH', ) ), ), metrics=MetricsConfig(enabled=metrics_enabled), tracing=TracingConfig(enabled=tracing_enabled), otel=_load_otel_config( yaml_data, env_values, enable_logs=logging_output == 'otel', enable_metrics=metrics_enabled, enable_tracing=tracing_enabled, ), docker=DockerConfig( base_url=_yaml_or_env_str( docker_section, 'base_url', 'docker.base_url', env_values, 'APP_DOCKER_BASE_URL', ) ), sandbox=_load_sandbox_config(sandbox_section, env_values), security=SecurityConfig( token_header=_yaml_or_env_str( security_section, 'token_header', 'security.token_header', env_values, 'APP_API_TOKEN_HEADER', ), api_token=_env_str(env_values, 'APP_API_TOKEN'), signing_key=_env_str(env_values, 'APP_SIGNING_KEY'), ), ) def _path_or_default(path_value: Path | str | None, default: Path) -> Path: if path_value is None: return default return Path(path_value) def _load_yaml(path: Path) -> dict[str, object]: try: with path.open(encoding='utf-8') as file: loaded = yaml.safe_load(file) except FileNotFoundError as exc: raise ConfigError(f'missing {_display_path(path)}') from exc if loaded is None: raise ConfigError('empty config') if not isinstance(loaded, dict): raise ConfigError('invalid root') data: dict[str, object] = {} for key, value in loaded.items(): if not isinstance(key, str): raise ConfigError('invalid root') data[key] = value return data def _load_env( env_path: Path, environ: Mapping[str, str] | None, ) -> dict[str, str]: values = _read_dotenv(env_path) source = os.environ if environ is None else environ for key, value in source.items(): values[key] = value return values def _read_dotenv(path: Path) -> dict[str, str]: if not path.exists(): return {} values: dict[str, str] = {} for key, value in dotenv_values(path).items(): if value is None: continue values[key] = value return values def _section(data: Mapping[str, object], name: str) -> dict[str, object]: value = data.get(name) if value is None: raise ConfigError(f'missing {name}') if not isinstance(value, dict): raise ConfigError(f'invalid {name}') section: dict[str, object] = {} for key, item in value.items(): if not isinstance(key, str): raise ConfigError(f'invalid {name}') section[key] = item return section def _optional_section(data: Mapping[str, object], name: str) -> dict[str, object]: value = data.get(name) if value is None: return {} if not isinstance(value, dict): raise ConfigError(f'invalid {name}') section: dict[str, object] = {} for key, item in value.items(): if not isinstance(key, str): raise ConfigError(f'invalid {name}') section[key] = item return section def _load_sandbox_config( section: Mapping[str, object], env: Mapping[str, str], ) -> SandboxConfig: return SandboxConfig( image=_yaml_or_env_str( section, 'image', 'sandbox.image', env, 'APP_SANDBOX_IMAGE', ), ttl_seconds=_yaml_or_env_int( section, 'ttl_seconds', 'sandbox.ttl_seconds', env, 'APP_SANDBOX_TTL_SECONDS', ), cleanup_interval_seconds=_yaml_or_env_int( section, 'cleanup_interval_seconds', 'sandbox.cleanup_interval_seconds', env, 'APP_SANDBOX_CLEANUP_INTERVAL_SECONDS', ), chats_root=_yaml_or_env_str( section, 'chats_root', 'sandbox.chats_root', env, 'APP_SANDBOX_CHATS_ROOT', ), dependencies_host_path=_yaml_or_env_str( section, 'dependencies_host_path', 'sandbox.dependencies_host_path', env, 'APP_SANDBOX_DEPENDENCIES_HOST_PATH', ), lambda_tools_host_path=_yaml_or_env_str( section, 'lambda_tools_host_path', 'sandbox.lambda_tools_host_path', env, 'APP_SANDBOX_LAMBDA_TOOLS_HOST_PATH', ), chat_mount_path=_yaml_or_env_str( section, 'chat_mount_path', 'sandbox.chat_mount_path', env, 'APP_SANDBOX_CHAT_MOUNT_PATH', ), dependencies_mount_path=_yaml_or_env_str( section, 'dependencies_mount_path', 'sandbox.dependencies_mount_path', env, 'APP_SANDBOX_DEPENDENCIES_MOUNT_PATH', ), lambda_tools_mount_path=_yaml_or_env_str( section, 'lambda_tools_mount_path', 'sandbox.lambda_tools_mount_path', env, 'APP_SANDBOX_LAMBDA_TOOLS_MOUNT_PATH', ), ) def _load_otel_config( data: Mapping[str, object], env: Mapping[str, str], *, enable_logs: bool, enable_metrics: bool, enable_tracing: bool, ) -> OtelConfig: if not any((enable_logs, enable_metrics, enable_tracing)): return OtelConfig( service_name='', logs_endpoint='', metrics_endpoint='', traces_endpoint='', metric_export_interval=0, ) otel_section = _optional_section(data, 'otel') service_name = _yaml_or_env_str( otel_section, 'service_name', 'otel.service_name', env, 'APP_OTEL_SERVICE_NAME', ) logs_endpoint = '' metrics_endpoint = '' traces_endpoint = '' metric_export_interval = 0 if enable_logs: logs_endpoint = _yaml_or_env_str( otel_section, 'logs_endpoint', 'otel.logs_endpoint', env, 'APP_OTEL_LOGS_ENDPOINT', ) if enable_metrics: metrics_endpoint = _yaml_or_env_str( otel_section, 'metrics_endpoint', 'otel.metrics_endpoint', env, 'APP_OTEL_METRICS_ENDPOINT', ) metric_export_interval = _yaml_or_env_int( otel_section, 'metric_export_interval', 'otel.metric_export_interval', env, 'OTEL_METRIC_EXPORT_INTERVAL', ) if enable_tracing: traces_endpoint = _yaml_or_env_str( otel_section, 'traces_endpoint', 'otel.traces_endpoint', env, 'APP_OTEL_TRACES_ENDPOINT', ) return OtelConfig( service_name=service_name, logs_endpoint=logs_endpoint, metrics_endpoint=metrics_endpoint, traces_endpoint=traces_endpoint, metric_export_interval=metric_export_interval, ) def _yaml_or_env_str( section: Mapping[str, object], field_name: str, label: str, env: Mapping[str, str], env_name: str | None = None, ) -> str: if env_name is not None and env_name in env: return _as_str(env[env_name], env_name) if field_name not in section: raise ConfigError(f'missing {label}') return _as_str(section[field_name], label) def _yaml_or_env_int( section: Mapping[str, object], field_name: str, label: str, env: Mapping[str, str], env_name: str | None = None, ) -> int: if env_name is not None and env_name in env: return _as_int(env[env_name], env_name) if field_name not in section: raise ConfigError(f'missing {label}') return _as_int(section[field_name], label) def _yaml_or_env_bool( section: Mapping[str, object], field_name: str, label: str, env: Mapping[str, str], env_name: str | None = None, ) -> bool: if env_name is not None and env_name in env: return _as_bool(env[env_name], env_name) if field_name not in section: raise ConfigError(f'missing {label}') return _as_bool(section[field_name], label) def _yaml_or_env_choice( section: Mapping[str, object], field_name: str, label: str, env: Mapping[str, str], choices: set[str], env_name: str | None = None, ) -> str: value = _yaml_or_env_str(section, field_name, label, env, env_name) normalized = value.lower() if normalized not in choices: raise ConfigError(f'invalid {label}') return normalized def _env_str(env: Mapping[str, str], name: str) -> str: if name not in env: raise ConfigError(f'missing {name}') return _as_str(env[name], name) def _as_str(value: object, label: str) -> str: if not isinstance(value, str): raise ConfigError(f'invalid {label}') text = value.strip() if not text: raise ConfigError(f'invalid {label}') return text def _as_int(value: object, label: str) -> int: if isinstance(value, bool): raise ConfigError(f'invalid {label}') if isinstance(value, int): return value if isinstance(value, str): text = value.strip() if not text: raise ConfigError(f'invalid {label}') try: return int(text) except ValueError as exc: raise ConfigError(f'invalid {label}') from exc raise ConfigError(f'invalid {label}') def _as_bool(value: object, label: str) -> bool: if isinstance(value, bool): return value if isinstance(value, str): text = value.strip().lower() if text == 'true': return True if text == 'false': return False raise ConfigError(f'invalid {label}') def _display_path(path: Path) -> str: if path.is_relative_to(ROOT_DIR): return str(path.relative_to(ROOT_DIR)) return str(path)