#!/usr/bin/env python3 """ stella-callgraph-python Call graph extraction tool for Python projects using AST analysis. """ import argparse import ast import json import os import sys from pathlib import Path from typing import Any from ast_analyzer import PythonASTAnalyzer from framework_detect import detect_frameworks def main() -> int: parser = argparse.ArgumentParser( description="Extract call graphs from Python projects" ) parser.add_argument( "path", help="Path to Python project or file" ) parser.add_argument( "--json", action="store_true", help="Output formatted JSON" ) parser.add_argument( "--verbose", "-v", action="store_true", help="Verbose output" ) args = parser.parse_args() try: result = analyze_project(Path(args.path), verbose=args.verbose) if args.json: print(json.dumps(result, indent=2)) else: print(json.dumps(result)) return 0 except Exception as e: print(f"Error: {e}", file=sys.stderr) return 1 def analyze_project(project_path: Path, verbose: bool = False) -> dict[str, Any]: """Analyze a Python project and extract its call graph.""" if not project_path.exists(): raise FileNotFoundError(f"Path not found: {project_path}") # Find project root (look for pyproject.toml, setup.py, etc.) root = find_project_root(project_path) package_name = extract_package_name(root) # Detect frameworks frameworks = detect_frameworks(root) # Find Python source files source_files = find_python_files(root) if verbose: print(f"Found {len(source_files)} Python files", file=sys.stderr) # Analyze all files analyzer = PythonASTAnalyzer(package_name, root, frameworks) for source_file in source_files: try: with open(source_file, 'r', encoding='utf-8') as f: content = f.read() tree = ast.parse(content, filename=str(source_file)) relative_path = source_file.relative_to(root) analyzer.analyze_file(tree, str(relative_path)) except SyntaxError as e: if verbose: print(f"Warning: Syntax error in {source_file}: {e}", file=sys.stderr) except Exception as e: if verbose: print(f"Warning: Failed to parse {source_file}: {e}", file=sys.stderr) return analyzer.get_result() def find_project_root(path: Path) -> Path: """Find the project root by looking for marker files.""" markers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', '.git'] current = path.resolve() if current.is_file(): current = current.parent while current != current.parent: for marker in markers: if (current / marker).exists(): return current current = current.parent return path.resolve() if path.is_dir() else path.parent.resolve() def extract_package_name(root: Path) -> str: """Extract package name from project metadata.""" # Try pyproject.toml pyproject = root / 'pyproject.toml' if pyproject.exists(): try: import tomllib with open(pyproject, 'rb') as f: data = tomllib.load(f) return data.get('project', {}).get('name', root.name) except Exception: pass # Try setup.py setup_py = root / 'setup.py' if setup_py.exists(): try: with open(setup_py, 'r') as f: content = f.read() # Simple regex-based extraction import re match = re.search(r"name\s*=\s*['\"]([^'\"]+)['\"]", content) if match: return match.group(1) except Exception: pass return root.name def find_python_files(root: Path) -> list[Path]: """Find all Python source files in the project.""" exclude_dirs = { '__pycache__', '.git', '.tox', '.nox', '.mypy_cache', '.pytest_cache', 'venv', '.venv', 'env', '.env', 'node_modules', 'dist', 'build', 'eggs', '*.egg-info' } files = [] for path in root.rglob('*.py'): # Skip excluded directories skip = False for part in path.parts: if part in exclude_dirs or part.endswith('.egg-info'): skip = True break if not skip and not path.name.startswith('.'): files.append(path) return sorted(files) if __name__ == '__main__': sys.exit(main())