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>
217 lines
6.5 KiB
Python
217 lines
6.5 KiB
Python
"""
|
|
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 |