archview 0.2.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.
- archview-0.2.0/LICENSE +21 -0
- archview-0.2.0/PKG-INFO +9 -0
- archview-0.2.0/README.md +112 -0
- archview-0.2.0/archview/__init__.py +1 -0
- archview-0.2.0/archview/annotations.py +26 -0
- archview-0.2.0/archview/cli.py +236 -0
- archview-0.2.0/archview/diff.py +161 -0
- archview-0.2.0/archview/graph.py +589 -0
- archview-0.2.0/archview/server.py +165 -0
- archview-0.2.0/archview/static/cytoscape-dagre.js +397 -0
- archview-0.2.0/archview/static/cytoscape.min.js +32 -0
- archview-0.2.0/archview/static/dagre.min.js +3809 -0
- archview-0.2.0/archview/static/live.html +1336 -0
- archview-0.2.0/archview.egg-info/PKG-INFO +9 -0
- archview-0.2.0/archview.egg-info/SOURCES.txt +20 -0
- archview-0.2.0/archview.egg-info/dependency_links.txt +1 -0
- archview-0.2.0/archview.egg-info/entry_points.txt +2 -0
- archview-0.2.0/archview.egg-info/requires.txt +3 -0
- archview-0.2.0/archview.egg-info/top_level.txt +1 -0
- archview-0.2.0/pyproject.toml +23 -0
- archview-0.2.0/setup.cfg +4 -0
- archview-0.2.0/tests/test_graph.py +384 -0
archview-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lorenzo Mirante
|
|
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.
|
archview-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: archview
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Interactive live architecture viewer for Python projects
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Provides-Extra: test
|
|
8
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
9
|
+
Dynamic: license-file
|
archview-0.2.0/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# ArchView
|
|
2
|
+
|
|
3
|
+
**See your codebase. Understand it. Then change it.**
|
|
4
|
+
|
|
5
|
+
ArchView gives you a live, interactive map of any Python project's architecture — right in your browser. It parses real Python (via AST, not regex), watches for changes, and updates the graph in real time.
|
|
6
|
+
|
|
7
|
+
Built for developers who vibe-code and need to stay oriented, or anyone inheriting a codebase they didn't write.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
You're 200 files deep in someone else's project. Or you're building fast and your own code is getting tangled. You need to *see* the structure — what depends on what, where the entry points are, which modules are isolated.
|
|
14
|
+
|
|
15
|
+
ArchView shows you all of that in seconds, and keeps updating as you code.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install archview
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Available on [PyPI](https://pypi.org/project/archview/). Pure Python, works anywhere with Python 3.9+.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
archview /path/to/your/project
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Open http://localhost:9090 — that's it.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Custom port and refresh interval
|
|
35
|
+
archview /path/to/project --port 8080 --interval 5
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What you see
|
|
39
|
+
|
|
40
|
+
| Color | Meaning |
|
|
41
|
+
|-------|---------|
|
|
42
|
+
| **Green** | Entry points — modules that import but aren't imported |
|
|
43
|
+
| **Blue** | Connectors — modules that both import and are imported |
|
|
44
|
+
| **Red** | Utilities — leaf modules only imported by others |
|
|
45
|
+
| **Gray** | Isolated — no import relationships |
|
|
46
|
+
| **Red (bright)** | Syntax errors — files that failed to parse |
|
|
47
|
+
|
|
48
|
+
<!-- TODO: replace with actual GIF -->
|
|
49
|
+

|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
### Live refresh
|
|
54
|
+
Edit your code, save — the graph updates automatically. No restart needed.
|
|
55
|
+
|
|
56
|
+
<!-- TODO: replace with actual GIF -->
|
|
57
|
+

|
|
58
|
+
|
|
59
|
+
### Interactive exploration
|
|
60
|
+
- **Hover** a node to see its docstring, type, and exported symbols
|
|
61
|
+
- **Click** a node to highlight its direct dependencies
|
|
62
|
+
- **Double-click** to open the file in VS Code
|
|
63
|
+
- **Drag** nodes to rearrange the layout
|
|
64
|
+
- **Click folders** to collapse/expand entire packages
|
|
65
|
+
|
|
66
|
+
<!-- TODO: replace with actual GIF -->
|
|
67
|
+

|
|
68
|
+
|
|
69
|
+
### Dependency highlighting
|
|
70
|
+
Hover over an edge to see exactly which symbols are imported. Click a node and its entire dependency chain lights up — everything else fades.
|
|
71
|
+
|
|
72
|
+
### Export
|
|
73
|
+
- **PNG** — screenshot the current view
|
|
74
|
+
- **Save** — persist node positions (restored on next launch)
|
|
75
|
+
|
|
76
|
+
### Ignore patterns
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Exclude directories from analysis
|
|
80
|
+
archview ignore tests __pycache__ venv
|
|
81
|
+
|
|
82
|
+
# List current patterns
|
|
83
|
+
archview ignore --list
|
|
84
|
+
|
|
85
|
+
# Remove a pattern
|
|
86
|
+
archview ignore --remove tests
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## How it works
|
|
90
|
+
|
|
91
|
+
1. Collects all `.py` files (git-aware, respects `.archviewignore`)
|
|
92
|
+
2. Parses each file's AST to extract imports, functions, classes
|
|
93
|
+
3. Builds a dependency graph with classified nodes
|
|
94
|
+
4. Renders it with [Cytoscape.js](https://js.cytoscape.org/) + [Dagre](https://github.com/dagrejs/dagre) layout
|
|
95
|
+
5. Watches for changes and re-generates every N seconds
|
|
96
|
+
|
|
97
|
+
**Zero dependencies** — pure Python stdlib. The frontend ships bundled.
|
|
98
|
+
|
|
99
|
+
## Example
|
|
100
|
+
|
|
101
|
+
Try it on the example project (hosted on GitHub):
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git clone https://github.com/lm17918/archview.git archview-demo
|
|
105
|
+
cd archview-demo
|
|
106
|
+
pip install archview
|
|
107
|
+
archview example_project
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Node annotations persistence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def handle_annotations_get(handler):
|
|
7
|
+
path = handler.data_dir / "annotations.json"
|
|
8
|
+
if not path.exists():
|
|
9
|
+
handler.send_response(404)
|
|
10
|
+
handler.end_headers()
|
|
11
|
+
return
|
|
12
|
+
data = path.read_bytes()
|
|
13
|
+
handler.send_response(200)
|
|
14
|
+
handler.send_header("Content-Type", "application/json")
|
|
15
|
+
handler.send_header("Content-Length", str(len(data)))
|
|
16
|
+
handler.end_headers()
|
|
17
|
+
handler.wfile.write(data)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def handle_annotations_post(handler):
|
|
21
|
+
data = handler._read_json_body()
|
|
22
|
+
if data is None:
|
|
23
|
+
return
|
|
24
|
+
(handler.data_dir / "annotations.json").write_text(
|
|
25
|
+
json.dumps(data, indent=2))
|
|
26
|
+
handler._json_response({"ok": True})
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import hashlib
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from archview.graph import generate_graph
|
|
11
|
+
from archview.server import make_server
|
|
12
|
+
|
|
13
|
+
# Known stdlib module names that are commonly shadowed by project folders
|
|
14
|
+
_STDLIB_NAMES = {
|
|
15
|
+
"abc", "ast", "asyncio", "base64", "calendar", "collections", "copy",
|
|
16
|
+
"csv", "ctypes", "datetime", "decimal", "difflib", "email", "enum",
|
|
17
|
+
"fractions", "functools", "glob", "gzip", "hashlib", "html", "http",
|
|
18
|
+
"importlib", "inspect", "io", "itertools", "json", "logging", "math",
|
|
19
|
+
"multiprocessing", "numbers", "operator", "os", "pathlib", "pickle",
|
|
20
|
+
"platform", "pprint", "profile", "queue", "random", "re", "secrets",
|
|
21
|
+
"select", "shelve", "shutil", "signal", "socket", "sqlite3",
|
|
22
|
+
"statistics", "string", "struct", "subprocess", "sys", "tempfile",
|
|
23
|
+
"test", "textwrap", "threading", "time", "timeit", "token", "tokenize",
|
|
24
|
+
"trace", "traceback", "types", "typing", "unittest", "urllib", "uuid",
|
|
25
|
+
"warnings", "weakref", "xml", "xmlrpc", "zipfile", "zipimport",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _check_stdlib_shadowing(project_dir: Path):
|
|
30
|
+
"""Warn if any folder in the project shadows a Python stdlib module."""
|
|
31
|
+
shadowed = []
|
|
32
|
+
for item in project_dir.iterdir():
|
|
33
|
+
if item.is_dir() and item.name in _STDLIB_NAMES:
|
|
34
|
+
init = item / "__init__.py"
|
|
35
|
+
if init.exists() or any(item.glob("*.py")):
|
|
36
|
+
shadowed.append(item.name)
|
|
37
|
+
if shadowed:
|
|
38
|
+
print(f"\n WARNING: These project folders shadow Python stdlib modules:")
|
|
39
|
+
for name in sorted(shadowed):
|
|
40
|
+
print(f" - {name}/")
|
|
41
|
+
print(f" This may cause crashes in archview or pip.")
|
|
42
|
+
print(f" Consider renaming them.\n")
|
|
43
|
+
|
|
44
|
+
IGNORE_FILENAME = ".archviewignore"
|
|
45
|
+
|
|
46
|
+
DEFAULT_IGNORE_PATTERNS = [
|
|
47
|
+
".archview",
|
|
48
|
+
".venv",
|
|
49
|
+
"venv",
|
|
50
|
+
"__pycache__",
|
|
51
|
+
"node_modules",
|
|
52
|
+
".git",
|
|
53
|
+
"build",
|
|
54
|
+
"dist",
|
|
55
|
+
"*.egg-info",
|
|
56
|
+
".tox",
|
|
57
|
+
".mypy_cache",
|
|
58
|
+
".pytest_cache",
|
|
59
|
+
".ruff_cache",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ignore_file_path(project_dir: Path) -> Path:
|
|
64
|
+
return project_dir / IGNORE_FILENAME
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ensure_default_ignore_file(project_dir: Path) -> Path:
|
|
68
|
+
ignore_file = _ignore_file_path(project_dir)
|
|
69
|
+
if ignore_file.exists():
|
|
70
|
+
return ignore_file
|
|
71
|
+
header = "# Auto-generated by archview. Folders/patterns to exclude from analysis (one per line).\n"
|
|
72
|
+
ignore_file.write_text(header + "\n".join(DEFAULT_IGNORE_PATTERNS) + "\n")
|
|
73
|
+
print(f"Created default {IGNORE_FILENAME}")
|
|
74
|
+
return ignore_file
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_patterns(ignore_file: Path) -> list[str]:
|
|
78
|
+
if not ignore_file.exists():
|
|
79
|
+
return []
|
|
80
|
+
lines = ignore_file.read_text().splitlines()
|
|
81
|
+
return [l for l in lines if l.strip() and not l.strip().startswith("#")]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cmd_ignore(args):
|
|
85
|
+
project_dir = Path(args.project_dir).resolve()
|
|
86
|
+
ignore_file = _ignore_file_path(project_dir)
|
|
87
|
+
|
|
88
|
+
if args.list:
|
|
89
|
+
patterns = _read_patterns(ignore_file)
|
|
90
|
+
if not patterns:
|
|
91
|
+
print("No patterns in .archviewignore")
|
|
92
|
+
else:
|
|
93
|
+
print(f"{ignore_file}:")
|
|
94
|
+
for p in patterns:
|
|
95
|
+
print(f" {p}")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if args.remove:
|
|
99
|
+
if not ignore_file.exists():
|
|
100
|
+
print("No .archviewignore file found")
|
|
101
|
+
return
|
|
102
|
+
lines = ignore_file.read_text().splitlines()
|
|
103
|
+
new_lines = [l for l in lines if l.strip() != args.remove]
|
|
104
|
+
if len(new_lines) == len(lines):
|
|
105
|
+
print(f"Pattern not found: {args.remove}")
|
|
106
|
+
return
|
|
107
|
+
ignore_file.write_text("\n".join(new_lines) + "\n")
|
|
108
|
+
print(f"Removed: {args.remove}")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if not args.patterns:
|
|
112
|
+
print("Usage: archview ignore <pattern> [pattern ...]")
|
|
113
|
+
print(" archview ignore --list")
|
|
114
|
+
print(" archview ignore --remove <pattern>")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
existing = _read_patterns(ignore_file)
|
|
118
|
+
added = []
|
|
119
|
+
for pattern in args.patterns:
|
|
120
|
+
if pattern in existing:
|
|
121
|
+
print(f"Already ignored: {pattern}")
|
|
122
|
+
else:
|
|
123
|
+
added.append(pattern)
|
|
124
|
+
|
|
125
|
+
if added:
|
|
126
|
+
write_header = not ignore_file.exists() or ignore_file.stat().st_size == 0
|
|
127
|
+
with ignore_file.open("a") as f:
|
|
128
|
+
if write_header:
|
|
129
|
+
f.write("# Folders/patterns to exclude from analysis (one per line)\n")
|
|
130
|
+
for p in added:
|
|
131
|
+
f.write(p + "\n")
|
|
132
|
+
for p in added:
|
|
133
|
+
print(f"Added: {p}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _project_cache_dir(project_dir: Path) -> Path:
|
|
137
|
+
"""Per-project cache dir outside the repo (XDG-style)."""
|
|
138
|
+
base = Path(
|
|
139
|
+
os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
|
|
140
|
+
) / "archview"
|
|
141
|
+
digest = hashlib.sha1(str(project_dir).encode("utf-8")).hexdigest()[:10]
|
|
142
|
+
return base / f"{project_dir.name}-{digest}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cmd_serve(args):
|
|
146
|
+
project_dir = Path(args.project_dir).resolve()
|
|
147
|
+
data_dir = _project_cache_dir(project_dir)
|
|
148
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
ignore_file = _ensure_default_ignore_file(project_dir)
|
|
151
|
+
|
|
152
|
+
static_dir = Path(str(importlib.resources.files("archview"))) / "static"
|
|
153
|
+
graph_path = data_dir / "graph.json"
|
|
154
|
+
|
|
155
|
+
_check_stdlib_shadowing(project_dir)
|
|
156
|
+
print("Running initial analysis...")
|
|
157
|
+
generate_graph(project_dir, ignore_file, graph_path)
|
|
158
|
+
print(f"Graph generated. Open http://localhost:{args.port}")
|
|
159
|
+
|
|
160
|
+
stop_event = threading.Event()
|
|
161
|
+
|
|
162
|
+
def watcher():
|
|
163
|
+
while not stop_event.wait(args.interval):
|
|
164
|
+
try:
|
|
165
|
+
generate_graph(project_dir, ignore_file, graph_path)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
threading.Thread(target=watcher, daemon=True).start()
|
|
170
|
+
|
|
171
|
+
server = make_server("127.0.0.1", args.port, static_dir, data_dir,
|
|
172
|
+
project_dir, args.interval, ignore_file)
|
|
173
|
+
|
|
174
|
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
175
|
+
server_thread.start()
|
|
176
|
+
|
|
177
|
+
print("Press Ctrl+C to stop\n")
|
|
178
|
+
|
|
179
|
+
shutdown_requested = threading.Event()
|
|
180
|
+
|
|
181
|
+
def _request_shutdown(signum, frame):
|
|
182
|
+
shutdown_requested.set()
|
|
183
|
+
|
|
184
|
+
signal.signal(signal.SIGTERM, _request_shutdown)
|
|
185
|
+
signal.signal(signal.SIGHUP, _request_shutdown)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
while server_thread.is_alive() and not shutdown_requested.is_set():
|
|
189
|
+
server_thread.join(timeout=1)
|
|
190
|
+
except KeyboardInterrupt:
|
|
191
|
+
pass
|
|
192
|
+
stop_event.set()
|
|
193
|
+
server.shutdown()
|
|
194
|
+
print("\nStopped.")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main():
|
|
198
|
+
# If first positional arg is not a known subcommand, inject "serve"
|
|
199
|
+
# so that `archview example_project` works as `archview serve example_project`
|
|
200
|
+
known_commands = {"ignore", "serve"}
|
|
201
|
+
if len(sys.argv) > 1 and sys.argv[1] not in known_commands and not sys.argv[1].startswith("-"):
|
|
202
|
+
sys.argv.insert(1, "serve")
|
|
203
|
+
|
|
204
|
+
parser = argparse.ArgumentParser(
|
|
205
|
+
prog="archview",
|
|
206
|
+
description="Interactive live architecture viewer for Python projects",
|
|
207
|
+
)
|
|
208
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
209
|
+
|
|
210
|
+
# --- ignore subcommand ---
|
|
211
|
+
ignore_parser = subparsers.add_parser("ignore", help="Manage .archviewignore patterns")
|
|
212
|
+
ignore_parser.add_argument("patterns", nargs="*", help="Patterns to add")
|
|
213
|
+
ignore_parser.add_argument("--list", "-l", action="store_true", help="List current patterns")
|
|
214
|
+
ignore_parser.add_argument("--remove", "-r", metavar="PATTERN", help="Remove a pattern")
|
|
215
|
+
ignore_parser.add_argument("--project-dir", default=".", metavar="DIR")
|
|
216
|
+
|
|
217
|
+
# --- serve (default) ---
|
|
218
|
+
serve_parser = subparsers.add_parser("serve", help="Start the live architecture viewer")
|
|
219
|
+
serve_parser.add_argument("project_dir", nargs="?", default=".")
|
|
220
|
+
serve_parser.add_argument("--port", "-p", type=int, default=9090,
|
|
221
|
+
choices=range(1, 65536), metavar="PORT")
|
|
222
|
+
serve_parser.add_argument("--interval", type=int, default=10,
|
|
223
|
+
choices=range(1, 3601), metavar="SEC",
|
|
224
|
+
help="Graph refresh interval in seconds (default: 10)")
|
|
225
|
+
|
|
226
|
+
args = parser.parse_args()
|
|
227
|
+
|
|
228
|
+
if args.command == "ignore":
|
|
229
|
+
_cmd_ignore(args)
|
|
230
|
+
elif args.command == "serve":
|
|
231
|
+
_cmd_serve(args)
|
|
232
|
+
else:
|
|
233
|
+
# No args at all → default to serve current dir
|
|
234
|
+
sys.argv.insert(1, "serve")
|
|
235
|
+
args = parser.parse_args()
|
|
236
|
+
_cmd_serve(args)
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Diff view: compare current graph against a git ref."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import parse_qs, urlparse
|
|
11
|
+
|
|
12
|
+
from archview.graph import generate_graph_json
|
|
13
|
+
|
|
14
|
+
_diff_lock = threading.Lock()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def handle_refs(handler):
|
|
18
|
+
handler._json_response(_list_refs(handler.project_dir))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def handle_diff(handler):
|
|
22
|
+
qs = parse_qs(urlparse(handler.path).query)
|
|
23
|
+
ref = qs.get("ref", [None])[0]
|
|
24
|
+
if not ref:
|
|
25
|
+
handler._json_response({"error": "missing ref param"}, 400)
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
if not _diff_lock.acquire(blocking=False):
|
|
29
|
+
handler._json_response({"error": "diff already running"}, 429)
|
|
30
|
+
return
|
|
31
|
+
try:
|
|
32
|
+
old = _generate_old_graph(
|
|
33
|
+
handler.project_dir, ref, handler.ignore_file)
|
|
34
|
+
cur = generate_graph_json(
|
|
35
|
+
handler.project_dir, handler.ignore_file)
|
|
36
|
+
handler._json_response(_compute_diff(cur, old, ref))
|
|
37
|
+
except subprocess.CalledProcessError:
|
|
38
|
+
handler._json_response({"error": f"invalid ref: {ref}"}, 400)
|
|
39
|
+
finally:
|
|
40
|
+
_diff_lock.release()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _list_refs(project_dir: Path) -> dict:
|
|
44
|
+
project_dir = Path(project_dir)
|
|
45
|
+
result: dict[str, list] = {"commits": [], "branches": [], "tags": []}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
log = subprocess.run(
|
|
49
|
+
["git", "log", "--oneline", "-20"],
|
|
50
|
+
cwd=project_dir, capture_output=True, text=True, check=True,
|
|
51
|
+
)
|
|
52
|
+
for line in log.stdout.strip().splitlines():
|
|
53
|
+
if not line:
|
|
54
|
+
continue
|
|
55
|
+
parts = line.split(None, 1)
|
|
56
|
+
result["commits"].append({
|
|
57
|
+
"hash": parts[0],
|
|
58
|
+
"message": parts[1] if len(parts) > 1 else "",
|
|
59
|
+
})
|
|
60
|
+
except subprocess.CalledProcessError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
branches = subprocess.run(
|
|
65
|
+
["git", "branch", "--format=%(refname:short)"],
|
|
66
|
+
cwd=project_dir, capture_output=True, text=True, check=True,
|
|
67
|
+
)
|
|
68
|
+
result["branches"] = [
|
|
69
|
+
b.strip() for b in branches.stdout.strip().splitlines()
|
|
70
|
+
if b.strip()
|
|
71
|
+
]
|
|
72
|
+
except subprocess.CalledProcessError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
tags = subprocess.run(
|
|
77
|
+
["git", "tag"],
|
|
78
|
+
cwd=project_dir, capture_output=True, text=True, check=True,
|
|
79
|
+
)
|
|
80
|
+
result["tags"] = [
|
|
81
|
+
t.strip() for t in tags.stdout.strip().splitlines()
|
|
82
|
+
if t.strip()
|
|
83
|
+
]
|
|
84
|
+
except subprocess.CalledProcessError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _generate_old_graph(project_dir: Path, ref: str,
|
|
91
|
+
ignore_file: Path | None) -> list[dict]:
|
|
92
|
+
project_dir = Path(project_dir)
|
|
93
|
+
subprocess.run(
|
|
94
|
+
["git", "rev-parse", "--verify", ref],
|
|
95
|
+
cwd=project_dir, capture_output=True, text=True, check=True,
|
|
96
|
+
)
|
|
97
|
+
tmpdir = tempfile.mkdtemp(prefix="archview_diff_")
|
|
98
|
+
try:
|
|
99
|
+
subprocess.run(
|
|
100
|
+
["git", "worktree", "add", "--detach", tmpdir, ref],
|
|
101
|
+
cwd=project_dir, capture_output=True, text=True, check=True,
|
|
102
|
+
)
|
|
103
|
+
return generate_graph_json(Path(tmpdir), ignore_file)
|
|
104
|
+
finally:
|
|
105
|
+
subprocess.run(
|
|
106
|
+
["git", "worktree", "remove", "--force", tmpdir],
|
|
107
|
+
cwd=project_dir, capture_output=True, text=True,
|
|
108
|
+
)
|
|
109
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _element_fingerprint(el: dict) -> str:
|
|
113
|
+
d = el["data"]
|
|
114
|
+
if "source" in d:
|
|
115
|
+
return f"{d.get('source')}|{d.get('target')}|{d.get('label', '')}"
|
|
116
|
+
return (f"{d.get('type', '')}|{d.get('symbols', '')}"
|
|
117
|
+
f"|{d.get('docstring', '')}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _compute_diff(current: list[dict], old: list[dict], ref: str) -> dict:
|
|
121
|
+
def index(elements):
|
|
122
|
+
nodes, edges = {}, {}
|
|
123
|
+
for el in elements:
|
|
124
|
+
eid = el["data"]["id"]
|
|
125
|
+
if "source" in el["data"]:
|
|
126
|
+
edges[eid] = el
|
|
127
|
+
else:
|
|
128
|
+
nodes[eid] = el
|
|
129
|
+
return nodes, edges
|
|
130
|
+
|
|
131
|
+
cur_nodes, cur_edges = index(current)
|
|
132
|
+
old_nodes, old_edges = index(old)
|
|
133
|
+
|
|
134
|
+
added_nodes = sorted(set(cur_nodes) - set(old_nodes))
|
|
135
|
+
removed_nodes = sorted(set(old_nodes) - set(cur_nodes))
|
|
136
|
+
added_edges = sorted(set(cur_edges) - set(old_edges))
|
|
137
|
+
removed_edges = sorted(set(old_edges) - set(cur_edges))
|
|
138
|
+
|
|
139
|
+
modified_nodes = []
|
|
140
|
+
for nid in sorted(set(cur_nodes) & set(old_nodes)):
|
|
141
|
+
if _element_fingerprint(cur_nodes[nid]) != \
|
|
142
|
+
_element_fingerprint(old_nodes[nid]):
|
|
143
|
+
modified_nodes.append(nid)
|
|
144
|
+
|
|
145
|
+
removed_elements = []
|
|
146
|
+
for nid in removed_nodes:
|
|
147
|
+
el = old_nodes[nid]
|
|
148
|
+
if not el["data"].get("is_folder"):
|
|
149
|
+
removed_elements.append(el)
|
|
150
|
+
for eid in removed_edges:
|
|
151
|
+
removed_elements.append(old_edges[eid])
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"ref": ref,
|
|
155
|
+
"added_nodes": added_nodes,
|
|
156
|
+
"removed_nodes": removed_nodes,
|
|
157
|
+
"modified_nodes": modified_nodes,
|
|
158
|
+
"added_edges": added_edges,
|
|
159
|
+
"removed_edges": removed_edges,
|
|
160
|
+
"removed_elements": removed_elements,
|
|
161
|
+
}
|