feat: Implement unified configuration management system
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>
This commit is contained in:
304
config/base.py
Normal file
304
config/base.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user