Consolidates scattered configuration patterns across TDDAI, Gitea, and MarkiTect into a unified, maintainable system addressing issue #22. Key improvements: - Created centralized config/ module with base classes and utilities - Eliminated duplicate load_dotenv_file() functions - Standardized environment variables with MARKITECT_ prefix - Implemented comprehensive validation with helpful error messages - Maintained full backward compatibility with existing TDDAI config Architecture: - BaseConfig: Abstract base with common functionality - MarkitectConfig: Main configuration class with legacy support - Compatibility layer: TddaiConfigCompat and GiteaConfigCompat wrappers - Unified error handling: ConfigurationError hierarchy All existing tests pass without modification, ensuring seamless transition. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""
|
|
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 |