Coverage for src/quickmcp/factory/import_analyzer.py: 87%
161 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-20 20:56 +0200
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-20 20:56 +0200
1"""
2Import analysis for detecting missing dependencies.
3"""
5import ast
6import importlib
7import logging
8from dataclasses import dataclass
9from pathlib import Path
10from typing import List, Optional, Set, Dict, Any
12from .config import FactoryConfig, DEFAULT_CONFIG
14logger = logging.getLogger(__name__)
17@dataclass
18class MissingDependency:
19 """Information about a missing dependency."""
20 module: str
21 import_type: str # "import", "from_import", "optional"
22 line_number: Optional[int] = None
23 source_line: Optional[str] = None
24 suggested_install: Optional[str] = None
25 is_dev_dependency: bool = False
28class ImportAnalysisError(Exception):
29 """Raised when import analysis fails."""
30 pass
33class ImportAnalyzer:
34 """Analyze Python files for imports and missing dependencies."""
36 # Common package name to pip install name mappings
37 BASE_PIP_MAPPINGS = {
38 'aiohttp': 'aiohttp',
39 'requests': 'requests',
40 'fastapi': 'fastapi',
41 'starlette': 'starlette',
42 'uvicorn': 'uvicorn',
43 'pydantic': 'pydantic',
44 'numpy': 'numpy',
45 'pandas': 'pandas',
46 'sqlalchemy': 'sqlalchemy',
47 'psycopg2': 'psycopg2-binary',
48 'mysql': 'mysql-connector-python',
49 'redis': 'redis',
50 'celery': 'celery',
51 'click': 'click',
52 'typer': 'typer',
53 'rich': 'rich',
54 'httpx': 'httpx',
55 'websockets': 'websockets',
56 'jinja2': 'jinja2',
57 'flask': 'flask',
58 'django': 'django',
59 'boto3': 'boto3',
60 'openai': 'openai',
61 'anthropic': 'anthropic',
62 'langchain': 'langchain',
63 'transformers': 'transformers',
64 'torch': 'torch',
65 'tensorflow': 'tensorflow',
66 'sklearn': 'scikit-learn',
67 'cv2': 'opencv-python',
68 'PIL': 'Pillow',
69 'yaml': 'PyYAML',
70 'toml': 'toml',
71 'dotenv': 'python-dotenv',
72 'jwt': 'PyJWT',
73 'bcrypt': 'bcrypt',
74 'cryptography': 'cryptography',
75 'bs4': 'beautifulsoup4',
76 }
78 # Development/testing packages
79 DEV_PACKAGES = {
80 'pytest', 'mypy', 'black', 'flake8', 'pylint', 'coverage',
81 'tox', 'pre-commit', 'sphinx', 'mkdocs', 'bandit',
82 'isort', 'autopep8', 'yapf', 'ruff'
83 }
85 # Standard library modules (Python 3.11+)
86 BASE_STDLIB_MODULES = {
87 'os', 'sys', 'json', 'ast', 'inspect', 'importlib', 'pathlib',
88 'typing', 'logging', 'asyncio', 'traceback', 'dataclasses',
89 'functools', 'itertools', 'collections', 'datetime', 'time',
90 'math', 'random', 'string', 're', 'urllib', 'http', 'email',
91 'xml', 'html', 'sqlite3', 'pickle', 'csv', 'configparser',
92 'argparse', 'subprocess', 'threading', 'multiprocessing',
93 'queue', 'socket', 'ssl', 'hashlib', 'uuid', 'tempfile',
94 'shutil', 'glob', 'fnmatch', 'zipfile', 'tarfile', 'gzip',
95 'io', 'contextlib', 'warnings', 'unittest', 'doctest',
96 'operator', 'copy', 'weakref', 'gc', 'atexit', 'signal',
97 'errno', 'stat', 'filecmp', 'linecache', 'tokenize',
98 'keyword', 'heapq', 'bisect', 'array', 'struct', 'codecs'
99 }
101 def __init__(self, config: Optional[FactoryConfig] = None):
102 """Initialize the import analyzer."""
103 self.config = config or DEFAULT_CONFIG
104 self._pip_mappings = self.config.merge_pip_mappings(self.BASE_PIP_MAPPINGS)
105 self._stdlib_modules = self.config.get_effective_stdlib_modules(self.BASE_STDLIB_MODULES)
106 self._analysis_cache: Dict[str, List[MissingDependency]] = {}
108 def analyze_file(self, file_path: str) -> List[MissingDependency]:
109 """Analyze a Python file for missing dependencies."""
110 file_path = str(Path(file_path).resolve())
112 # Check cache if enabled
113 if self.config.cache_dependency_analysis and file_path in self._analysis_cache:
114 logger.debug(f"Using cached analysis for {file_path}")
115 return self._analysis_cache[file_path]
117 try:
118 missing_deps = self._analyze_file_impl(file_path)
120 # Cache results if enabled
121 if self.config.cache_dependency_analysis:
122 self._analysis_cache[file_path] = missing_deps
124 return missing_deps
126 except Exception as e:
127 error_msg = f"Failed to analyze imports in {file_path}: {e}"
128 if self.config.verbose_errors:
129 logger.error(error_msg, exc_info=True)
130 else:
131 logger.error(error_msg)
132 raise ImportAnalysisError(error_msg) from e
134 def _analyze_file_impl(self, file_path: str) -> List[MissingDependency]:
135 """Internal implementation of file analysis."""
136 path = Path(file_path)
138 # Check file size
139 if path.stat().st_size > self.config.max_file_size_mb * 1024 * 1024:
140 raise ImportAnalysisError(f"File {file_path} is too large (>{self.config.max_file_size_mb}MB)")
142 # Read and parse file
143 try:
144 with open(file_path, 'r', encoding='utf-8') as f:
145 content = f.read()
146 lines = content.splitlines()
147 except UnicodeDecodeError:
148 raise ImportAnalysisError(f"Cannot decode file {file_path} as UTF-8")
149 except IOError as e:
150 raise ImportAnalysisError(f"Cannot read file {file_path}: {e}")
152 # Parse AST
153 try:
154 tree = ast.parse(content, filename=file_path)
155 except SyntaxError as e:
156 logger.warning(f"Syntax error in {file_path}: {e}")
157 # Return empty list for files with syntax errors
158 return []
160 # Find all import statements
161 missing_deps = []
162 for node in ast.walk(tree):
163 if isinstance(node, ast.Import):
164 for alias in node.names:
165 missing = self._check_module(alias.name, "import", node.lineno, lines)
166 if missing:
167 missing_deps.append(missing)
169 elif isinstance(node, ast.ImportFrom):
170 # Skip relative imports (level > 0 means relative)
171 if node.level > 0:
172 logger.debug(f"Skipping relative import at line {node.lineno}")
173 continue
175 if node.module:
176 missing = self._check_module(node.module, "from_import", node.lineno, lines)
177 if missing:
178 missing_deps.append(missing)
180 # Remove duplicates while preserving order
181 seen = set()
182 unique_deps = []
183 for dep in missing_deps:
184 key = (dep.module, dep.import_type, dep.line_number)
185 if key not in seen:
186 seen.add(key)
187 unique_deps.append(dep)
189 return unique_deps
191 def _check_module(self, module_name: str, import_type: str, line_num: int, lines: List[str]) -> Optional[MissingDependency]:
192 """Check if a module is available."""
193 # Get the top-level module name
194 top_module = module_name.split('.')[0]
196 # Skip standard library modules
197 if top_module in self._stdlib_modules:
198 logger.debug(f"Skipping stdlib module: {top_module}")
199 return None
201 try:
202 importlib.import_module(top_module)
203 logger.debug(f"Module {top_module} is available")
204 return None # Module is available
205 except ImportError:
206 logger.debug(f"Module {top_module} is missing")
207 pass
208 except Exception as e:
209 # Other import errors (circular imports, etc.)
210 logger.warning(f"Unexpected error importing {top_module}: {e}")
211 return None
213 # Get source line
214 source_line = lines[line_num - 1] if 0 < line_num <= len(lines) else None
216 # Check if this is an optional import (in try/except block)
217 is_optional = self._is_optional_import(lines, line_num - 1)
219 # Check if it's a development dependency
220 is_dev = top_module in self.DEV_PACKAGES
222 return MissingDependency(
223 module=top_module,
224 import_type="optional" if is_optional else import_type,
225 line_number=line_num,
226 source_line=source_line.strip() if source_line else None,
227 suggested_install=self._pip_mappings.get(top_module, top_module),
228 is_dev_dependency=is_dev
229 )
231 def _is_optional_import(self, lines: List[str], line_index: int) -> bool:
232 """Check if an import is inside a try/except block or TYPE_CHECKING block."""
233 if line_index < 0 or line_index >= len(lines):
234 return False
236 # Get the indentation level of the import line
237 import_line = lines[line_index]
238 import_indent = len(import_line) - len(import_line.lstrip())
240 # Check for TYPE_CHECKING block - must be indented under it
241 in_type_checking = False
242 for i in range(line_index - 1, max(-1, line_index - 10), -1):
243 line = lines[i]
244 if not line.strip(): # Skip empty lines
245 continue
247 line_indent = len(line) - len(line.lstrip())
249 # Check if TYPE_CHECKING block is above us
250 if 'if TYPE_CHECKING:' in line and line_indent < import_indent:
251 in_type_checking = True
252 break
254 # If we find a line at same or lesser indentation, check if we're leaving the block
255 if line_indent < import_indent and not line.strip().startswith(('import', 'from')):
256 # We've left the import block
257 break
259 if in_type_checking:
260 return True
262 # Check if we're in an except block
263 for i in range(line_index - 1, max(-1, line_index - 10), -1):
264 line = lines[i]
265 if not line.strip(): # Skip empty lines
266 continue
268 line_indent = len(line) - len(line.lstrip())
270 # If we find an except at same or lesser indentation, we're optional
271 if line_indent < import_indent and line.strip().startswith('except'):
272 return True
274 # Look backwards for 'try:' at any indentation less than import
275 # This handles nested try blocks
276 try_indent = None
277 for i in range(line_index - 1, max(-1, line_index - 20), -1):
278 line = lines[i]
279 if not line.strip(): # Skip empty lines
280 continue
282 line_indent = len(line) - len(line.lstrip())
284 # Look for try: at any level less indented than the import
285 if line_indent < import_indent and line.strip().startswith('try:'):
286 try_indent = line_indent
287 break
289 if try_indent is None:
290 return False
292 # Look forwards for 'except' block at the same indentation as the found try
293 for i in range(line_index + 1, min(len(lines), line_index + 20)):
294 line = lines[i]
295 if not line.strip(): # Skip empty lines
296 continue
298 line_indent = len(line) - len(line.lstrip())
300 # Look for except at the same level as the try
301 if line_indent == try_indent:
302 if line.strip().startswith('except'):
303 return True
304 # Don't stop on other code at try level, as except might come later
306 return False
308 def get_install_commands(self, missing_deps: List[MissingDependency], use_uv: bool = None) -> Dict[str, str]:
309 """Get install commands for missing dependencies."""
310 import shutil
312 if use_uv is None:
313 use_uv = shutil.which('uv') is not None
315 commands = {}
316 for dep in missing_deps:
317 if dep.import_type == "optional":
318 key = f"optional:{dep.module}"
319 else:
320 key = dep.module
322 cmd = "uv pip install" if use_uv else "pip install"
323 commands[key] = f"{cmd} {dep.suggested_install}"
325 return commands
327 def clear_cache(self):
328 """Clear the analysis cache."""
329 self._analysis_cache.clear()
330 logger.debug("Cleared import analysis cache")
332 def get_cache_info(self) -> Dict[str, Any]:
333 """Get information about the cache."""
334 return {
335 "enabled": self.config.cache_dependency_analysis,
336 "entries": len(self._analysis_cache),
337 "files": list(self._analysis_cache.keys())
338 }