Files
markitect-main/config/loaders.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

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