Files
git.stella-ops.org/devops/tools/callgraph/python/__main__.py
2025-12-26 18:11:06 +02:00

169 lines
4.6 KiB
Python

#!/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())