Files
markitect-main/config/base.py
tegwick a7a7960ef6 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>
2025-09-26 17:45:56 +02:00

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