vizzpy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vizzpy/__init__.py +0 -0
- vizzpy/cli.py +56 -0
- vizzpy/graph.py +69 -0
- vizzpy/parser/__init__.py +0 -0
- vizzpy/parser/project.py +87 -0
- vizzpy/parser/scope.py +34 -0
- vizzpy/parser/walker.py +297 -0
- vizzpy/render.py +67 -0
- vizzpy/server.py +110 -0
- vizzpy/static/app.js +358 -0
- vizzpy/static/index.html +53 -0
- vizzpy/static/style.css +287 -0
- vizzpy/static/vendor/d3.min.js +2 -0
- vizzpy/static/vendor/dagre-d3.min.js +4816 -0
- vizzpy-0.1.0.dist-info/METADATA +119 -0
- vizzpy-0.1.0.dist-info/RECORD +20 -0
- vizzpy-0.1.0.dist-info/WHEEL +5 -0
- vizzpy-0.1.0.dist-info/entry_points.txt +2 -0
- vizzpy-0.1.0.dist-info/licenses/LICENSE +21 -0
- vizzpy-0.1.0.dist-info/top_level.txt +1 -0
vizzpy/__init__.py
ADDED
|
File without changes
|
vizzpy/cli.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
vizzpy CLI entry point.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
vizzpy --serve [--host HOST] [--port PORT]
|
|
6
|
+
vizzpy --headless <project_path> [--output output.svg]
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cli() -> None:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="vizzpy",
|
|
17
|
+
description="Python call tree visualizer",
|
|
18
|
+
)
|
|
19
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
20
|
+
mode.add_argument("--serve", action="store_true", help="Start the web server")
|
|
21
|
+
mode.add_argument("--headless", metavar="PROJECT_PATH", help="Render SVG without a web server")
|
|
22
|
+
|
|
23
|
+
parser.add_argument("--host", default="127.0.0.1", help="Host to bind (serve mode)")
|
|
24
|
+
parser.add_argument("--port", type=int, default=8000, help="Port to bind (serve mode)")
|
|
25
|
+
parser.add_argument("--output", default="call_tree.svg", help="Output SVG path (headless mode)")
|
|
26
|
+
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
|
|
29
|
+
if args.serve:
|
|
30
|
+
_run_server(args.host, args.port)
|
|
31
|
+
else:
|
|
32
|
+
_run_headless(Path(args.headless), Path(args.output))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_server(host: str, port: int) -> None:
|
|
36
|
+
try:
|
|
37
|
+
import uvicorn
|
|
38
|
+
except ImportError:
|
|
39
|
+
sys.exit("uvicorn is required for serve mode: pip install uvicorn[standard]")
|
|
40
|
+
|
|
41
|
+
from vizzpy.server import app
|
|
42
|
+
uvicorn.run(app, host=host, port=port)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_headless(project_path: Path, output_path: Path) -> None:
|
|
46
|
+
if not project_path.exists():
|
|
47
|
+
sys.exit(f"Project path does not exist: {project_path}")
|
|
48
|
+
|
|
49
|
+
from vizzpy.render import render_svg
|
|
50
|
+
print(f"Analyzing {project_path} ...")
|
|
51
|
+
render_svg(project_path, output_path)
|
|
52
|
+
print(f"SVG written to {output_path}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
cli()
|
vizzpy/graph.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Converts raw (caller, callee) edges into a JSON-serializable graph structure
|
|
3
|
+
that the frontend and render layer both consume.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .parser.project import analyze_project
|
|
10
|
+
from .parser.scope import FuncSpan
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_graph(root: Path) -> dict:
|
|
14
|
+
"""
|
|
15
|
+
Analyze the project at *root* and return a dict with shape:
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
"nodes": [{"id": str, "label": str, "module": str, "docstring": str|null}],
|
|
19
|
+
"edges": [{"source": str, "target": str, "count": int}],
|
|
20
|
+
"modules": {"module.name": ["node_id", ...]}
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
edges_raw, node_info = analyze_project(root)
|
|
24
|
+
|
|
25
|
+
# Collect unique node ids from edges only (skip isolates)
|
|
26
|
+
node_ids: set[str] = set()
|
|
27
|
+
for src, tgt in edges_raw:
|
|
28
|
+
node_ids.add(src)
|
|
29
|
+
node_ids.add(tgt)
|
|
30
|
+
|
|
31
|
+
# Deduplicate edges and count multiplicity
|
|
32
|
+
edge_counts: dict[tuple[str, str], int] = defaultdict(int)
|
|
33
|
+
for src, tgt in edges_raw:
|
|
34
|
+
edge_counts[(src, tgt)] += 1
|
|
35
|
+
|
|
36
|
+
# Build node list
|
|
37
|
+
nodes = []
|
|
38
|
+
for nid in sorted(node_ids):
|
|
39
|
+
span: FuncSpan | None = node_info.get(nid)
|
|
40
|
+
nodes.append({
|
|
41
|
+
"id": nid,
|
|
42
|
+
"label": span.display_name if span else _fallback_label(nid),
|
|
43
|
+
"module": span.module_name if span else _fallback_module(nid),
|
|
44
|
+
"docstring": span.docstring if span else None,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
# Build module → [node_id] grouping
|
|
48
|
+
modules: dict[str, list[str]] = defaultdict(list)
|
|
49
|
+
for node in nodes:
|
|
50
|
+
modules[node["module"]].append(node["id"])
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"nodes": nodes,
|
|
54
|
+
"edges": [
|
|
55
|
+
{"source": src, "target": tgt, "count": cnt}
|
|
56
|
+
for (src, tgt), cnt in sorted(edge_counts.items())
|
|
57
|
+
],
|
|
58
|
+
"modules": dict(modules),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fallback_label(qname: str) -> str:
|
|
63
|
+
"""Best-effort display label when a node has no FuncSpan (e.g. __main__ calls)."""
|
|
64
|
+
return qname.split(".")[-1]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fallback_module(qname: str) -> str:
|
|
68
|
+
parts = qname.split(".")
|
|
69
|
+
return ".".join(parts[:-1]) if len(parts) > 1 else qname
|
|
File without changes
|
vizzpy/parser/project.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-file project analysis: discovers all .py files, builds scopes,
|
|
3
|
+
resolves cross-module imports, and emits the full edge list.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import ast
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .scope import FuncSpan
|
|
11
|
+
from .walker import build_scope, build_import_map, CallVisitor
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_test_file(f: Path) -> bool:
|
|
17
|
+
"""Return True for files that are part of a test suite."""
|
|
18
|
+
parts = f.parts
|
|
19
|
+
return (
|
|
20
|
+
f.name.startswith("test_")
|
|
21
|
+
or f.name.endswith("_test.py")
|
|
22
|
+
or any(p in ("tests", "test") for p in parts)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_module_name(file_path: Path, root: Path) -> str:
|
|
27
|
+
"""Convert an absolute file path to its dotted module name relative to *root*."""
|
|
28
|
+
rel = file_path.relative_to(root)
|
|
29
|
+
parts = list(rel.parts)
|
|
30
|
+
if parts[-1] == "__init__.py":
|
|
31
|
+
parts = parts[:-1]
|
|
32
|
+
else:
|
|
33
|
+
parts[-1] = parts[-1][:-3] # strip .py
|
|
34
|
+
return ".".join(parts) if parts else "__root__"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def analyze_project(root: Path) -> tuple[list[tuple[str, str]], dict[str, FuncSpan]]:
|
|
38
|
+
"""
|
|
39
|
+
Parse every .py file under *root* and return:
|
|
40
|
+
edges — list of (caller_qname, callee_qname)
|
|
41
|
+
node_info — dict mapping qname -> FuncSpan (for label/docstring lookup)
|
|
42
|
+
"""
|
|
43
|
+
py_files = sorted(f for f in root.rglob("*.py") if not _is_test_file(f))
|
|
44
|
+
|
|
45
|
+
# Pass 1: parse every file and build per-module scopes
|
|
46
|
+
modules: dict[str, tuple[ast.AST, object]] = {} # module_name -> (tree, scope)
|
|
47
|
+
for f in py_files:
|
|
48
|
+
module_name = get_module_name(f, root)
|
|
49
|
+
try:
|
|
50
|
+
source = f.read_text(encoding="utf-8", errors="replace")
|
|
51
|
+
tree = ast.parse(source, filename=str(f))
|
|
52
|
+
scope = build_scope(module_name, tree)
|
|
53
|
+
modules[module_name] = (tree, scope)
|
|
54
|
+
except SyntaxError as exc:
|
|
55
|
+
logger.warning("Skipping %s — syntax error: %s", f, exc)
|
|
56
|
+
|
|
57
|
+
# Collect the project-wide set of known qualified names
|
|
58
|
+
all_names: set[str] = set()
|
|
59
|
+
node_info: dict[str, FuncSpan] = {}
|
|
60
|
+
for _module_name, (_tree, scope) in modules.items():
|
|
61
|
+
all_names |= scope.names
|
|
62
|
+
for span in scope.spans:
|
|
63
|
+
node_info[span.qualified_name] = span
|
|
64
|
+
|
|
65
|
+
# Build suffix index: maps every trailing dotted suffix of each qname to that qname.
|
|
66
|
+
# Used to resolve imports whose top-level package name differs from the folder name
|
|
67
|
+
# (e.g. code imports "xml_framework.x" but the folder is "xml_framework_updated").
|
|
68
|
+
# Values of None indicate an ambiguous suffix (two qnames share the same suffix).
|
|
69
|
+
suffix_index: dict[str, str | None] = {}
|
|
70
|
+
for qname in all_names:
|
|
71
|
+
parts = qname.split(".")
|
|
72
|
+
for i in range(1, len(parts)): # skip i=0 (full name already in all_names)
|
|
73
|
+
suffix = ".".join(parts[i:])
|
|
74
|
+
if suffix in suffix_index:
|
|
75
|
+
suffix_index[suffix] = None # ambiguous — mark, don't use
|
|
76
|
+
else:
|
|
77
|
+
suffix_index[suffix] = qname
|
|
78
|
+
|
|
79
|
+
# Pass 2: extract edges from each module
|
|
80
|
+
edges: list[tuple[str, str]] = []
|
|
81
|
+
for module_name, (tree, _scope) in modules.items():
|
|
82
|
+
import_map = build_import_map(tree, module_name)
|
|
83
|
+
visitor = CallVisitor(module_name, import_map, all_names, suffix_index)
|
|
84
|
+
visitor.visit(tree)
|
|
85
|
+
edges.extend(visitor.edges)
|
|
86
|
+
|
|
87
|
+
return edges, node_info
|
vizzpy/parser/scope.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class FuncSpan:
|
|
8
|
+
qualified_name: str # e.g. "services.order.OrderService.process"
|
|
9
|
+
display_name: str # e.g. "OrderService.process" or "OrderService" for __init__
|
|
10
|
+
module_name: str # e.g. "services.order"
|
|
11
|
+
start: int
|
|
12
|
+
end: int
|
|
13
|
+
docstring: Optional[str] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FuncScope:
|
|
17
|
+
"""Registry of all function/method definitions within a single module."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, module_name: str):
|
|
20
|
+
self.module_name = module_name
|
|
21
|
+
self._spans: list[FuncSpan] = []
|
|
22
|
+
self._names: set[str] = set()
|
|
23
|
+
|
|
24
|
+
def add(self, span: FuncSpan) -> None:
|
|
25
|
+
self._spans.append(span)
|
|
26
|
+
self._names.add(span.qualified_name)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def names(self) -> set[str]:
|
|
30
|
+
return self._names
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def spans(self) -> list[FuncSpan]:
|
|
34
|
+
return list(self._spans)
|
vizzpy/parser/walker.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AST visitors for building function scopes and extracting call edges.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import ast
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .scope import FuncScope, FuncSpan
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Helpers
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
def _qualified_name(module: str, class_stack: list[str], func_name: str) -> str:
|
|
16
|
+
if class_stack:
|
|
17
|
+
class_name = class_stack[-1]
|
|
18
|
+
if func_name == "__init__":
|
|
19
|
+
return f"{module}.{class_name}"
|
|
20
|
+
return f"{module}.{class_name}.{func_name}"
|
|
21
|
+
return f"{module}.{func_name}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _display_name(class_stack: list[str], func_name: str) -> str:
|
|
25
|
+
if class_stack:
|
|
26
|
+
class_name = class_stack[-1]
|
|
27
|
+
if func_name == "__init__":
|
|
28
|
+
return class_name
|
|
29
|
+
return f"{class_name}.{func_name}"
|
|
30
|
+
return func_name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Scope builder: first pass — collect all function/method definitions
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
class ScopeBuilder(ast.NodeVisitor):
|
|
38
|
+
"""Collects FuncSpan entries for every top-level function and class method."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, module_name: str):
|
|
41
|
+
self.scope = FuncScope(module_name)
|
|
42
|
+
self._module = module_name
|
|
43
|
+
self._class_stack: list[str] = []
|
|
44
|
+
self._in_func = False # True once inside any function body
|
|
45
|
+
|
|
46
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
47
|
+
self._class_stack.append(node.name)
|
|
48
|
+
self.generic_visit(node)
|
|
49
|
+
self._class_stack.pop()
|
|
50
|
+
|
|
51
|
+
def _visit_funcdef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
52
|
+
# Only register top-level functions and direct class methods, not nested functions.
|
|
53
|
+
if not self._in_func:
|
|
54
|
+
qname = _qualified_name(self._module, self._class_stack, node.name)
|
|
55
|
+
dname = _display_name(self._class_stack, node.name)
|
|
56
|
+
self.scope.add(FuncSpan(
|
|
57
|
+
qualified_name=qname,
|
|
58
|
+
display_name=dname,
|
|
59
|
+
module_name=self._module,
|
|
60
|
+
start=node.lineno,
|
|
61
|
+
end=node.end_lineno,
|
|
62
|
+
docstring=ast.get_docstring(node),
|
|
63
|
+
))
|
|
64
|
+
prev = self._in_func
|
|
65
|
+
self._in_func = True
|
|
66
|
+
self.generic_visit(node)
|
|
67
|
+
self._in_func = prev
|
|
68
|
+
|
|
69
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
70
|
+
self._visit_funcdef(node)
|
|
71
|
+
|
|
72
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
73
|
+
self._visit_funcdef(node)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_scope(module_name: str, tree: ast.AST) -> FuncScope:
|
|
77
|
+
builder = ScopeBuilder(module_name)
|
|
78
|
+
builder.visit(tree)
|
|
79
|
+
return builder.scope
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Import resolution: build a map from local alias → qualified name
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def build_import_map(tree: ast.AST, module_name: str) -> dict[str, str]:
|
|
87
|
+
"""
|
|
88
|
+
Returns {local_name: qualified_name} for all imports in the file.
|
|
89
|
+
|
|
90
|
+
Examples:
|
|
91
|
+
from services.order import OrderService -> {'OrderService': 'services.order.OrderService'}
|
|
92
|
+
from services.order import OrderService as OS -> {'OS': 'services.order.OrderService'}
|
|
93
|
+
import services.order -> {'services': 'services', 'services.order': 'services.order'}
|
|
94
|
+
"""
|
|
95
|
+
import_map: dict[str, str] = {}
|
|
96
|
+
module_parts = module_name.split(".")
|
|
97
|
+
|
|
98
|
+
for node in ast.walk(tree):
|
|
99
|
+
if isinstance(node, ast.ImportFrom):
|
|
100
|
+
if node.names and node.names[0].name == "*":
|
|
101
|
+
continue # skip star imports — can't resolve statically
|
|
102
|
+
|
|
103
|
+
mod = node.module or ""
|
|
104
|
+
|
|
105
|
+
# Resolve relative imports
|
|
106
|
+
if node.level > 0:
|
|
107
|
+
base = module_parts[:max(0, len(module_parts) - node.level)]
|
|
108
|
+
mod = ".".join(base + ([mod] if mod else []))
|
|
109
|
+
|
|
110
|
+
for alias in node.names:
|
|
111
|
+
local = alias.asname if alias.asname else alias.name
|
|
112
|
+
import_map[local] = f"{mod}.{alias.name}" if mod else alias.name
|
|
113
|
+
|
|
114
|
+
elif isinstance(node, ast.Import):
|
|
115
|
+
for alias in node.names:
|
|
116
|
+
local = alias.asname if alias.asname else alias.name
|
|
117
|
+
import_map[local] = alias.name
|
|
118
|
+
# Also register dotted prefix so `services.order.func` resolves
|
|
119
|
+
if "." in alias.name:
|
|
120
|
+
parts = alias.name.split(".")
|
|
121
|
+
for i in range(1, len(parts) + 1):
|
|
122
|
+
prefix = ".".join(parts[:i])
|
|
123
|
+
import_map.setdefault(prefix, prefix)
|
|
124
|
+
|
|
125
|
+
return import_map
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Call visitor: second pass — extract caller → callee edges
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
class CallVisitor(ast.NodeVisitor):
|
|
133
|
+
"""
|
|
134
|
+
Walks the AST of a single module and emits (caller_qname, callee_qname) edges.
|
|
135
|
+
Only edges where the callee is in *all_names* (the project-wide set of known
|
|
136
|
+
function qualified names) are recorded.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
module_name: str,
|
|
142
|
+
import_map: dict[str, str],
|
|
143
|
+
all_names: set[str],
|
|
144
|
+
suffix_index: dict[str, "str | None"] | None = None,
|
|
145
|
+
):
|
|
146
|
+
self._module = module_name
|
|
147
|
+
self._import_map = import_map
|
|
148
|
+
self._all_names = all_names
|
|
149
|
+
self._suffix_index: dict[str, str | None] = suffix_index or {}
|
|
150
|
+
self.edges: list[tuple[str, str]] = []
|
|
151
|
+
|
|
152
|
+
self._class_stack: list[str] = []
|
|
153
|
+
# Stack of qualified names representing the current call chain.
|
|
154
|
+
# Nested functions fall back to their parent's qname as the caller.
|
|
155
|
+
self._func_stack: list[str] = []
|
|
156
|
+
|
|
157
|
+
# -- context tracking ----------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
160
|
+
self._class_stack.append(node.name)
|
|
161
|
+
self.generic_visit(node)
|
|
162
|
+
self._class_stack.pop()
|
|
163
|
+
|
|
164
|
+
def _visit_funcdef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
165
|
+
# Determine the qualified name to use as caller context.
|
|
166
|
+
# For nested functions, reuse the enclosing registered function's qname.
|
|
167
|
+
if not self._func_stack:
|
|
168
|
+
qname = _qualified_name(self._module, self._class_stack, node.name)
|
|
169
|
+
else:
|
|
170
|
+
qname = self._func_stack[-1] # nested → attribute calls still emit from parent
|
|
171
|
+
|
|
172
|
+
self._func_stack.append(qname)
|
|
173
|
+
self.generic_visit(node)
|
|
174
|
+
self._func_stack.pop()
|
|
175
|
+
|
|
176
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
177
|
+
self._visit_funcdef(node)
|
|
178
|
+
|
|
179
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
180
|
+
self._visit_funcdef(node)
|
|
181
|
+
|
|
182
|
+
# -- call extraction -----------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
185
|
+
caller = self._func_stack[-1] if self._func_stack else f"{self._module}.__main__"
|
|
186
|
+
callee = self._resolve_call(node)
|
|
187
|
+
if callee and callee in self._all_names and callee != caller:
|
|
188
|
+
self.edges.append((caller, callee))
|
|
189
|
+
self.generic_visit(node)
|
|
190
|
+
|
|
191
|
+
def _resolve_call(self, node: ast.Call) -> Optional[str]:
|
|
192
|
+
if isinstance(node.func, ast.Name):
|
|
193
|
+
return self._resolve_name(node.func.id)
|
|
194
|
+
if isinstance(node.func, ast.Attribute):
|
|
195
|
+
return self._resolve_attr(node.func)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
def _resolve_name(self, name: str) -> Optional[str]:
|
|
199
|
+
# Check import map first
|
|
200
|
+
if name in self._import_map:
|
|
201
|
+
candidate = self._import_map[name]
|
|
202
|
+
if candidate in self._all_names:
|
|
203
|
+
return candidate
|
|
204
|
+
# Import found but qname unknown — try suffix matching to handle
|
|
205
|
+
# package renames (e.g. folder is "pkg_v2" but code imports "pkg")
|
|
206
|
+
result = self._resolve_via_suffix(candidate)
|
|
207
|
+
if result:
|
|
208
|
+
return result
|
|
209
|
+
# Try as a function in this module
|
|
210
|
+
qname = f"{self._module}.{name}"
|
|
211
|
+
return qname if qname in self._all_names else None
|
|
212
|
+
|
|
213
|
+
def _resolve_via_suffix(self, candidate: str) -> Optional[str]:
|
|
214
|
+
"""
|
|
215
|
+
Try progressively shorter suffixes of *candidate* against the suffix index.
|
|
216
|
+
Returns the unambiguous match, or None if not found / ambiguous.
|
|
217
|
+
"""
|
|
218
|
+
parts = candidate.split(".")
|
|
219
|
+
for i in range(1, len(parts)):
|
|
220
|
+
suffix = ".".join(parts[i:])
|
|
221
|
+
if suffix in self._suffix_index:
|
|
222
|
+
return self._suffix_index[suffix] # None if ambiguous
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
def _resolve_attr(self, node: ast.Attribute) -> Optional[str]:
|
|
226
|
+
attr = node.attr
|
|
227
|
+
|
|
228
|
+
# self.method()
|
|
229
|
+
if isinstance(node.value, ast.Name) and node.value.id == "self":
|
|
230
|
+
return self._resolve_self_attr(attr)
|
|
231
|
+
|
|
232
|
+
# cls.method()
|
|
233
|
+
if isinstance(node.value, ast.Name) and node.value.id == "cls":
|
|
234
|
+
return self._resolve_cls_attr(attr)
|
|
235
|
+
|
|
236
|
+
# SomeName.method() — could be ClassName.method, module.func, or imported.func
|
|
237
|
+
if isinstance(node.value, ast.Name):
|
|
238
|
+
return self._resolve_dotted(node.value.id, attr)
|
|
239
|
+
|
|
240
|
+
# module.submodule.func() — e.g. ast.Attribute chain
|
|
241
|
+
if isinstance(node.value, ast.Attribute):
|
|
242
|
+
# Reconstruct the dotted prefix as best we can
|
|
243
|
+
prefix = self._unparse_attr_chain(node.value)
|
|
244
|
+
if prefix:
|
|
245
|
+
qname = f"{prefix}.{attr}"
|
|
246
|
+
if qname in self._all_names:
|
|
247
|
+
return qname
|
|
248
|
+
# Maybe prefix is an import alias
|
|
249
|
+
if prefix in self._import_map:
|
|
250
|
+
qname = f"{self._import_map[prefix]}.{attr}"
|
|
251
|
+
if qname in self._all_names:
|
|
252
|
+
return qname
|
|
253
|
+
# Suffix fallback for chained attribute calls
|
|
254
|
+
result = self._resolve_via_suffix(qname)
|
|
255
|
+
if result:
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
def _resolve_self_attr(self, attr: str) -> Optional[str]:
|
|
261
|
+
if not self._class_stack:
|
|
262
|
+
return None
|
|
263
|
+
class_name = self._class_stack[-1]
|
|
264
|
+
if attr == "__init__":
|
|
265
|
+
qname = f"{self._module}.{class_name}"
|
|
266
|
+
else:
|
|
267
|
+
qname = f"{self._module}.{class_name}.{attr}"
|
|
268
|
+
return qname if qname in self._all_names else None
|
|
269
|
+
|
|
270
|
+
def _resolve_cls_attr(self, attr: str) -> Optional[str]:
|
|
271
|
+
if not self._class_stack:
|
|
272
|
+
return None
|
|
273
|
+
class_name = self._class_stack[-1]
|
|
274
|
+
qname = f"{self._module}.{class_name}.{attr}"
|
|
275
|
+
return qname if qname in self._all_names else None
|
|
276
|
+
|
|
277
|
+
def _resolve_dotted(self, obj_name: str, attr: str) -> Optional[str]:
|
|
278
|
+
# obj is an import alias pointing to a module or class
|
|
279
|
+
if obj_name in self._import_map:
|
|
280
|
+
base = self._import_map[obj_name]
|
|
281
|
+
qname = f"{base}.{attr}"
|
|
282
|
+
if qname in self._all_names:
|
|
283
|
+
return qname
|
|
284
|
+
# obj is a class in this module (ClassName.method or ClassName() == __init__)
|
|
285
|
+
qname = f"{self._module}.{obj_name}.{attr}"
|
|
286
|
+
if qname in self._all_names:
|
|
287
|
+
return qname
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def _unparse_attr_chain(self, node: ast.expr) -> Optional[str]:
|
|
291
|
+
"""Reconstruct a dotted name from a chain of ast.Attribute nodes."""
|
|
292
|
+
if isinstance(node, ast.Name):
|
|
293
|
+
return node.id
|
|
294
|
+
if isinstance(node, ast.Attribute):
|
|
295
|
+
prefix = self._unparse_attr_chain(node.value)
|
|
296
|
+
return f"{prefix}.{node.attr}" if prefix else None
|
|
297
|
+
return None
|
vizzpy/render.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Headless SVG rendering via the graphviz Python package.
|
|
3
|
+
|
|
4
|
+
The graphviz package is a thin wrapper around the `dot` binary (Graphviz).
|
|
5
|
+
Install Graphviz system package first:
|
|
6
|
+
macOS: brew install graphviz
|
|
7
|
+
Ubuntu: apt install graphviz
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import graphviz # type: ignore
|
|
13
|
+
|
|
14
|
+
from .graph import build_graph
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_svg(project_root: Path, output_path: Path) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Analyze *project_root* and write the call graph as an SVG to *output_path*.
|
|
20
|
+
"""
|
|
21
|
+
graph_data = build_graph(project_root)
|
|
22
|
+
dot = _to_dot(graph_data)
|
|
23
|
+
svg_source = dot.pipe(format="svg").decode("utf-8")
|
|
24
|
+
output_path.write_text(svg_source, encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _to_dot(graph_data: dict) -> graphviz.Digraph:
|
|
28
|
+
dot = graphviz.Digraph(
|
|
29
|
+
graph_attr={
|
|
30
|
+
"rankdir": "LR",
|
|
31
|
+
"fontname": "Helvetica",
|
|
32
|
+
"splines": "ortho",
|
|
33
|
+
"nodesep": "0.5",
|
|
34
|
+
"ranksep": "1.0",
|
|
35
|
+
},
|
|
36
|
+
node_attr={
|
|
37
|
+
"shape": "box",
|
|
38
|
+
"style": "rounded,filled",
|
|
39
|
+
"fillcolor": "#dbe9f4",
|
|
40
|
+
"fontname": "Helvetica",
|
|
41
|
+
"fontsize": "11",
|
|
42
|
+
},
|
|
43
|
+
edge_attr={
|
|
44
|
+
"fontname": "Helvetica",
|
|
45
|
+
"fontsize": "9",
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Add module subgraphs (clusters)
|
|
50
|
+
for module_name, node_ids in graph_data["modules"].items():
|
|
51
|
+
with dot.subgraph(name=f"cluster_{module_name}") as sub:
|
|
52
|
+
sub.attr(label=module_name, style="rounded", color="#aaaaaa", fontsize="10")
|
|
53
|
+
for nid in node_ids:
|
|
54
|
+
# Find label for this node
|
|
55
|
+
label = nid # fallback
|
|
56
|
+
for n in graph_data["nodes"]:
|
|
57
|
+
if n["id"] == nid:
|
|
58
|
+
label = n["label"]
|
|
59
|
+
break
|
|
60
|
+
sub.node(nid, label=label)
|
|
61
|
+
|
|
62
|
+
# Add edges
|
|
63
|
+
for edge in graph_data["edges"]:
|
|
64
|
+
label = str(edge["count"]) if edge["count"] > 1 else ""
|
|
65
|
+
dot.edge(edge["source"], edge["target"], label=label)
|
|
66
|
+
|
|
67
|
+
return dot
|