- Implemented a new tool `stella-callgraph-node` that extracts call graphs from JavaScript/TypeScript projects using Babel AST. - Added command-line interface with options for JSON output and help. - Included functionality to analyze project structure, detect functions, and build call graphs. - Created a package.json file for dependency management. feat: introduce stella-callgraph-python for Python call graph extraction - Developed `stella-callgraph-python` to extract call graphs from Python projects using AST analysis. - Implemented command-line interface with options for JSON output and verbose logging. - Added framework detection to identify popular web frameworks and their entry points. - Created an AST analyzer to traverse Python code and extract function definitions and calls. - Included requirements.txt for project dependencies. chore: add framework detection for Python projects - Implemented framework detection logic to identify frameworks like Flask, FastAPI, Django, and others based on project files and import patterns. - Enhanced the AST analyzer to recognize entry points based on decorators and function definitions.
251 lines
8.1 KiB
Python
251 lines
8.1 KiB
Python
"""
|
|
Framework detection for Python projects.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
import re
|
|
|
|
|
|
# Framework patterns
|
|
FRAMEWORK_PATTERNS = {
|
|
"flask": {
|
|
"packages": ["flask"],
|
|
"imports": [r"from flask import", r"import flask"],
|
|
"patterns": [r"@\w+\.route\(", r"Flask\(__name__\)"],
|
|
"entrypoint_type": "http_handler"
|
|
},
|
|
"fastapi": {
|
|
"packages": ["fastapi"],
|
|
"imports": [r"from fastapi import", r"import fastapi"],
|
|
"patterns": [r"@\w+\.(get|post|put|delete|patch)\(", r"FastAPI\("],
|
|
"entrypoint_type": "http_handler"
|
|
},
|
|
"django": {
|
|
"packages": ["django"],
|
|
"imports": [r"from django", r"import django"],
|
|
"patterns": [r"urlpatterns\s*=", r"class \w+View\(", r"@api_view\("],
|
|
"entrypoint_type": "http_handler"
|
|
},
|
|
"click": {
|
|
"packages": ["click"],
|
|
"imports": [r"from click import", r"import click"],
|
|
"patterns": [r"@click\.command\(", r"@click\.group\(", r"@\w+\.command\("],
|
|
"entrypoint_type": "cli_command"
|
|
},
|
|
"typer": {
|
|
"packages": ["typer"],
|
|
"imports": [r"from typer import", r"import typer"],
|
|
"patterns": [r"typer\.Typer\(", r"@\w+\.command\("],
|
|
"entrypoint_type": "cli_command"
|
|
},
|
|
"celery": {
|
|
"packages": ["celery"],
|
|
"imports": [r"from celery import", r"import celery"],
|
|
"patterns": [r"@\w+\.task\(", r"@shared_task\(", r"Celery\("],
|
|
"entrypoint_type": "background_job"
|
|
},
|
|
"dramatiq": {
|
|
"packages": ["dramatiq"],
|
|
"imports": [r"from dramatiq import", r"import dramatiq"],
|
|
"patterns": [r"@dramatiq\.actor\("],
|
|
"entrypoint_type": "background_job"
|
|
},
|
|
"rq": {
|
|
"packages": ["rq"],
|
|
"imports": [r"from rq import", r"import rq"],
|
|
"patterns": [r"@job\(", r"queue\.enqueue\("],
|
|
"entrypoint_type": "background_job"
|
|
},
|
|
"sanic": {
|
|
"packages": ["sanic"],
|
|
"imports": [r"from sanic import", r"import sanic"],
|
|
"patterns": [r"@\w+\.route\(", r"Sanic\("],
|
|
"entrypoint_type": "http_handler"
|
|
},
|
|
"aiohttp": {
|
|
"packages": ["aiohttp"],
|
|
"imports": [r"from aiohttp import", r"import aiohttp"],
|
|
"patterns": [r"web\.Application\(", r"@routes\.(get|post|put|delete)\("],
|
|
"entrypoint_type": "http_handler"
|
|
},
|
|
"tornado": {
|
|
"packages": ["tornado"],
|
|
"imports": [r"from tornado import", r"import tornado"],
|
|
"patterns": [r"class \w+Handler\(", r"tornado\.web\.Application\("],
|
|
"entrypoint_type": "http_handler"
|
|
},
|
|
"aws_lambda": {
|
|
"packages": ["aws_lambda_powertools", "boto3"],
|
|
"imports": [r"def handler\(event", r"def lambda_handler\("],
|
|
"patterns": [r"def handler\(event,\s*context\)", r"@logger\.inject_lambda_context"],
|
|
"entrypoint_type": "lambda"
|
|
},
|
|
"azure_functions": {
|
|
"packages": ["azure.functions"],
|
|
"imports": [r"import azure\.functions"],
|
|
"patterns": [r"@func\.route\(", r"func\.HttpRequest"],
|
|
"entrypoint_type": "cloud_function"
|
|
},
|
|
"grpc": {
|
|
"packages": ["grpcio", "grpc"],
|
|
"imports": [r"import grpc", r"from grpc import"],
|
|
"patterns": [r"_pb2_grpc\.add_\w+Servicer_to_server\("],
|
|
"entrypoint_type": "grpc_method"
|
|
},
|
|
"graphql": {
|
|
"packages": ["graphene", "strawberry", "ariadne"],
|
|
"imports": [r"import graphene", r"import strawberry", r"import ariadne"],
|
|
"patterns": [r"@strawberry\.(type|mutation|query)\(", r"class \w+\(graphene\.ObjectType\)"],
|
|
"entrypoint_type": "graphql_resolver"
|
|
}
|
|
}
|
|
|
|
|
|
def detect_frameworks(project_root: Path) -> list[str]:
|
|
"""Detect frameworks used in a Python project."""
|
|
detected: set[str] = set()
|
|
|
|
# Check pyproject.toml
|
|
pyproject = project_root / "pyproject.toml"
|
|
if pyproject.exists():
|
|
detected.update(_detect_from_pyproject(pyproject))
|
|
|
|
# Check requirements.txt
|
|
requirements = project_root / "requirements.txt"
|
|
if requirements.exists():
|
|
detected.update(_detect_from_requirements(requirements))
|
|
|
|
# Check setup.py
|
|
setup_py = project_root / "setup.py"
|
|
if setup_py.exists():
|
|
detected.update(_detect_from_setup_py(setup_py))
|
|
|
|
# Scan source files for import patterns
|
|
detected.update(_detect_from_source(project_root))
|
|
|
|
return sorted(detected)
|
|
|
|
|
|
def _detect_from_pyproject(path: Path) -> set[str]:
|
|
"""Detect frameworks from pyproject.toml."""
|
|
detected: set[str] = set()
|
|
|
|
try:
|
|
import tomllib
|
|
with open(path, 'rb') as f:
|
|
data = tomllib.load(f)
|
|
|
|
# Check dependencies
|
|
deps = set()
|
|
deps.update(data.get("project", {}).get("dependencies", []))
|
|
deps.update(data.get("project", {}).get("optional-dependencies", {}).get("dev", []))
|
|
|
|
# Poetry format
|
|
poetry = data.get("tool", {}).get("poetry", {})
|
|
deps.update(poetry.get("dependencies", {}).keys())
|
|
deps.update(poetry.get("dev-dependencies", {}).keys())
|
|
|
|
for dep in deps:
|
|
# Extract package name (remove version specifier)
|
|
pkg = re.split(r'[<>=!~\[]', dep)[0].strip().lower()
|
|
for framework, config in FRAMEWORK_PATTERNS.items():
|
|
if pkg in config["packages"]:
|
|
detected.add(framework)
|
|
except Exception:
|
|
pass
|
|
|
|
return detected
|
|
|
|
|
|
def _detect_from_requirements(path: Path) -> set[str]:
|
|
"""Detect frameworks from requirements.txt."""
|
|
detected: set[str] = set()
|
|
|
|
try:
|
|
with open(path, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith('#'):
|
|
continue
|
|
|
|
# Extract package name
|
|
pkg = re.split(r'[<>=!~\[]', line)[0].strip().lower()
|
|
for framework, config in FRAMEWORK_PATTERNS.items():
|
|
if pkg in config["packages"]:
|
|
detected.add(framework)
|
|
except Exception:
|
|
pass
|
|
|
|
return detected
|
|
|
|
|
|
def _detect_from_setup_py(path: Path) -> set[str]:
|
|
"""Detect frameworks from setup.py."""
|
|
detected: set[str] = set()
|
|
|
|
try:
|
|
with open(path, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Look for install_requires
|
|
for framework, config in FRAMEWORK_PATTERNS.items():
|
|
for pkg in config["packages"]:
|
|
if f'"{pkg}"' in content or f"'{pkg}'" in content:
|
|
detected.add(framework)
|
|
except Exception:
|
|
pass
|
|
|
|
return detected
|
|
|
|
|
|
def _detect_from_source(project_root: Path) -> set[str]:
|
|
"""Detect frameworks by scanning Python source files."""
|
|
detected: set[str] = set()
|
|
|
|
exclude_dirs = {
|
|
'__pycache__', '.git', '.tox', '.nox', 'venv', '.venv', 'env', '.env',
|
|
'node_modules', 'dist', 'build'
|
|
}
|
|
|
|
# Only scan first few files to avoid slow startup
|
|
max_files = 50
|
|
scanned = 0
|
|
|
|
for py_file in project_root.rglob('*.py'):
|
|
if scanned >= max_files:
|
|
break
|
|
|
|
# Skip excluded directories
|
|
skip = False
|
|
for part in py_file.parts:
|
|
if part in exclude_dirs:
|
|
skip = True
|
|
break
|
|
if skip:
|
|
continue
|
|
|
|
try:
|
|
with open(py_file, 'r', encoding='utf-8') as f:
|
|
content = f.read(4096) # Only read first 4KB
|
|
|
|
for framework, config in FRAMEWORK_PATTERNS.items():
|
|
if framework in detected:
|
|
continue
|
|
|
|
for pattern in config["imports"] + config["patterns"]:
|
|
if re.search(pattern, content):
|
|
detected.add(framework)
|
|
break
|
|
|
|
scanned += 1
|
|
except Exception:
|
|
continue
|
|
|
|
return detected
|
|
|
|
|
|
def get_entrypoint_type(framework: str) -> str:
|
|
"""Get the entrypoint type for a framework."""
|
|
return FRAMEWORK_PATTERNS.get(framework, {}).get("entrypoint_type", "unknown")
|