""" Base configuration classes. Provides base classes and common functionality for all configuration management across the MarkiTect project. """ import os from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path from typing import Dict, Any, Optional, List, Union from .exceptions import ConfigurationError, ConfigValidationError from .loaders import load_env_file, get_env_var, resolve_path @dataclass class BaseConfig(ABC): """Base configuration class with common functionality. Provides a foundation for all configuration classes with: - Environment variable loading with standardized prefixes - File-based configuration loading - Validation and error handling - Backward compatibility support """ def __post_init__(self): """Initialize configuration after dataclass creation.""" self.load_from_environment() self.load_from_files() self.validate() @abstractmethod def get_env_prefix(self) -> str: """Get the environment variable prefix for this configuration. Returns: Environment variable prefix (e.g., 'MARKITECT_', 'TDDAI_') """ pass @abstractmethod def get_config_files(self) -> List[Union[str, Path]]: """Get list of configuration files to load (in order of priority). Returns: List of configuration file paths to attempt loading """ pass @abstractmethod def validate(self) -> None: """Validate configuration values. Raises: ConfigValidationError: If validation fails """ pass def load_from_environment(self) -> None: """Load configuration from environment variables. Uses the prefix from get_env_prefix() to find relevant variables. """ prefix = self.get_env_prefix() # Get all environment variables with the prefix env_vars = { key[len(prefix):].lower(): value for key, value in os.environ.items() if key.startswith(prefix) } # Apply environment variables to configuration fields for field_name, value in env_vars.items(): if hasattr(self, field_name): # Type conversion based on field type field_type = self.__dataclass_fields__[field_name].type try: if field_type == bool: converted_value = value.lower() in ('true', '1', 'yes', 'on') elif field_type == int: converted_value = int(value) elif field_type == Path: converted_value = resolve_path(value) else: converted_value = value setattr(self, field_name, converted_value) except (ValueError, TypeError) as e: raise ConfigurationError( f"Invalid value for {prefix}{field_name.upper()}: {value}", field=field_name, value=value, suggestion=f"Provide a valid {field_type.__name__} value" ) from e def load_from_files(self) -> None: """Load configuration from files. Loads from files specified by get_config_files() in order, with later files overriding earlier ones. """ for config_file in self.get_config_files(): try: file_vars = load_env_file(config_file, required=False) # Apply file variables (convert to lowercase for consistency) prefix = self.get_env_prefix() legacy_mapping = self.get_legacy_mapping() for key, value in file_vars.items(): field_name = None # Check if it's a legacy variable we can map if key in legacy_mapping: field_name = legacy_mapping[key] # Remove prefix if present in file elif key.startswith(prefix): field_name = key[len(prefix):].lower() else: field_name = key.lower() if field_name and hasattr(self, field_name): # Type conversion field_type = self.__dataclass_fields__[field_name].type try: if field_type == bool: converted_value = value.lower() in ('true', '1', 'yes', 'on') elif field_type == int: converted_value = int(value) elif field_type == Path: converted_value = resolve_path(value) else: converted_value = value setattr(self, field_name, converted_value) except (ValueError, TypeError) as e: raise ConfigurationError( f"Invalid value in {config_file} for {field_name}: {value}", field=field_name, value=value ) from e except ConfigurationError: # Re-raise configuration errors raise except Exception as e: # Convert other errors to configuration errors raise ConfigurationError( f"Failed to load configuration from {config_file}: {e}", context={'file': str(config_file)} ) from e def validate_required_fields(self, *field_names: str) -> None: """Validate that required fields are not empty. Args: *field_names: Names of fields to validate Raises: ConfigValidationError: If any required field is empty """ for field_name in field_names: value = getattr(self, field_name, None) if not value or (isinstance(value, str) and not value.strip()): raise ConfigValidationError( f"Required configuration field cannot be empty: {field_name}", field=field_name, value=value, suggestion=f"Set {self.get_env_prefix()}{field_name.upper()} environment variable or add to config file" ) def validate_url(self, field_name: str) -> None: """Validate that a field contains a valid URL. Args: field_name: Name of the field to validate Raises: ConfigValidationError: If URL is invalid """ url = getattr(self, field_name, None) if not url: return # Empty URLs are handled by validate_required_fields if not isinstance(url, str): raise ConfigValidationError( f"URL field must be a string: {field_name}", field=field_name, value=url ) # Basic URL validation if not (url.startswith('http://') or url.startswith('https://')): raise ConfigValidationError( f"URL must start with http:// or https://: {field_name}", field=field_name, value=url, suggestion="Add http:// or https:// prefix to the URL" ) def validate_path_exists(self, field_name: str, create_if_missing: bool = False) -> None: """Validate that a path exists. Args: field_name: Name of the field to validate create_if_missing: Whether to create the path if it doesn't exist Raises: ConfigValidationError: If path doesn't exist and cannot be created """ path = getattr(self, field_name, None) if not path: return # Empty paths are handled by validate_required_fields path = Path(path) if not path.exists(): if create_if_missing: try: path.mkdir(parents=True, exist_ok=True) except OSError as e: raise ConfigValidationError( f"Cannot create directory: {path}", field=field_name, value=str(path), suggestion="Check directory permissions and parent directory existence" ) from e else: raise ConfigValidationError( f"Path does not exist: {path}", field=field_name, value=str(path), suggestion=f"Create the directory {path} or use a different path" ) def to_dict(self) -> Dict[str, Any]: """Convert configuration to dictionary. Returns: Dictionary representation of configuration """ result = {} for field_name, field_def in self.__dataclass_fields__.items(): value = getattr(self, field_name) # Convert Path objects to strings if isinstance(value, Path): value = str(value) result[field_name] = value return result def get_legacy_mapping(self) -> Dict[str, str]: """Get mapping of legacy environment variable names to new names. Override in subclasses to provide backward compatibility. Returns: Dictionary mapping old environment variable names to new field names """ return {} def load_legacy_environment(self) -> None: """Load configuration from legacy environment variables. Provides backward compatibility for existing environment variable names. """ legacy_mapping = self.get_legacy_mapping() for old_var, field_name in legacy_mapping.items(): if old_var in os.environ and hasattr(self, field_name): value = os.environ[old_var] # Type conversion field_type = self.__dataclass_fields__[field_name].type try: if field_type == bool: converted_value = value.lower() in ('true', '1', 'yes', 'on') elif field_type == int: converted_value = int(value) elif field_type == Path: converted_value = resolve_path(value) else: converted_value = value setattr(self, field_name, converted_value) except (ValueError, TypeError) as e: raise ConfigurationError( f"Invalid value for legacy environment variable {old_var}: {value}", field=field_name, value=value, suggestion=f"Use new environment variable {self.get_env_prefix()}{field_name.upper()} instead" ) from e