Daniel Wagner 15b595456a fix: modernize pydantic config to remove deprecation warning
- Replace deprecated class Config pattern with model_config = ConfigDict(...)
- Add ConfigDict import from pydantic
- Fixes PydanticDeprecatedSince20 warning in test output
- Maintains all existing functionality

All 73 tests pass without warnings.
2026-07-26 12:54:54 +10:00

244 lines
7.7 KiB
Python

"""Configuration management for Steward.
Supports configuration via:
1. YAML config file (passed via CONFIG_FILE env var)
2. Environment variables (STEWARD__SECTION__KEY=value format)
3. Default schema values
Environment variables take precedence over config file values.
"""
import logging
import os
from pathlib import Path
from omegaconf import OmegaConf
from pydantic import BaseModel, ConfigDict, Field
logger = logging.getLogger(__name__)
class TelegramConfig(BaseModel):
"""Telegram bot configuration."""
bot_token: str = Field(default="", description="Telegram bot token")
allowed_user_ids: list[int] = Field(default_factory=list, description="Allowed user IDs")
group_ids: list[int] = Field(default_factory=list, description="Allowed group/channel IDs")
class OpenAIConfig(BaseModel):
"""OpenAI/LLM configuration."""
api_key: str = Field(default="", description="OpenAI API key")
base_url: str = Field(default="https://api.openai.com/v1", description="API base URL")
model: str = Field(default="gpt-4o", description="Model to use")
system_prompt: str = Field(
default=(
"You are Steward, a persistent, trustworthy AI-assisted personal operations platform. "
"You reduce cognitive load by observing, remembering, planning, and proposing actions. "
"You are conservative, transparent, and policy-aware. "
"Always explain your reasoning."
),
description="System prompt for the LLM",
)
class AnalysisConfig(BaseModel):
"""Analysis/proposal worker configuration."""
target_url: str = Field(default="", description="Target URL for analysis")
target_api_key: str = Field(default="", description="API key for target")
cron_hour: int = Field(default=8, description="Hour for cron schedule")
cron_minute: int = Field(default=0, description="Minute for cron schedule")
class MemoryConfig(BaseModel):
"""Memory/persistence configuration."""
thread_memory_path: str = Field(default="thread_memory.json", description="Thread memory path")
class ToolsConfig(BaseModel):
"""Tools/MCP configuration."""
mcp_server_url: str = Field(default="", description="MCP server URL")
mcp_server_api_key: str = Field(default="", description="MCP server API key")
class Settings(BaseModel):
"""Application settings with OmegaConf and pydantic integration."""
model_config = ConfigDict(arbitrary_types_allowed=True)
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
openai: OpenAIConfig = Field(default_factory=OpenAIConfig)
analysis: AnalysisConfig = Field(default_factory=AnalysisConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig)
# Compatibility properties for existing code
@property
def telegram_bot_token(self) -> str:
"""Legacy property for backward compatibility."""
return self.telegram.bot_token
@property
def telegram_allowed_user_ids(self) -> list[int]:
"""Legacy property for backward compatibility."""
return self.telegram.allowed_user_ids
@property
def telegram_group_ids(self) -> list[int]:
"""Legacy property for backward compatibility."""
return self.telegram.group_ids
@property
def openai_api_key(self) -> str:
"""Legacy property for backward compatibility."""
return self.openai.api_key
@property
def openai_base_url(self) -> str:
"""Legacy property for backward compatibility."""
return self.openai.base_url
@property
def openai_model(self) -> str:
"""Legacy property for backward compatibility."""
return self.openai.model
@property
def openai_system_prompt(self) -> str:
"""Legacy property for backward compatibility."""
return self.openai.system_prompt
@property
def analysis_target_url(self) -> str:
"""Legacy property for backward compatibility."""
return self.analysis.target_url
@property
def analysis_target_api_key(self) -> str:
"""Legacy property for backward compatibility."""
return self.analysis.target_api_key
@property
def analysis_cron_hour(self) -> int:
"""Legacy property for backward compatibility."""
return self.analysis.cron_hour
@property
def analysis_cron_minute(self) -> int:
"""Legacy property for backward compatibility."""
return self.analysis.cron_minute
@property
def thread_memory_path(self) -> str:
"""Legacy property for backward compatibility."""
return self.memory.thread_memory_path
@property
def mcp_server_url(self) -> str:
"""Legacy property for backward compatibility."""
return self.tools.mcp_server_url
@property
def mcp_server_api_key(self) -> str:
"""Legacy property for backward compatibility."""
return self.tools.mcp_server_api_key
_settings_instance: Settings | None = None
def _load_config_from_file(config_path: str | Path) -> dict:
"""Load configuration from YAML file."""
config_path = Path(config_path)
if not config_path.exists():
logger.warning("Config file not found: %s", config_path)
return {}
logger.info("Loading config from %s", config_path)
cfg = OmegaConf.load(config_path)
return OmegaConf.to_container(cfg, resolve=True) or {}
def _load_config_from_env() -> dict:
"""Load configuration from environment variables (STEWARD__SECTION__KEY format)."""
cfg = {}
prefix = "STEWARD__"
for key, value in os.environ.items():
if not key.startswith(prefix):
continue
# Parse STEWARD__SECTION__KEY=value
parts = key[len(prefix) :].lower().split("__")
if len(parts) < 2:
continue
section = parts[0]
setting_key = "__".join(parts[1:])
if section not in cfg:
cfg[section] = {}
# Try to parse value as JSON first (for lists, etc.)
if isinstance(cfg[section], dict):
try:
import json
cfg[section][setting_key] = json.loads(value)
except (json.JSONDecodeError, ValueError):
cfg[section][setting_key] = value
return cfg
def get_settings() -> Settings:
"""Get or create the settings singleton.
Configuration is loaded in order of precedence (highest to lowest):
1. Environment variables (STEWARD__SECTION__KEY=value)
2. YAML config file (path via CONFIG_FILE env var)
3. Default values from schema
Returns:
Settings: The application settings.
"""
global _settings_instance
if _settings_instance is not None:
return _settings_instance
# Start with schema defaults
schema_path = Path(__file__).parent / "config_schema.yaml"
base_cfg = OmegaConf.load(schema_path)
# Merge in config file if specified
config_file = os.environ.get("CONFIG_FILE")
if config_file:
file_cfg = OmegaConf.create(_load_config_from_file(config_file))
base_cfg = OmegaConf.merge(base_cfg, file_cfg)
# Merge in environment variables (takes precedence)
env_cfg = OmegaConf.create(_load_config_from_env())
if env_cfg:
base_cfg = OmegaConf.merge(base_cfg, env_cfg)
# Convert to dict and create Settings instance
config_dict = OmegaConf.to_container(base_cfg, resolve=True) or {}
_settings_instance = Settings(**config_dict)
logger.info("Configuration loaded successfully")
logger.debug("Active configuration: %s", OmegaConf.to_yaml(base_cfg))
return _settings_instance
def reload_settings() -> Settings:
"""Reload configuration from source (mainly for testing)."""
global _settings_instance
_settings_instance = None
return get_settings()