Files
crawl4ai/tests/check_dependencies.py

344 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Dependency checker for Crawl4AI
Analyzes imports in the codebase and shows which files use them
"""
import ast
import os
import sys
from pathlib import Path
from typing import Set, Dict, List, Tuple
from collections import defaultdict
import re
import toml
# Standard library modules to ignore
STDLIB_MODULES = {
'abc', 'argparse', 'asyncio', 'base64', 'collections', 'concurrent', 'contextlib',
'copy', 'datetime', 'decimal', 'email', 'enum', 'functools', 'glob', 'hashlib',
'http', 'importlib', 'io', 'itertools', 'json', 'logging', 'math', 'mimetypes',
'multiprocessing', 'os', 'pathlib', 'pickle', 'platform', 'pprint', 'random',
're', 'shutil', 'signal', 'socket', 'sqlite3', 'string', 'subprocess', 'sys',
'tempfile', 'threading', 'time', 'traceback', 'typing', 'unittest', 'urllib',
'uuid', 'warnings', 'weakref', 'xml', 'zipfile', 'dataclasses', 'secrets',
'statistics', 'textwrap', 'queue', 'csv', 'gzip', 'tarfile', 'configparser',
'inspect', 'operator', 'struct', 'binascii', 'codecs', 'locale', 'gc',
'atexit', 'builtins', 'html', 'errno', 'fcntl', 'pwd', 'grp', 'resource',
'termios', 'tty', 'pty', 'select', 'selectors', 'ssl', 'zlib', 'bz2',
'lzma', 'types', 'copy', 'pydoc', 'profile', 'cProfile', 'timeit',
'trace', 'doctest', 'pdb', 'contextvars', 'dataclasses', 'graphlib',
'zoneinfo', 'tomllib', 'cgi', 'wsgiref', 'fileinput', 'linecache',
'tokenize', 'tabnanny', 'compileall', 'dis', 'pickletools', 'formatter',
'__future__', 'array', 'ctypes', 'heapq', 'bisect', 'array', 'weakref',
'types', 'copy', 'pprint', 'repr', 'numbers', 'cmath', 'fractions',
'statistics', 'itertools', 'functools', 'operator', 'pathlib', 'fileinput',
'stat', 'filecmp', 'tempfile', 'glob', 'fnmatch', 'linecache', 'shutil',
'pickle', 'copyreg', 'shelve', 'marshal', 'dbm', 'sqlite3', 'zlib', 'gzip',
'bz2', 'lzma', 'zipfile', 'tarfile', 'configparser', 'netrc', 'xdrlib',
'plistlib', 'hashlib', 'hmac', 'secrets', 'os', 'io', 'time', 'argparse',
'getopt', 'logging', 'getpass', 'curses', 'platform', 'errno', 'ctypes',
'threading', 'multiprocessing', 'concurrent', 'subprocess', 'sched', 'queue',
'contextvars', 'asyncio', 'socket', 'ssl', 'email', 'json', 'mailcap',
'mailbox', 'mimetypes', 'base64', 'binhex', 'binascii', 'quopri', 'uu',
'html', 'xml', 'webbrowser', 'cgi', 'cgitb', 'wsgiref', 'urllib', 'http',
'ftplib', 'poplib', 'imaplib', 'nntplib', 'smtplib', 'smtpd', 'telnetlib',
'uuid', 'socketserver', 'xmlrpc', 'ipaddress', 'audioop', 'aifc', 'sunau',
'wave', 'chunk', 'colorsys', 'imghdr', 'sndhdr', 'ossaudiodev', 'gettext',
'locale', 'turtle', 'cmd', 'shlex', 'tkinter', 'typing', 'pydoc', 'doctest',
'unittest', 'test', '2to3', 'distutils', 'venv', 'ensurepip', 'zipapp',
'py_compile', 'compileall', 'dis', 'pickletools', 'pdb', 'timeit', 'trace',
'tracemalloc', 'warnings', 'faulthandler', 'pdb', 'dataclasses', 'cgi',
'cgitb', 'chunk', 'crypt', 'imghdr', 'mailcap', 'nis', 'nntplib', 'optparse',
'ossaudiodev', 'pipes', 'smtpd', 'sndhdr', 'spwd', 'sunau', 'telnetlib',
'uu', 'xdrlib', 'msilib', 'pstats', 'rlcompleter', 'tkinter', 'ast'
}
# Known package name mappings (import name -> package name)
PACKAGE_MAPPINGS = {
'bs4': 'beautifulsoup4',
'PIL': 'pillow',
'cv2': 'opencv-python',
'sklearn': 'scikit-learn',
'yaml': 'PyYAML',
'OpenSSL': 'pyOpenSSL',
'sqlalchemy': 'SQLAlchemy',
'playwright': 'playwright',
'patchright': 'patchright',
'dotenv': 'python-dotenv',
'fake_useragent': 'fake-useragent',
'playwright_stealth': 'tf-playwright-stealth',
'sentence_transformers': 'sentence-transformers',
'rank_bm25': 'rank-bm25',
'snowballstemmer': 'snowballstemmer',
'pypdf': 'pypdf',
'pdf2image': 'pdf2image',
}
class ImportVisitor(ast.NodeVisitor):
"""AST visitor to extract imports from Python files"""
def __init__(self):
self.imports = {} # Changed to dict to store line numbers
self.from_imports = {}
def visit_Import(self, node):
for alias in node.names:
module_name = alias.name.split('.')[0]
if module_name not in self.imports:
self.imports[module_name] = []
self.imports[module_name].append(node.lineno)
def visit_ImportFrom(self, node):
if node.module and node.level == 0: # absolute imports only
module_name = node.module.split('.')[0]
if module_name not in self.from_imports:
self.from_imports[module_name] = []
self.from_imports[module_name].append(node.lineno)
def extract_imports_from_file(filepath: Path) -> Dict[str, List[int]]:
"""Extract all imports from a Python file with line numbers"""
all_imports = {}
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
tree = ast.parse(content)
visitor = ImportVisitor()
visitor.visit(tree)
# Merge imports and from_imports
for module, lines in visitor.imports.items():
if module not in all_imports:
all_imports[module] = []
all_imports[module].extend(lines)
for module, lines in visitor.from_imports.items():
if module not in all_imports:
all_imports[module] = []
all_imports[module].extend(lines)
except Exception as e:
# Silently skip files that can't be parsed
pass
return all_imports
def get_codebase_imports_with_files(root_dir: Path) -> Dict[str, List[Tuple[str, List[int]]]]:
"""Get all imports from the crawl4ai library and docs folders with file locations and line numbers"""
import_to_files = defaultdict(list)
# Only scan crawl4ai library folder and docs folder
target_dirs = [
root_dir / 'crawl4ai',
root_dir / 'docs'
]
for target_dir in target_dirs:
if not target_dir.exists():
continue
for py_file in target_dir.rglob('*.py'):
# Skip __pycache__ directories
if '__pycache__' in py_file.parts:
continue
# Skip setup.py and similar files
if py_file.name in ['setup.py', 'setup.cfg', 'conf.py']:
continue
imports = extract_imports_from_file(py_file)
# Map each import to the file and line numbers
for imp, line_numbers in imports.items():
relative_path = py_file.relative_to(root_dir)
import_to_files[imp].append((str(relative_path), sorted(line_numbers)))
return dict(import_to_files)
def get_declared_dependencies() -> Set[str]:
"""Get declared dependencies from pyproject.toml and requirements.txt"""
declared = set()
# Read from pyproject.toml
if Path('pyproject.toml').exists():
with open('pyproject.toml', 'r') as f:
data = toml.load(f)
# Get main dependencies
deps = data.get('project', {}).get('dependencies', [])
for dep in deps:
# Parse dependency string (e.g., "numpy>=1.26.0,<3")
match = re.match(r'^([a-zA-Z0-9_-]+)', dep)
if match:
pkg_name = match.group(1).lower()
declared.add(pkg_name)
# Get optional dependencies
optional = data.get('project', {}).get('optional-dependencies', {})
for group, deps in optional.items():
for dep in deps:
match = re.match(r'^([a-zA-Z0-9_-]+)', dep)
if match:
pkg_name = match.group(1).lower()
declared.add(pkg_name)
# Also check requirements.txt as backup
if Path('requirements.txt').exists():
with open('requirements.txt', 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
match = re.match(r'^([a-zA-Z0-9_-]+)', line)
if match:
pkg_name = match.group(1).lower()
declared.add(pkg_name)
return declared
def normalize_package_name(name: str) -> str:
"""Normalize package name for comparison"""
# Handle known mappings first
if name in PACKAGE_MAPPINGS:
return PACKAGE_MAPPINGS[name].lower()
# Basic normalization
return name.lower().replace('_', '-')
def check_missing_dependencies():
"""Main function to check for missing dependencies"""
print("🔍 Analyzing crawl4ai library and docs folders...\n")
# Get all imports with their file locations
root_dir = Path('.')
import_to_files = get_codebase_imports_with_files(root_dir)
# Get declared dependencies
declared_deps = get_declared_dependencies()
# Normalize declared dependencies
normalized_declared = {normalize_package_name(dep) for dep in declared_deps}
# Categorize imports
external_imports = {}
local_imports = {}
# Known local packages
local_packages = {'crawl4ai'}
for imp, file_info in import_to_files.items():
# Skip standard library
if imp in STDLIB_MODULES:
continue
# Check if it's a local import
if any(imp.startswith(local) for local in local_packages):
local_imports[imp] = file_info
else:
external_imports[imp] = file_info
# Check which external imports are not declared
not_declared = {}
declared_imports = {}
for imp, file_info in external_imports.items():
normalized_imp = normalize_package_name(imp)
# Check if import is covered by declared dependencies
found = False
for declared in normalized_declared:
if normalized_imp == declared or normalized_imp.startswith(declared + '.') or declared.startswith(normalized_imp):
found = True
break
if found:
declared_imports[imp] = file_info
else:
not_declared[imp] = file_info
# Print results
print(f"📊 Summary:")
print(f" - Total unique imports: {len(import_to_files)}")
print(f" - External imports: {len(external_imports)}")
print(f" - Declared dependencies: {len(declared_deps)}")
print(f" - External imports NOT in dependencies: {len(not_declared)}\n")
if not_declared:
print("❌ External imports NOT declared in pyproject.toml or requirements.txt:\n")
# Sort by import name
for imp in sorted(not_declared.keys()):
file_info = not_declared[imp]
print(f" 📦 {imp}")
if imp in PACKAGE_MAPPINGS:
print(f" → Package name: {PACKAGE_MAPPINGS[imp]}")
# Show up to 3 files that use this import
for i, (file_path, line_numbers) in enumerate(file_info[:3]):
# Format line numbers for clickable output
if len(line_numbers) == 1:
print(f" - {file_path}:{line_numbers[0]}")
else:
# Show first few line numbers
line_str = ','.join(str(ln) for ln in line_numbers[:3])
if len(line_numbers) > 3:
line_str += f"... ({len(line_numbers)} imports)"
print(f" - {file_path}: lines {line_str}")
if len(file_info) > 3:
print(f" ... and {len(file_info) - 3} more files")
print()
# Check for potentially unused dependencies
print("\n🔎 Checking declared dependencies usage...\n")
# Get all used external packages
used_packages = set()
for imp in external_imports.keys():
normalized = normalize_package_name(imp)
used_packages.add(normalized)
# Find unused
unused = []
for dep in declared_deps:
normalized_dep = normalize_package_name(dep)
# Check if any import uses this dependency
found_usage = False
for used in used_packages:
if used == normalized_dep or used.startswith(normalized_dep) or normalized_dep.startswith(used):
found_usage = True
break
if not found_usage:
# Some packages are commonly unused directly
indirect_deps = {'wheel', 'setuptools', 'pip', 'colorama', 'certifi', 'packaging', 'urllib3'}
if normalized_dep not in indirect_deps:
unused.append(dep)
if unused:
print("⚠️ Declared dependencies with NO imports found:")
for dep in sorted(unused):
print(f" - {dep}")
print("\n Note: These might be used indirectly or by other dependencies")
else:
print("✅ All declared dependencies have corresponding imports")
print("\n" + "="*60)
print("💡 How to use this report:")
print(" 1. Check each ❌ import to see if it's legitimate")
print(" 2. If legitimate, add the package to pyproject.toml")
print(" 3. If it's an internal module or typo, fix the import")
print(" 4. Review unused dependencies - remove if truly not needed")
print("="*60)
if __name__ == '__main__':
check_missing_dependencies()