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 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
@@ -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)
@@ -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