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 ADDED
@@ -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})
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
+ }