vizzpy 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- vizzpy-0.1.0/PKG-INFO +119 -0
- vizzpy-0.1.0/README.md +84 -0
- vizzpy-0.1.0/pyproject.toml +56 -0
- vizzpy-0.1.0/setup.cfg +4 -0
- vizzpy-0.1.0/tests/test_graph.py +81 -0
- vizzpy-0.1.0/tests/test_project.py +97 -0
- vizzpy-0.1.0/tests/test_server.py +134 -0
- vizzpy-0.1.0/tests/test_walker.py +219 -0
- vizzpy-0.1.0/vizzpy/__init__.py +0 -0
- vizzpy-0.1.0/vizzpy/cli.py +56 -0
- vizzpy-0.1.0/vizzpy/graph.py +69 -0
- vizzpy-0.1.0/vizzpy/parser/__init__.py +0 -0
- vizzpy-0.1.0/vizzpy/parser/project.py +87 -0
- vizzpy-0.1.0/vizzpy/parser/scope.py +34 -0
- vizzpy-0.1.0/vizzpy/parser/walker.py +297 -0
- vizzpy-0.1.0/vizzpy/render.py +67 -0
- vizzpy-0.1.0/vizzpy/server.py +110 -0
- vizzpy-0.1.0/vizzpy/static/app.js +358 -0
- vizzpy-0.1.0/vizzpy/static/index.html +53 -0
- vizzpy-0.1.0/vizzpy/static/style.css +287 -0
- vizzpy-0.1.0/vizzpy/static/vendor/d3.min.js +2 -0
- vizzpy-0.1.0/vizzpy/static/vendor/dagre-d3.min.js +4816 -0
- vizzpy-0.1.0/vizzpy.egg-info/PKG-INFO +119 -0
- vizzpy-0.1.0/vizzpy.egg-info/SOURCES.txt +27 -0
- vizzpy-0.1.0/vizzpy.egg-info/dependency_links.txt +1 -0
- vizzpy-0.1.0/vizzpy.egg-info/entry_points.txt +2 -0
- vizzpy-0.1.0/vizzpy.egg-info/requires.txt +10 -0
- vizzpy-0.1.0/vizzpy.egg-info/top_level.txt +1 -0
vizzpy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Atul Saurav
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
vizzpy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vizzpy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive Python call tree visualizer — upload a project, explore its call graph in the browser
|
|
5
|
+
Author: Atul
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/atulsaurav/vizzpy
|
|
8
|
+
Project-URL: Repository, https://github.com/atulsaurav/vizzpy
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/atulsaurav/vizzpy/issues
|
|
10
|
+
Keywords: python,call-tree,call-graph,ast,visualization,static-analysis
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: fastapi>=0.100
|
|
26
|
+
Requires-Dist: uvicorn[standard]>=0.23
|
|
27
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
28
|
+
Requires-Dist: graphviz>=0.20
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
32
|
+
Requires-Dist: anyio[trio]; extra == "dev"
|
|
33
|
+
Requires-Dist: httpx; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# VizPy
|
|
37
|
+
|
|
38
|
+
Interactive Python call tree visualizer. Upload a Python project and explore its function call graph in the browser.
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- **Interactive web UI** — zoom, pan, drag nodes, collapse subtrees
|
|
43
|
+
- **Cross-module resolution** — follows imports across files to draw edges between modules
|
|
44
|
+
- **Dark mode** — toggle with one click
|
|
45
|
+
- **Headless mode** — render a static SVG from the command line (requires Graphviz)
|
|
46
|
+
- **Folder or zip upload** — drag-and-drop a `.zip` or browse for a local folder
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install vizzpy
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Headless SVG rendering additionally requires the [Graphviz](https://graphviz.org/download/) system package:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# macOS
|
|
58
|
+
brew install graphviz
|
|
59
|
+
|
|
60
|
+
# Debian/Ubuntu
|
|
61
|
+
apt install graphviz
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### Web UI (recommended)
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
vizzpy --serve
|
|
70
|
+
# open http://127.0.0.1:8000
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Upload a `.zip` of your Python project or select a local folder, then click **Analyze**.
|
|
74
|
+
|
|
75
|
+
- **Scroll / pinch** to zoom
|
|
76
|
+
- **Drag background** to pan
|
|
77
|
+
- **Drag nodes** to rearrange
|
|
78
|
+
- **Double-click a node** to collapse/expand its call subtree
|
|
79
|
+
- **Hover** a node to see its docstring
|
|
80
|
+
|
|
81
|
+
### Headless SVG
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
vizzpy --headless ./myproject --output call_tree.svg
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Options
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
vizzpy --serve [--host HOST] [--port PORT]
|
|
91
|
+
vizzpy --headless PROJECT_PATH [--output OUTPUT.svg]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## How it works
|
|
95
|
+
|
|
96
|
+
VizPy uses Python's `ast` module to do a two-pass analysis of your source files:
|
|
97
|
+
|
|
98
|
+
1. **Scope pass** — collects every top-level function and class method with its qualified name (`module.ClassName.method`)
|
|
99
|
+
2. **Edge pass** — walks each file again, resolves `self.method()`, `cls.method()`, imported names, and direct calls to project-internal functions
|
|
100
|
+
|
|
101
|
+
Results are rendered in the browser with [dagre-d3](https://github.com/dagrejs/dagre-d3) (vendored, works offline). Calls to stdlib or third-party libraries are silently excluded.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
git clone https://github.com/atulsaurav/vizzpy
|
|
107
|
+
cd vizzpy
|
|
108
|
+
pip install -e ".[dev]"
|
|
109
|
+
|
|
110
|
+
# run tests
|
|
111
|
+
pytest
|
|
112
|
+
|
|
113
|
+
# run with coverage
|
|
114
|
+
pytest --cov=vizzpy --cov-report=term-missing
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
vizzpy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# VizPy
|
|
2
|
+
|
|
3
|
+
Interactive Python call tree visualizer. Upload a Python project and explore its function call graph in the browser.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Interactive web UI** — zoom, pan, drag nodes, collapse subtrees
|
|
8
|
+
- **Cross-module resolution** — follows imports across files to draw edges between modules
|
|
9
|
+
- **Dark mode** — toggle with one click
|
|
10
|
+
- **Headless mode** — render a static SVG from the command line (requires Graphviz)
|
|
11
|
+
- **Folder or zip upload** — drag-and-drop a `.zip` or browse for a local folder
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install vizzpy
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Headless SVG rendering additionally requires the [Graphviz](https://graphviz.org/download/) system package:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# macOS
|
|
23
|
+
brew install graphviz
|
|
24
|
+
|
|
25
|
+
# Debian/Ubuntu
|
|
26
|
+
apt install graphviz
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### Web UI (recommended)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
vizzpy --serve
|
|
35
|
+
# open http://127.0.0.1:8000
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Upload a `.zip` of your Python project or select a local folder, then click **Analyze**.
|
|
39
|
+
|
|
40
|
+
- **Scroll / pinch** to zoom
|
|
41
|
+
- **Drag background** to pan
|
|
42
|
+
- **Drag nodes** to rearrange
|
|
43
|
+
- **Double-click a node** to collapse/expand its call subtree
|
|
44
|
+
- **Hover** a node to see its docstring
|
|
45
|
+
|
|
46
|
+
### Headless SVG
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
vizzpy --headless ./myproject --output call_tree.svg
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Options
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
vizzpy --serve [--host HOST] [--port PORT]
|
|
56
|
+
vizzpy --headless PROJECT_PATH [--output OUTPUT.svg]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## How it works
|
|
60
|
+
|
|
61
|
+
VizPy uses Python's `ast` module to do a two-pass analysis of your source files:
|
|
62
|
+
|
|
63
|
+
1. **Scope pass** — collects every top-level function and class method with its qualified name (`module.ClassName.method`)
|
|
64
|
+
2. **Edge pass** — walks each file again, resolves `self.method()`, `cls.method()`, imported names, and direct calls to project-internal functions
|
|
65
|
+
|
|
66
|
+
Results are rendered in the browser with [dagre-d3](https://github.com/dagrejs/dagre-d3) (vendored, works offline). Calls to stdlib or third-party libraries are silently excluded.
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/atulsaurav/vizzpy
|
|
72
|
+
cd vizzpy
|
|
73
|
+
pip install -e ".[dev]"
|
|
74
|
+
|
|
75
|
+
# run tests
|
|
76
|
+
pytest
|
|
77
|
+
|
|
78
|
+
# run with coverage
|
|
79
|
+
pytest --cov=vizzpy --cov-report=term-missing
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vizzpy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Interactive Python call tree visualizer — upload a project, explore its call graph in the browser"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Atul" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
keywords = ["python", "call-tree", "call-graph", "ast", "visualization", "static-analysis"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Web Environment",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"fastapi>=0.100",
|
|
30
|
+
"uvicorn[standard]>=0.23",
|
|
31
|
+
"python-multipart>=0.0.9",
|
|
32
|
+
"graphviz>=0.20",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = [
|
|
37
|
+
"pytest>=8",
|
|
38
|
+
"pytest-cov",
|
|
39
|
+
"anyio[trio]",
|
|
40
|
+
"httpx",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
vizzpy = "vizzpy.cli:cli"
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://github.com/atulsaurav/vizzpy"
|
|
48
|
+
Repository = "https://github.com/atulsaurav/vizzpy"
|
|
49
|
+
"Bug Tracker" = "https://github.com/atulsaurav/vizzpy/issues"
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.packages.find]
|
|
52
|
+
where = ["."]
|
|
53
|
+
include = ["vizzpy*"]
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.package-data]
|
|
56
|
+
vizzpy = ["static/**/*"]
|
vizzpy-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Tests for graph.py: build_graph, _fallback_label, _fallback_module."""
|
|
2
|
+
import pytest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from vizzpy.graph import build_graph, _fallback_label, _fallback_module
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# ── _fallback_label / _fallback_module ───────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
def test_fallback_label_simple():
|
|
11
|
+
assert _fallback_label("pkg.utils.helper") == "helper"
|
|
12
|
+
|
|
13
|
+
def test_fallback_label_no_dot():
|
|
14
|
+
assert _fallback_label("main") == "main"
|
|
15
|
+
|
|
16
|
+
def test_fallback_module_with_dot():
|
|
17
|
+
assert _fallback_module("pkg.utils.helper") == "pkg.utils"
|
|
18
|
+
|
|
19
|
+
def test_fallback_module_no_dot():
|
|
20
|
+
assert _fallback_module("main") == "main"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── build_graph ───────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
def test_build_graph_basic(tmp_path):
|
|
26
|
+
(tmp_path / "foo.py").write_text("def a(): pass\ndef b():\n a()")
|
|
27
|
+
g = build_graph(tmp_path)
|
|
28
|
+
node_ids = {n["id"] for n in g["nodes"]}
|
|
29
|
+
assert "foo.a" in node_ids
|
|
30
|
+
assert "foo.b" in node_ids
|
|
31
|
+
assert any(e["source"] == "foo.b" and e["target"] == "foo.a" for e in g["edges"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_build_graph_edge_count(tmp_path):
|
|
35
|
+
# Two calls to the same callee → count == 2
|
|
36
|
+
(tmp_path / "m.py").write_text("def a(): pass\ndef b():\n a()\n a()")
|
|
37
|
+
g = build_graph(tmp_path)
|
|
38
|
+
edge = next(e for e in g["edges"] if e["source"] == "m.b" and e["target"] == "m.a")
|
|
39
|
+
assert edge["count"] == 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_build_graph_modules_grouping(tmp_path):
|
|
43
|
+
(tmp_path / "pkg").mkdir()
|
|
44
|
+
(tmp_path / "pkg" / "utils.py").write_text("def helper(): pass\ndef run():\n helper()")
|
|
45
|
+
g = build_graph(tmp_path)
|
|
46
|
+
assert "pkg.utils" in g["modules"]
|
|
47
|
+
assert "pkg.utils.helper" in g["modules"]["pkg.utils"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_build_graph_node_label_and_module(tmp_path):
|
|
51
|
+
(tmp_path / "svc.py").write_text("def process(): pass\ndef main():\n process()")
|
|
52
|
+
g = build_graph(tmp_path)
|
|
53
|
+
node = next(n for n in g["nodes"] if n["id"] == "svc.process")
|
|
54
|
+
assert node["label"] == "process"
|
|
55
|
+
assert node["module"] == "svc"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_build_graph_cross_module(tmp_path):
|
|
59
|
+
(tmp_path / "a.py").write_text("def helper(): pass")
|
|
60
|
+
(tmp_path / "b.py").write_text("from a import helper\ndef caller():\n helper()")
|
|
61
|
+
g = build_graph(tmp_path)
|
|
62
|
+
assert any(e["source"] == "b.caller" and e["target"] == "a.helper" for e in g["edges"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_build_graph_fallback_node_for_unknown_callee(tmp_path):
|
|
66
|
+
# Callee comes from an external package — still shows up as a node if referenced
|
|
67
|
+
# within-project edges only (external callee won't appear unless it's in all_names)
|
|
68
|
+
# This test just verifies build_graph doesn't crash on an empty project.
|
|
69
|
+
g = build_graph(tmp_path)
|
|
70
|
+
assert g["nodes"] == []
|
|
71
|
+
assert g["edges"] == []
|
|
72
|
+
assert g["modules"] == {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_build_graph_node_docstring(tmp_path):
|
|
76
|
+
(tmp_path / "m.py").write_text(
|
|
77
|
+
'def a():\n """Does something."""\n pass\ndef b():\n a()'
|
|
78
|
+
)
|
|
79
|
+
g = build_graph(tmp_path)
|
|
80
|
+
node = next(n for n in g["nodes"] if n["id"] == "m.a")
|
|
81
|
+
assert node["docstring"] == "Does something."
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Tests for project-level analysis: module naming, test exclusion, edge collection."""
|
|
2
|
+
import pytest
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from vizzpy.parser.project import _is_test_file, get_module_name, analyze_project
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# ── _is_test_file ─────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
def test_is_test_by_prefix():
|
|
11
|
+
assert _is_test_file(Path("/proj/test_foo.py"))
|
|
12
|
+
|
|
13
|
+
def test_is_test_by_suffix():
|
|
14
|
+
assert _is_test_file(Path("/proj/foo_test.py"))
|
|
15
|
+
|
|
16
|
+
def test_is_test_by_dir():
|
|
17
|
+
assert _is_test_file(Path("/proj/tests/helper.py"))
|
|
18
|
+
|
|
19
|
+
def test_is_test_nested_dir():
|
|
20
|
+
assert _is_test_file(Path("/proj/src/test/utils.py"))
|
|
21
|
+
|
|
22
|
+
def test_not_test_file():
|
|
23
|
+
assert not _is_test_file(Path("/proj/src/utils.py"))
|
|
24
|
+
|
|
25
|
+
def test_not_test_file_with_test_in_name():
|
|
26
|
+
# "testament.py" should not be excluded
|
|
27
|
+
assert not _is_test_file(Path("/proj/testament.py"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── get_module_name ───────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
def test_module_name_simple(tmp_path):
|
|
33
|
+
f = tmp_path / "utils.py"
|
|
34
|
+
f.touch()
|
|
35
|
+
assert get_module_name(f, tmp_path) == "utils"
|
|
36
|
+
|
|
37
|
+
def test_module_name_nested(tmp_path):
|
|
38
|
+
(tmp_path / "services").mkdir()
|
|
39
|
+
f = tmp_path / "services" / "order.py"
|
|
40
|
+
f.touch()
|
|
41
|
+
assert get_module_name(f, tmp_path) == "services.order"
|
|
42
|
+
|
|
43
|
+
def test_module_name_init(tmp_path):
|
|
44
|
+
(tmp_path / "services").mkdir()
|
|
45
|
+
f = tmp_path / "services" / "__init__.py"
|
|
46
|
+
f.touch()
|
|
47
|
+
assert get_module_name(f, tmp_path) == "services"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── analyze_project ───────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def test_basic_edges(tmp_path):
|
|
53
|
+
(tmp_path / "foo.py").write_text("def a(): pass\ndef b():\n a()")
|
|
54
|
+
edges, node_info = analyze_project(tmp_path)
|
|
55
|
+
assert ("foo.b", "foo.a") in edges
|
|
56
|
+
assert "foo.a" in node_info
|
|
57
|
+
assert "foo.b" in node_info
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_excludes_test_files(tmp_path):
|
|
61
|
+
(tmp_path / "foo.py").write_text("def a(): pass")
|
|
62
|
+
test_dir = tmp_path / "tests"
|
|
63
|
+
test_dir.mkdir()
|
|
64
|
+
(test_dir / "test_foo.py").write_text(
|
|
65
|
+
"from foo import a\ndef test_it():\n a()"
|
|
66
|
+
)
|
|
67
|
+
edges, node_info = analyze_project(tmp_path)
|
|
68
|
+
# test functions must not appear as nodes or callers
|
|
69
|
+
assert not any("test_it" in qn for qn in node_info)
|
|
70
|
+
assert not any("test_it" in e[0] for e in edges)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_cross_module_edges(tmp_path):
|
|
74
|
+
(tmp_path / "a.py").write_text("def helper(): pass")
|
|
75
|
+
(tmp_path / "b.py").write_text(
|
|
76
|
+
"from a import helper\ndef caller():\n helper()"
|
|
77
|
+
)
|
|
78
|
+
edges, _ = analyze_project(tmp_path)
|
|
79
|
+
assert ("b.caller", "a.helper") in edges
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_syntax_error_file_skipped(tmp_path):
|
|
83
|
+
(tmp_path / "good.py").write_text("def ok(): pass")
|
|
84
|
+
(tmp_path / "bad.py").write_text("def broken(: pass") # syntax error
|
|
85
|
+
# Should not raise; the bad file is silently skipped
|
|
86
|
+
edges, node_info = analyze_project(tmp_path)
|
|
87
|
+
assert "good.ok" in node_info
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_no_duplicate_edges(tmp_path):
|
|
91
|
+
# Two calls from the same caller to the same callee → one edge with count 2
|
|
92
|
+
(tmp_path / "m.py").write_text(
|
|
93
|
+
"def a(): pass\ndef b():\n a()\n a()"
|
|
94
|
+
)
|
|
95
|
+
edges, _ = analyze_project(tmp_path)
|
|
96
|
+
matching = [e for e in edges if e == ("m.b", "m.a")]
|
|
97
|
+
assert len(matching) == 2 # raw edges — duplicates expected; build_graph deduplicates
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Tests for the FastAPI server endpoints."""
|
|
2
|
+
import io
|
|
3
|
+
import zipfile
|
|
4
|
+
import pytest
|
|
5
|
+
from httpx import AsyncClient, ASGITransport
|
|
6
|
+
|
|
7
|
+
from vizzpy.server import app, _find_project_root
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def _make_zip(files: dict[str, str]) -> bytes:
|
|
14
|
+
"""Build an in-memory zip from {relative_path: content} pairs."""
|
|
15
|
+
buf = io.BytesIO()
|
|
16
|
+
with zipfile.ZipFile(buf, "w") as zf:
|
|
17
|
+
for name, content in files.items():
|
|
18
|
+
zf.writestr(name, content)
|
|
19
|
+
return buf.getvalue()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
async def client():
|
|
24
|
+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
25
|
+
yield c
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── GET / ─────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
@pytest.mark.anyio
|
|
31
|
+
async def test_index_returns_html(client):
|
|
32
|
+
r = await client.get("/")
|
|
33
|
+
assert r.status_code == 200
|
|
34
|
+
assert "text/html" in r.headers["content-type"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── POST /api/analyze ─────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
@pytest.mark.anyio
|
|
40
|
+
async def test_analyze_valid_zip(client):
|
|
41
|
+
zip_bytes = _make_zip({
|
|
42
|
+
"proj/foo.py": "def a(): pass\ndef b():\n a()",
|
|
43
|
+
})
|
|
44
|
+
r = await client.post(
|
|
45
|
+
"/api/analyze",
|
|
46
|
+
files={"file": ("proj.zip", zip_bytes, "application/zip")},
|
|
47
|
+
)
|
|
48
|
+
assert r.status_code == 200
|
|
49
|
+
data = r.json()
|
|
50
|
+
assert "nodes" in data and "edges" in data and "modules" in data
|
|
51
|
+
node_ids = {n["id"] for n in data["nodes"]}
|
|
52
|
+
assert "foo.a" in node_ids
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.anyio
|
|
56
|
+
async def test_analyze_rejects_non_zip(client):
|
|
57
|
+
r = await client.post(
|
|
58
|
+
"/api/analyze",
|
|
59
|
+
files={"file": ("code.py", b"def f(): pass", "text/plain")},
|
|
60
|
+
)
|
|
61
|
+
assert r.status_code == 400
|
|
62
|
+
assert "zip" in r.json()["detail"].lower()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.anyio
|
|
66
|
+
async def test_analyze_rejects_bad_zip(client):
|
|
67
|
+
r = await client.post(
|
|
68
|
+
"/api/analyze",
|
|
69
|
+
files={"file": ("bad.zip", b"not a zip", "application/zip")},
|
|
70
|
+
)
|
|
71
|
+
assert r.status_code == 400
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.anyio
|
|
75
|
+
async def test_analyze_zip_single_top_level_dir(client):
|
|
76
|
+
# Zip wraps everything in a top-level folder — _find_project_root should unwrap it
|
|
77
|
+
zip_bytes = _make_zip({
|
|
78
|
+
"myproject/utils.py": "def helper(): pass\ndef run():\n helper()",
|
|
79
|
+
})
|
|
80
|
+
r = await client.post(
|
|
81
|
+
"/api/analyze",
|
|
82
|
+
files={"file": ("myproject.zip", zip_bytes, "application/zip")},
|
|
83
|
+
)
|
|
84
|
+
assert r.status_code == 200
|
|
85
|
+
node_ids = {n["id"] for n in r.json()["nodes"]}
|
|
86
|
+
assert "utils.helper" in node_ids # module name from inside myproject/, not myproject.utils
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── POST /api/analyze-folder ──────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
@pytest.mark.anyio
|
|
92
|
+
async def test_analyze_folder_basic(client):
|
|
93
|
+
files = [
|
|
94
|
+
("files", ("proj/foo.py", b"def a(): pass\ndef b():\n a()", "text/x-python")),
|
|
95
|
+
]
|
|
96
|
+
r = await client.post("/api/analyze-folder", files=files)
|
|
97
|
+
assert r.status_code == 200
|
|
98
|
+
data = r.json()
|
|
99
|
+
node_ids = {n["id"] for n in data["nodes"]}
|
|
100
|
+
assert "foo.a" in node_ids
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.anyio
|
|
104
|
+
async def test_analyze_folder_multiple_files(client):
|
|
105
|
+
files = [
|
|
106
|
+
("files", ("pkg/a.py", b"def helper(): pass", "text/x-python")),
|
|
107
|
+
("files", ("pkg/b.py", b"from a import helper\ndef caller():\n helper()", "text/x-python")),
|
|
108
|
+
]
|
|
109
|
+
r = await client.post("/api/analyze-folder", files=files)
|
|
110
|
+
assert r.status_code == 200
|
|
111
|
+
data = r.json()
|
|
112
|
+
assert any(e["source"] == "b.caller" and e["target"] == "a.helper" for e in data["edges"])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── _find_project_root ────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def test_find_project_root_unwraps_single_dir(tmp_path):
|
|
118
|
+
inner = tmp_path / "myproject"
|
|
119
|
+
inner.mkdir()
|
|
120
|
+
(inner / "foo.py").touch()
|
|
121
|
+
assert _find_project_root(tmp_path) == inner
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_find_project_root_keeps_flat(tmp_path):
|
|
125
|
+
(tmp_path / "foo.py").touch()
|
|
126
|
+
(tmp_path / "bar.py").touch()
|
|
127
|
+
assert _find_project_root(tmp_path) == tmp_path
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_find_project_root_ignores_dot_files(tmp_path):
|
|
131
|
+
(tmp_path / ".DS_Store").touch()
|
|
132
|
+
inner = tmp_path / "src"
|
|
133
|
+
inner.mkdir()
|
|
134
|
+
assert _find_project_root(tmp_path) == inner
|