archview 0.2.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.
- archview/__init__.py +1 -0
- archview/annotations.py +26 -0
- archview/cli.py +236 -0
- archview/diff.py +161 -0
- archview/graph.py +589 -0
- archview/server.py +165 -0
- archview/static/cytoscape-dagre.js +397 -0
- archview/static/cytoscape.min.js +32 -0
- archview/static/dagre.min.js +3809 -0
- archview/static/live.html +1336 -0
- archview-0.2.0.dist-info/METADATA +9 -0
- archview-0.2.0.dist-info/RECORD +16 -0
- archview-0.2.0.dist-info/WHEEL +5 -0
- archview-0.2.0.dist-info/entry_points.txt +2 -0
- archview-0.2.0.dist-info/licenses/LICENSE +21 -0
- archview-0.2.0.dist-info/top_level.txt +1 -0
archview/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
archview/annotations.py
ADDED
|
@@ -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})
|
archview/cli.py
ADDED
|
@@ -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)
|
archview/diff.py
ADDED
|
@@ -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
|
+
}
|