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 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.
@@ -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
@@ -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
+ ![ArchView interface](docs/interface.png)
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
+ ![Node colors and types](docs/colors.gif)
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
+ ![Live refresh](docs/live-refresh.gif)
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
+ ![Interactive exploration](docs/interaction.gif)
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
+ }