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:
217
config/loaders.py
Normal file
217
config/loaders.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Configuration loading utilities.
|
||||
|
||||
Provides unified utilities for loading configuration from files,
|
||||
environment variables, and other sources.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Union
|
||||
|
||||
from .exceptions import ConfigFileError, EnvironmentVariableError
|
||||
|
||||
|
||||
def load_env_file(file_path: Union[str, Path],
|
||||
required: bool = False) -> Dict[str, str]:
|
||||
"""Load environment variables from a file.
|
||||
|
||||
Consolidates the duplicate load_dotenv_file() functions found in
|
||||
tddai/config.py and gitea/config.py.
|
||||
|
||||
Args:
|
||||
file_path: Path to the environment file
|
||||
required: Whether the file must exist
|
||||
|
||||
Returns:
|
||||
Dictionary of environment variables loaded from file
|
||||
|
||||
Raises:
|
||||
ConfigFileError: If required file is missing or cannot be parsed
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
if required:
|
||||
raise ConfigFileError(
|
||||
f"Required configuration file not found: {file_path}",
|
||||
suggestion=f"Create {file_path} or use environment variables"
|
||||
)
|
||||
return {}
|
||||
|
||||
try:
|
||||
env_vars = {}
|
||||
with file_path.open('r') as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Parse KEY=VALUE format
|
||||
if '=' not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split('=', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
|
||||
# Remove quotes if present
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1]
|
||||
elif value.startswith("'") and value.endswith("'"):
|
||||
value = value[1:-1]
|
||||
|
||||
env_vars[key] = value
|
||||
|
||||
return env_vars
|
||||
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
raise ConfigFileError(
|
||||
f"Failed to read configuration file: {file_path}",
|
||||
context={'error': str(e), 'line': line_num if 'line_num' in locals() else None}
|
||||
) from e
|
||||
|
||||
|
||||
def resolve_path(path: Union[str, Path],
|
||||
base_dir: Optional[Union[str, Path]] = None,
|
||||
create_parents: bool = False) -> Path:
|
||||
"""Resolve and normalize a file path.
|
||||
|
||||
Handles tilde expansion, relative paths, and optionally creates parent directories.
|
||||
|
||||
Args:
|
||||
path: Path to resolve
|
||||
base_dir: Base directory for relative paths (defaults to current working directory)
|
||||
create_parents: Whether to create parent directories if they don't exist
|
||||
|
||||
Returns:
|
||||
Resolved absolute Path object
|
||||
|
||||
Raises:
|
||||
ConfigFileError: If path resolution fails or parent creation fails
|
||||
"""
|
||||
try:
|
||||
# Convert to Path object
|
||||
path = Path(path)
|
||||
|
||||
# Expand user home directory (~)
|
||||
if str(path).startswith('~'):
|
||||
path = path.expanduser()
|
||||
|
||||
# Handle relative paths
|
||||
if not path.is_absolute():
|
||||
if base_dir:
|
||||
base_dir = Path(base_dir).expanduser().resolve()
|
||||
path = base_dir / path
|
||||
else:
|
||||
path = Path.cwd() / path
|
||||
|
||||
# Resolve to absolute path
|
||||
path = path.resolve()
|
||||
|
||||
# Create parent directories if requested
|
||||
if create_parents and not path.parent.exists():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return path
|
||||
|
||||
except (OSError, RuntimeError) as e:
|
||||
raise ConfigFileError(
|
||||
f"Failed to resolve path: {path}",
|
||||
context={'error': str(e), 'base_dir': base_dir}
|
||||
) from e
|
||||
|
||||
|
||||
def get_env_var(key: str,
|
||||
default: Optional[str] = None,
|
||||
required: bool = False,
|
||||
var_type: type = str) -> Any:
|
||||
"""Get environment variable with type conversion and validation.
|
||||
|
||||
Args:
|
||||
key: Environment variable name
|
||||
default: Default value if not found
|
||||
required: Whether the variable is required
|
||||
var_type: Type to convert the value to (str, int, bool, Path)
|
||||
|
||||
Returns:
|
||||
Environment variable value converted to specified type
|
||||
|
||||
Raises:
|
||||
EnvironmentVariableError: If required variable is missing or conversion fails
|
||||
"""
|
||||
value = os.getenv(key)
|
||||
|
||||
if value is None:
|
||||
if required:
|
||||
raise EnvironmentVariableError(
|
||||
f"Required environment variable not set: {key}",
|
||||
field=key,
|
||||
suggestion=f"Set {key} environment variable or provide default in config file"
|
||||
)
|
||||
return default
|
||||
|
||||
# Type conversion
|
||||
try:
|
||||
if var_type == bool:
|
||||
return value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif var_type == int:
|
||||
return int(value)
|
||||
elif var_type == float:
|
||||
return float(value)
|
||||
elif var_type == Path:
|
||||
return resolve_path(value)
|
||||
else:
|
||||
return var_type(value)
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
raise EnvironmentVariableError(
|
||||
f"Invalid value for environment variable {key}: {value}",
|
||||
field=key,
|
||||
value=value,
|
||||
suggestion=f"Provide a valid {var_type.__name__} value for {key}"
|
||||
) from e
|
||||
|
||||
|
||||
def load_json_config(file_path: Union[str, Path],
|
||||
required: bool = False) -> Dict[str, Any]:
|
||||
"""Load configuration from JSON file.
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON configuration file
|
||||
required: Whether the file must exist
|
||||
|
||||
Returns:
|
||||
Configuration dictionary loaded from JSON
|
||||
|
||||
Raises:
|
||||
ConfigFileError: If file cannot be read or parsed
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
if required:
|
||||
raise ConfigFileError(
|
||||
f"Required JSON configuration file not found: {file_path}",
|
||||
suggestion=f"Create {file_path} with valid JSON configuration"
|
||||
)
|
||||
return {}
|
||||
|
||||
try:
|
||||
with file_path.open('r') as f:
|
||||
return json.load(f)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigFileError(
|
||||
f"Invalid JSON in configuration file: {file_path}",
|
||||
context={'error': str(e), 'line': e.lineno, 'column': e.colno}
|
||||
) from e
|
||||
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
raise ConfigFileError(
|
||||
f"Failed to read JSON configuration file: {file_path}",
|
||||
context={'error': str(e)}
|
||||
) from e
|
||||
Reference in New Issue
Block a user