Coverage for src/quickmcp/factory/errors.py: 68%

154 statements  

« prev     ^ index     » next       coverage.py v7.10.4, created at 2025-08-20 20:56 +0200

1""" 

2Error handling and custom exceptions for MCP Factory. 

3""" 

4 

5from typing import List, Dict, Any 

6import logging 

7 

8from .import_analyzer import MissingDependency 

9 

10logger = logging.getLogger(__name__) 

11 

12 

13class FactoryError(Exception): 

14 """Base exception for all factory-related errors.""" 

15 pass 

16 

17 

18class ConfigurationError(FactoryError): 

19 """Raised when there's an issue with factory configuration.""" 

20 pass 

21 

22 

23class ModuleLoadError(FactoryError): 

24 """Raised when a module cannot be loaded.""" 

25 def __init__(self, message: str, module_path: str, original_error: Exception = None): 

26 super().__init__(message) 

27 self.module_path = module_path 

28 self.original_error = original_error 

29 

30 

31class FunctionExtractionError(FactoryError): 

32 """Raised when functions cannot be extracted from a module or class.""" 

33 pass 

34 

35 

36class ToolRegistrationError(FactoryError): 

37 """Raised when a tool cannot be registered.""" 

38 def __init__(self, message: str, tool_name: str, original_error: Exception = None): 

39 super().__init__(message) 

40 self.tool_name = tool_name 

41 self.original_error = original_error 

42 

43 

44class TypeConversionError(FactoryError): 

45 """Raised when type conversion fails.""" 

46 def __init__(self, message: str, value: Any, target_type: type, parameter_name: str = None): 

47 super().__init__(message) 

48 self.value = value 

49 self.target_type = target_type 

50 self.parameter_name = parameter_name 

51 

52 

53class MissingDependencyError(ImportError, FactoryError): 

54 """Raised when required dependencies are missing.""" 

55 

56 def __init__(self, message: str, missing_dependencies: List[MissingDependency], module_path: str = None): 

57 super().__init__(message) 

58 self.missing_dependencies = missing_dependencies 

59 self.module_path = module_path 

60 

61 @property 

62 def required_dependencies(self) -> List[MissingDependency]: 

63 """Get only the required (non-optional) dependencies.""" 

64 return [dep for dep in self.missing_dependencies if dep.import_type != "optional"] 

65 

66 @property 

67 def optional_dependencies(self) -> List[MissingDependency]: 

68 """Get only the optional dependencies.""" 

69 return [dep for dep in self.missing_dependencies if dep.import_type == "optional"] 

70 

71 @property 

72 def dev_dependencies(self) -> List[MissingDependency]: 

73 """Get only the development dependencies.""" 

74 return [dep for dep in self.missing_dependencies if dep.is_dev_dependency] 

75 

76 def format_error_message(self, include_dev_deps: bool = True) -> str: 

77 """Format a detailed error message with installation suggestions.""" 

78 lines = [str(self)] 

79 

80 if not self.missing_dependencies: 

81 return lines[0] 

82 

83 if self.module_path: 

84 lines.append(f"\nModule: {self.module_path}") 

85 

86 lines.append("\nMissing dependencies:") 

87 

88 # Group dependencies 

89 required = self.required_dependencies 

90 optional = self.optional_dependencies 

91 dev_deps = self.dev_dependencies if include_dev_deps else [] 

92 

93 if required: 

94 lines.append("\n❌ Required dependencies (will cause import errors):") 

95 for dep in required: 

96 lines.append(f" • {dep.module}") 

97 if dep.source_line and dep.line_number: 

98 lines.append(f" Line {dep.line_number}: {dep.source_line}") 

99 

100 install_cmd = self._get_install_command(dep) 

101 lines.append(f" Install: {install_cmd}") 

102 

103 if optional: 

104 lines.append("\n⚠️ Optional dependencies (handled gracefully):") 

105 for dep in optional: 

106 lines.append(f" • {dep.module}") 

107 install_cmd = self._get_install_command(dep) 

108 lines.append(f" Install: {install_cmd}") 

109 

110 if dev_deps and include_dev_deps: 

111 lines.append("\n🛠️ Development dependencies:") 

112 for dep in dev_deps: 

113 lines.append(f" • {dep.module}") 

114 install_cmd = self._get_install_command(dep) 

115 lines.append(f" Install: {install_cmd}") 

116 

117 # Generate installation commands 

118 import shutil 

119 use_uv = shutil.which('uv') is not None 

120 cmd = "uv pip install" if use_uv else "pip install" 

121 

122 if required: 

123 required_installs = [dep.suggested_install or dep.module for dep in required] 

