341 lines
14 KiB
Python
341 lines
14 KiB
Python
"""
|
|||
|
|
Dependency Management Module
|
||
|
|
Handles optional dependencies with graceful degradation and helpful error messages
|
||
|
|
"""
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import sys
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Dict, Any, Optional, List, Tuple, Callable
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class DependencyInfo:
|
||
|
|
"""Information about a dependency"""
|
||
|
|
name: str
|
||
|
|
import_name: str
|
||
|
|
install_command: str
|
||
|
|
description: str
|
||
|
|
required_for: List[str]
|
||
|
|
minimum_version: Optional[str] = None
|
||
|
|
alternative_packages: Optional[List[str]] = None
|
||
|
|
setup_instructions: Optional[str] = None
|
||
|
|
|
||
|
|
|
||
|
|
class DependencyManager:
|
||
|
|
"""Manages optional dependencies with graceful degradation"""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.logger = logging.getLogger("DependencyManager")
|
||
|
|
self._dependency_cache: Dict[str, Any] = {}
|
||
|
|
self._availability_cache: Dict[str, bool] = {}
|
||
|
|
|
||
|
|
# Define known dependencies
|
||
|
|
self.dependencies = {
|
||
|
|
'anthropic': DependencyInfo(
|
||
|
|
name='anthropic',
|
||
|
|
import_name='anthropic',
|
||
|
|
install_command='pip install anthropic>=0.25.0',
|
||
|
|
description='Anthropic Claude API client for AI-powered content analysis',
|
||
|
|
required_for=['Claude AI analysis', 'Natural language understanding', 'Content transformation'],
|
||
|
|
minimum_version='0.25.0',
|
||
|
|
setup_instructions="""
|
||
|
|
Claude API Setup:
|
||
|
|
1. Install: pip install anthropic>=0.25.0
|
||
|
|
2. Get API key from: https://console.anthropic.com/
|
||
|
|
3. Set environment variable: export ANTHROPIC_API_KEY="sk-ant-your-key-here"
|
||
|
|
4. Or add to config.yaml: claude.api_key: "${ANTHROPIC_API_KEY}"
|
||
|
|
"""
|
||
|
|
),
|
||
|
|
'aiohttp': DependencyInfo(
|
||
|
|
name='aiohttp',
|
||
|
|
import_name='aiohttp',
|
||
|
|
install_command='pip install aiohttp>=3.9.0',
|
||
|
|
description='Async HTTP client for Obsidian REST API integration',
|
||
|
|
required_for=['Obsidian API communication', 'Reading/writing notes', 'File operations'],
|
||
|
|
minimum_version='3.9.0',
|
||
|
|
setup_instructions="""
|
||
|
|
HTTP Client Setup:
|
||
|
|
1. Install: pip install aiohttp>=3.9.0
|
||
|
|
2. Used for communicating with Obsidian Local REST API
|
||
|
|
3. No additional configuration required
|
||
|
|
"""
|
||
|
|
),
|
||
|
|
'yaml': DependencyInfo(
|
||
|
|
name='PyYAML',
|
||
|
|
import_name='yaml',
|
||
|
|
install_command='pip install pyyaml>=6.0',
|
||
|
|
description='YAML parser for configuration files',
|
||
|
|
required_for=['Configuration file parsing', 'Settings management'],
|
||
|
|
minimum_version='6.0',
|
||
|
|
alternative_packages=['ruamel.yaml'],
|
||
|
|
setup_instructions="""
|
||
|
|
YAML Parser Setup:
|
||
|
|
1. Install: pip install pyyaml>=6.0
|
||
|
|
2. Alternative: pip install ruamel.yaml
|
||
|
|
3. Used for parsing config.yaml files
|
||
|
|
"""
|
||
|
|
),
|
||
|
|
'pydantic': DependencyInfo(
|
||
|
|
name='pydantic',
|
||
|
|
import_name='pydantic',
|
||
|
|
install_command='pip install pydantic>=2.0.0',
|
||
|
|
description='Data validation and settings management',
|
||
|
|
required_for=['Configuration validation', 'Data model validation', 'Type checking'],
|
||
|
|
minimum_version='2.0.0',
|
||
|
|
setup_instructions="""
|
||
|
|
Data Validation Setup:
|
||
|
|
1. Install: pip install pydantic>=2.0.0
|
||
|
|
2. Used for validating configuration files and data models
|
||
|
|
3. Provides enhanced error messages and type checking
|
||
|
|
"""
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
def is_available(self, dependency_name: str) -> bool:
|
||
|
|
"""Check if a dependency is available"""
|
||
|
|
if dependency_name in self._availability_cache:
|
||
|
|
return self._availability_cache[dependency_name]
|
||
|
|
|
||
|
|
if dependency_name not in self.dependencies:
|
||
|
|
self.logger.warning(f"Unknown dependency: {dependency_name}")
|
||
|
|
return False
|
||
|
|
|
||
|
|
dep_info = self.dependencies[dependency_name]
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Try to import the module
|
||
|
|
__import__(dep_info.import_name)
|
||
|
|
self._availability_cache[dependency_name] = True
|
||
|
|
return True
|
||
|
|
except ImportError:
|
||
|
|
self._availability_cache[dependency_name] = False
|
||
|
|
return False
|
||
|
|
|
||
|
|
def get_module(self, dependency_name: str, raise_on_missing: bool = False) -> Optional[Any]:
|
||
|
|
"""Get a module if available, with optional error raising"""
|
||
|
|
if dependency_name in self._dependency_cache:
|
||
|
|
return self._dependency_cache[dependency_name]
|
||
|
|
|
||
|
|
if not self.is_available(dependency_name):
|
||
|
|
if raise_on_missing:
|
||
|
|
raise ImportError(self._get_missing_dependency_message(dependency_name))
|
||
|
|
return None
|
||
|
|
|
||
|
|
dep_info = self.dependencies[dependency_name]
|
||
|
|
try:
|
||
|
|
module = __import__(dep_info.import_name)
|
||
|
|
self._dependency_cache[dependency_name] = module
|
||
|
|
return module
|
||
|
|
except ImportError as e:
|
||
|
|
if raise_on_missing:
|
||
|
|
raise ImportError(self._get_missing_dependency_message(dependency_name)) from e
|
||
|
|
return None
|
||
|
|
|
||
|
|
def get_class_from_module(self, dependency_name: str, class_name: str, raise_on_missing: bool = False) -> Optional[Any]:
|
||
|
|
"""Get a specific class from a module"""
|
||
|
|
module = self.get_module(dependency_name, raise_on_missing=False)
|
||
|
|
if module is None:
|
||
|
|
if raise_on_missing:
|
||
|
|
raise ImportError(self._get_missing_dependency_message(dependency_name))
|
||
|
|
return None
|
||
|
|
|
||
|
|
try:
|
||
|
|
return getattr(module, class_name)
|
||
|
|
except AttributeError as e:
|
||
|
|
if raise_on_missing:
|
||
|
|
raise ImportError(f"Class {class_name} not found in {dependency_name}") from e
|
||
|
|
return None
|
||
|
|
|
||
|
|
def require_dependency(self, dependency_name: str) -> Any:
|
||
|
|
"""Require a dependency, raising detailed error if not available"""
|
||
|
|
module = self.get_module(dependency_name, raise_on_missing=True)
|
||
|
|
return module
|
||
|
|
|
||
|
|
def check_all_dependencies(self) -> Dict[str, Dict[str, Any]]:
|
||
|
|
"""Check status of all known dependencies"""
|
||
|
|
results = {}
|
||
|
|
|
||
|
|
for dep_name, dep_info in self.dependencies.items():
|
||
|
|
is_avail = self.is_available(dep_name)
|
||
|
|
results[dep_name] = {
|
||
|
|
'available': is_avail,
|
||
|
|
'name': dep_info.name,
|
||
|
|
'description': dep_info.description,
|
||
|
|
'required_for': dep_info.required_for,
|
||
|
|
'install_command': dep_info.install_command,
|
||
|
|
'setup_instructions': dep_info.setup_instructions
|
||
|
|
}
|
||
|
|
|
||
|
|
if is_avail:
|
||
|
|
# Try to get version info
|
||
|
|
try:
|
||
|
|
module = self.get_module(dep_name)
|
||
|
|
if hasattr(module, '__version__'):
|
||
|
|
results[dep_name]['version'] = module.__version__
|
||
|
|
elif hasattr(module, 'version'):
|
||
|
|
results[dep_name]['version'] = module.version
|
||
|
|
except:
|
||
|
|
pass
|
||
|
|
|
||
|
|
return results
|
||
|
|
|
||
|
|
def get_missing_dependencies(self) -> List[str]:
|
||
|
|
"""Get list of missing dependencies"""
|
||
|
|
missing = []
|
||
|
|
for dep_name in self.dependencies:
|
||
|
|
if not self.is_available(dep_name):
|
||
|
|
missing.append(dep_name)
|
||
|
|
return missing
|
||
|
|
|
||
|
|
def get_installation_instructions(self, missing_only: bool = True) -> str:
|
||
|
|
"""Get installation instructions for dependencies"""
|
||
|
|
deps_to_show = self.get_missing_dependencies() if missing_only else list(self.dependencies.keys())
|
||
|
|
|
||
|
|
if not deps_to_show:
|
||
|
|
return "✓ All dependencies are available!"
|
||
|
|
|
||
|
|
instructions = []
|
||
|
|
instructions.append("Missing Dependencies Installation Guide:")
|
||
|
|
instructions.append("=" * 50)
|
||
|
|
|
||
|
|
for dep_name in deps_to_show:
|
||
|
|
dep_info = self.dependencies[dep_name]
|
||
|
|
instructions.append(f"\n📦 {dep_info.name}")
|
||
|
|
instructions.append(f" Description: {dep_info.description}")
|
||
|
|
instructions.append(f" Required for: {', '.join(dep_info.required_for)}")
|
||
|
|
instructions.append(f" Install: {dep_info.install_command}")
|
||
|
|
|
||
|
|
if dep_info.alternative_packages:
|
||
|
|
instructions.append(f" Alternatives: {', '.join(dep_info.alternative_packages)}")
|
||
|
|
|
||
|
|
if dep_info.setup_instructions:
|
||
|
|
instructions.append(f" Setup:{dep_info.setup_instructions}")
|
||
|
|
|
||
|
|
instructions.append("\n" + "=" * 50)
|
||
|
|
instructions.append("Quick install all missing dependencies:")
|
||
|
|
install_commands = [self.dependencies[dep].install_command for dep in deps_to_show]
|
||
|
|
instructions.append(" && ".join(install_commands))
|
||
|
|
|
||
|
|
return "\n".join(instructions)
|
||
|
|
|
||
|
|
def _get_missing_dependency_message(self, dependency_name: str) -> str:
|
||
|
|
"""Get detailed error message for missing dependency"""
|
||
|
|
if dependency_name not in self.dependencies:
|
||
|
|
return f"Unknown dependency: {dependency_name}"
|
||
|
|
|
||
|
|
dep_info = self.dependencies[dependency_name]
|
||
|
|
|
||
|
|
message_parts = [
|
||
|
|
f"Missing required dependency: {dep_info.name}",
|
||
|
|
f"Description: {dep_info.description}",
|
||
|
|
f"Required for: {', '.join(dep_info.required_for)}",
|
||
|
|
"",
|
||
|
|
f"To install: {dep_info.install_command}",
|
||
|
|
]
|
||
|
|
|
||
|
|
if dep_info.alternative_packages:
|
||
|
|
message_parts.append(f"Alternatives: {', '.join(dep_info.alternative_packages)}")
|
||
|
|
|
||
|
|
if dep_info.setup_instructions:
|
||
|
|
message_parts.append("")
|
||
|
|
message_parts.append("Setup Instructions:")
|
||
|
|
message_parts.append(dep_info.setup_instructions)
|
||
|
|
|
||
|
|
return "\n".join(message_parts)
|
||
|
|
|
||
|
|
def create_graceful_import_wrapper(self, dependency_name: str, fallback_message: Optional[str] = None):
|
||
|
|
"""Create a wrapper that provides graceful degradation for missing dependencies"""
|
||
|
|
def wrapper(func: Callable) -> Callable:
|
||
|
|
def inner(*args, **kwargs):
|
||
|
|
if not self.is_available(dependency_name):
|
||
|
|
error_msg = fallback_message or f"Feature unavailable: {dependency_name} is not installed"
|
||
|
|
detailed_msg = self._get_missing_dependency_message(dependency_name)
|
||
|
|
|
||
|
|
# Log the detailed message
|
||
|
|
self.logger.error(f"Dependency missing: {detailed_msg}")
|
||
|
|
|
||
|
|
# Return a user-friendly error
|
||
|
|
try:
|
||
|
|
from agent_core import SkillResult
|
||
|
|
except ImportError:
|
||
|
|
# Fallback if agent_core is not available
|
||
|
|
class SkillResult:
|
||
|
|
def __init__(self, success, error=None, message=""):
|
||
|
|
self.success = success
|
||
|
|
self.error = error
|
||
|
|
self.message = message
|
||
|
|
|
||
|
|
return SkillResult(
|
||
|
|
success=False,
|
||
|
|
error=error_msg,
|
||
|
|
message=f"Please install {dependency_name} to use this feature"
|
||
|
|
)
|
||
|
|
|
||
|
|
return func(*args, **kwargs)
|
||
|
|
return inner
|
||
|
|
return wrapper
|
||
|
|
|
||
|
|
def get_dependency_status_report(self) -> str:
|
||
|
|
"""Get a comprehensive dependency status report"""
|
||
|
|
all_deps = self.check_all_dependencies()
|
||
|
|
|
||
|
|
available = [name for name, info in all_deps.items() if info['available']]
|
||
|
|
missing = [name for name, info in all_deps.items() if not info['available']]
|
||
|
|
|
||
|
|
report = []
|
||
|
|
report.append("Dependency Status Report")
|
||
|
|
report.append("=" * 30)
|
||
|
|
|
||
|
|
if available:
|
||
|
|
report.append(f"\n✓ Available ({len(available)}):")
|
||
|
|
for dep_name in available:
|
||
|
|
info = all_deps[dep_name]
|
||
|
|
version = info.get('version', 'unknown version')
|
||
|
|
report.append(f" • {info['name']} ({version})")
|
||
|
|
|
||
|
|
if missing:
|
||
|
|
report.append(f"\n✗ Missing ({len(missing)}):")
|
||
|
|
for dep_name in missing:
|
||
|
|
info = all_deps[dep_name]
|
||
|
|
report.append(f" • {info['name']} - {info['description']}")
|
||
|
|
report.append(f" Install: {info['install_command']}")
|
||
|
|
|
||
|
|
if missing:
|
||
|
|
report.append(f"\nTo install all missing dependencies:")
|
||
|
|
install_commands = [all_deps[dep]['install_command'] for dep in missing]
|
||
|
|
report.append(" && ".join(install_commands))
|
||
|
|
else:
|
||
|
|
report.append(f"\n🎉 All dependencies are available!")
|
||
|
|
|
||
|
|
return "\n".join(report)
|
||
|
|
|
||
|
|
|
||
|
|
# Global dependency manager instance
|
||
|
|
dependency_manager = DependencyManager()
|
||
|
|
|
||
|
|
|
||
|
|
def get_dependency_manager() -> DependencyManager:
|
||
|
|
"""Get the global dependency manager instance"""
|
||
|
|
return dependency_manager
|
||
|
|
|
||
|
|
|
||
|
|
def check_dependency(dependency_name: str) -> bool:
|
||
|
|
"""Quick check if a dependency is available"""
|
||
|
|
return dependency_manager.is_available(dependency_name)
|
||
|
|
|
||
|
|
|
||
|
|
def require_dependency(dependency_name: str) -> Any:
|
||
|
|
"""Require a dependency, raising detailed error if not available"""
|
||
|
|
return dependency_manager.require_dependency(dependency_name)
|
||
|
|
|
||
|
|
|
||
|
|
def get_optional_module(dependency_name: str) -> Optional[Any]:
|
||
|
|
"""Get a module if available, None otherwise"""
|
||
|
|
return dependency_manager.get_module(dependency_name, raise_on_missing=False)
|
||
|
|
|
||
|
|
|
||
|
|
def graceful_import(dependency_name: str, fallback_message: Optional[str] = None):
|
||
|
|
"""Decorator for graceful dependency handling"""
|
||
|
|
return dependency_manager.create_graceful_import_wrapper(dependency_name, fallback_message)
|