124 lines.append(f"\n💡 Quick install (required): {cmd} {' '.join(set(required_installs))}") 

125 

126 if optional and not required: 

127 optional_installs = [dep.suggested_install or dep.module for dep in optional] 

128 lines.append(f"\n💡 Quick install (optional): {cmd} {' '.join(set(optional_installs))}") 

129 

130 return "\n".join(lines) 

131 

132 def _get_install_command(self, dep: MissingDependency, use_uv: bool = None) -> str: 

133 """Get the install command for a dependency.""" 

134 import shutil 

135 

136 # Auto-detect uv if not specified 

137 if use_uv is None: 

138 use_uv = shutil.which('uv') is not None 

139 

140 pkg = dep.suggested_install or dep.module 

141 cmd = "uv pip install" if use_uv else "pip install" 

142 return f"{cmd} {pkg}" 

143 

144 def get_install_commands(self, include_optional: bool = False, include_dev: bool = False, use_uv: bool = None) -> Dict[str, str]: 

145 """Get install commands for different dependency types.""" 

146 import shutil 

147 

148 # Auto-detect uv if not specified 

149 if use_uv is None: 

150 use_uv = shutil.which('uv') is not None 

151 

152 cmd = "uv pip install" if use_uv else "pip install" 

153 commands = {} 

154 

155 # Required dependencies 

156 required = self.required_dependencies 

157 if required: 

158 packages = set(dep.suggested_install or dep.module for dep in required) 

159 commands["required"] = f"{cmd} {' '.join(packages)}" 

160 

161 # Optional dependencies 

162 if include_optional: 

163 optional = self.optional_dependencies 

164 if optional: 

165 packages = set(dep.suggested_install or dep.module for dep in optional) 

166 commands["optional"] = f"{cmd} {' '.join(packages)}" 

167 

168 # Development dependencies 

169 if include_dev: 

170 dev_deps = self.dev_dependencies 

171 if dev_deps: 

172 packages = set(dep.suggested_install or dep.module for dep in dev_deps) 

173 commands["dev"] = f"{cmd} {' '.join(packages)}" 

174 

175 return commands 

176 

177 

178class SafetyError(FactoryError): 

179 """Raised when a safety check fails.""" 

180 pass 

181 

182 

183class CodeExecutionError(SafetyError): 

184 """Raised when code execution is attempted but not allowed.""" 

185 def __init__(self, message: str, module_path: str): 

186 super().__init__(message) 

187 self.module_path = module_path 

188 

189 

190def handle_factory_error(error: Exception, context: str = "", verbose: bool = True) -> str: 

191 """Handle and format factory errors consistently.""" 

192 if isinstance(error, MissingDependencyError): 

193 return error.format_error_message() 

194 

195 elif isinstance(error, ToolRegistrationError): 

196 msg = f"Failed to register tool '{error.tool_name}'" 

197 if context: 

198 msg = f"{context}: {msg}" 

199 if error.original_error and verbose: 

200 msg = f"{msg}: {error.original_error}" 

201 return msg 

202 

203 elif isinstance(error, ModuleLoadError): 

204 msg = f"Failed to load module '{error.module_path}'" 

205 if context: 

206 msg = f"{context}: {msg}" 

207 if error.original_error and verbose: 

208 msg = f"{msg}: {error.original_error}" 

209 return msg 

210 

211 elif isinstance(error, TypeConversionError): 

212 msg = f"Type conversion failed" 

213 if error.parameter_name: 

214 msg = f"{msg} for parameter '{error.parameter_name}'" 

215 msg = f"{msg}: cannot convert {type(error.value).__name__} to {error.target_type.__name__}" 

216 if context: 

217 msg = f"{context}: {msg}" 

218 return msg 

219 

220 elif isinstance(error, FactoryError): 

221 msg = str(error) 

222 if context: 

223 msg = f"{context}: {msg}" 

224 return msg 

225 

226 else: 

227 # Generic error handling 

228 error_type = type(error).__name__ 

229 msg = f"{error_type}: {error}" 

230 if context: 

231 msg = f"{context}: {msg}" 

232 return msg 

233 

234 

235def log_factory_error(error: Exception, context: str = "", level: int = logging.ERROR): 

236 """Log factory errors with appropriate detail level.""" 

237 message = handle_factory_error(error, context, verbose=True) 

238 

239 if isinstance(error, (MissingDependencyError, ConfigurationError)): 

240 # These are expected errors that users should see 

241 logger.log(level, message) 

242 else: 

243 # Unexpected errors should include traceback 

244 logger.log(level, message, exc_info=True)