system-diff 0.1.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.
- system_diff/__init__.py +8 -0
- system_diff/__main__.py +5 -0
- system_diff/agent.py +197 -0
- system_diff/cli.py +214 -0
- system_diff/compare.py +105 -0
- system_diff/config.py +106 -0
- system_diff/diff.py +171 -0
- system_diff/extract.py +279 -0
- system_diff/graph.py +209 -0
- system_diff/mcp_server.py +206 -0
- system_diff/narrate.py +141 -0
- system_diff/pictures.py +550 -0
- system_diff/png.py +121 -0
- system_diff/readme.py +39 -0
- system_diff/sources.py +76 -0
- system_diff/watch.py +177 -0
- system_diff-0.1.0.dist-info/METADATA +131 -0
- system_diff-0.1.0.dist-info/RECORD +21 -0
- system_diff-0.1.0.dist-info/WHEEL +4 -0
- system_diff-0.1.0.dist-info/entry_points.txt +3 -0
- system_diff-0.1.0.dist-info/licenses/LICENSE +21 -0
system_diff/png.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""SVG -> PNG, each FILE.svg -> FILE.png beside it, at twice the size for sharp text.
|
|
2
|
+
|
|
3
|
+
Only for places that show a PNG and not an SVG with its styles (some PR pages, some previews, some proxies); GitHub shows
|
|
4
|
+
the SVG itself. Headless Chromium draws them, in this order: Docker here (the pinned image below only for its Chromium
|
|
5
|
+
and puppeteer), else Playwright if it is installed (`pip install system-diff[png]`, then `playwright install chromium`),
|
|
6
|
+
else Docker on HOST over SSH (`--host`).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tarfile
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
IMAGE = os.getenv("SYSTEM_DIFF_RENDER_IMAGE", "minlag/mermaid-cli:11.4.2")
|
|
21
|
+
|
|
22
|
+
SHOT_JS = r"""// Every /data/N.svg -> /data/N.png: the SVG's own width and height, at device scale 2.
|
|
23
|
+
const fs = require("fs");
|
|
24
|
+
const puppeteer = require("/home/mermaidcli/node_modules/puppeteer");
|
|
25
|
+
const config = JSON.parse(fs.readFileSync("/puppeteer-config.json", "utf8"));
|
|
26
|
+
(async () => {
|
|
27
|
+
const browser = await puppeteer.launch(config);
|
|
28
|
+
const page = await browser.newPage();
|
|
29
|
+
for (const f of fs.readdirSync("/data").filter((x) => x.endsWith(".svg"))) {
|
|
30
|
+
const head = fs.readFileSync(`/data/${f}`, "utf8").slice(0, 400);
|
|
31
|
+
const width = Math.ceil(+(head.match(/\bwidth="([\d.]+)"/) || [0, 1200])[1]);
|
|
32
|
+
const height = Math.ceil(+(head.match(/\bheight="([\d.]+)"/) || [0, 800])[1]);
|
|
33
|
+
await page.setViewport({ width, height, deviceScaleFactor: 2 });
|
|
34
|
+
await page.goto(`file:///data/${f}`, { waitUntil: "load" });
|
|
35
|
+
await page.screenshot({ path: `/data/${f.replace(/\.svg$/, ".png")}`, clip: { x: 0, y: 0, width, height } });
|
|
36
|
+
}
|
|
37
|
+
await browser.close();
|
|
38
|
+
})().catch((e) => { console.error(`render failed: ${e.message}`); process.exit(1); });
|
|
39
|
+
"""
|
|
40
|
+
IN_CONTAINER = "cd /data && HOME=/tmp node shot.js"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _docker_here() -> bool:
|
|
44
|
+
if not shutil.which("docker"):
|
|
45
|
+
return False
|
|
46
|
+
return subprocess.run(["docker", "info"], capture_output=True).returncode == 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _playwright(stage: Path, names: List[str]) -> bool:
|
|
50
|
+
try:
|
|
51
|
+
from playwright.sync_api import sync_playwright # type: ignore[import-not-found]
|
|
52
|
+
except ImportError:
|
|
53
|
+
return False
|
|
54
|
+
import re
|
|
55
|
+
with sync_playwright() as p:
|
|
56
|
+
browser = p.chromium.launch()
|
|
57
|
+
for name in names:
|
|
58
|
+
svg = stage / name
|
|
59
|
+
head = svg.read_text(encoding="utf-8")[:400]
|
|
60
|
+
width = int(float((re.search(r'\bwidth="([\d.]+)"', head) or [0, 1200])[1]) + 0.999)
|
|
61
|
+
height = int(float((re.search(r'\bheight="([\d.]+)"', head) or [0, 800])[1]) + 0.999)
|
|
62
|
+
page = browser.new_page(viewport={"width": width, "height": height}, device_scale_factor=2)
|
|
63
|
+
page.goto(svg.resolve().as_uri())
|
|
64
|
+
page.screenshot(path=str(svg.with_suffix(".png")), clip={"x": 0, "y": 0, "width": width, "height": height})
|
|
65
|
+
page.close()
|
|
66
|
+
browser.close()
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _over_ssh(stage: Path, host: str) -> None:
|
|
71
|
+
buf = io.BytesIO()
|
|
72
|
+
with tarfile.open(fileobj=buf, mode="w") as tar:
|
|
73
|
+
for f in sorted(stage.iterdir()):
|
|
74
|
+
tar.add(f, arcname=f.name)
|
|
75
|
+
remote = ("set -e; d=$(mktemp -d $HOME/.system-diff-render.XXXXXX); trap 'rm -rf $d' EXIT; tar -x -C $d; "
|
|
76
|
+
f"chmod 777 $d; docker run --rm -v $d:/data --entrypoint sh {IMAGE} -c '{IN_CONTAINER}' >&2; "
|
|
77
|
+
"cd $d && tar -cf - *.png")
|
|
78
|
+
got = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", host, remote],
|
|
79
|
+
input=buf.getvalue(), capture_output=True)
|
|
80
|
+
if got.returncode != 0:
|
|
81
|
+
raise RuntimeError(f"drawing on {host} failed: {got.stderr.decode(errors='replace')[-800:]}")
|
|
82
|
+
with tarfile.open(fileobj=io.BytesIO(got.stdout)) as tar:
|
|
83
|
+
for m in tar.getmembers(): # only flat PNGs come back; nothing a remote sends may land elsewhere
|
|
84
|
+
name = Path(m.name).name
|
|
85
|
+
if m.isfile() and name.endswith(".png"):
|
|
86
|
+
(stage / name).write_bytes(tar.extractfile(m).read())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render(svgs: List[Path], host: Optional[str] = None) -> List[Path]:
|
|
90
|
+
"""Draw each SVG as a PNG beside it. Raises RuntimeError naming what is missing when nothing can draw."""
|
|
91
|
+
for f in svgs:
|
|
92
|
+
if f.suffix != ".svg" or not f.is_file():
|
|
93
|
+
raise ValueError(f"not an .svg file: {f}")
|
|
94
|
+
stage = Path(tempfile.mkdtemp(prefix="system-diff-render."))
|
|
95
|
+
try:
|
|
96
|
+
names = []
|
|
97
|
+
for i, f in enumerate(svgs):
|
|
98
|
+
shutil.copyfile(f, stage / f"{i}.svg")
|
|
99
|
+
names.append(f"{i}.svg")
|
|
100
|
+
(stage / "shot.js").write_text(SHOT_JS, encoding="utf-8", newline="\n")
|
|
101
|
+
if _docker_here():
|
|
102
|
+
user = ["-u", f"{os.getuid()}:{os.getgid()}"] if sys.platform.startswith("linux") else []
|
|
103
|
+
done = subprocess.run(["docker", "run", "--rm", *user, "-v", f"{stage}:/data", "--entrypoint", "sh", IMAGE,
|
|
104
|
+
"-c", IN_CONTAINER])
|
|
105
|
+
if done.returncode != 0:
|
|
106
|
+
raise RuntimeError("Chromium could not draw the pictures (output above)")
|
|
107
|
+
elif not _playwright(stage, names):
|
|
108
|
+
if not host:
|
|
109
|
+
raise RuntimeError("nothing here can draw PNGs: no Docker, no Playwright "
|
|
110
|
+
"(`pip install system-diff[png]`), and no --host with Docker")
|
|
111
|
+
_over_ssh(stage, host)
|
|
112
|
+
out = []
|
|
113
|
+
for i, f in enumerate(svgs):
|
|
114
|
+
png = stage / f"{i}.png"
|
|
115
|
+
if not png.is_file():
|
|
116
|
+
raise RuntimeError(f"no picture came back for {f}")
|
|
117
|
+
shutil.copyfile(png, f.with_suffix(".png"))
|
|
118
|
+
out.append(f.with_suffix(".png"))
|
|
119
|
+
return out
|
|
120
|
+
finally:
|
|
121
|
+
shutil.rmtree(stage, ignore_errors=True)
|
system_diff/readme.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The README's generated system-map section: the picture, how it is made, and a warning when the picture is stale."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Iterable
|
|
6
|
+
|
|
7
|
+
from .pictures import write_if_changed
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def readme_section(dormant: Dict[str, dict], cfg: Dict[str, Any], stale: Iterable[str] = ()) -> str:
|
|
11
|
+
"""The generated block. `stale`: pictures that could not be redrawn this time, named, so no one mistakes an old
|
|
12
|
+
picture for the current system; the SVGs beside them are always current."""
|
|
13
|
+
r = cfg["readme"]
|
|
14
|
+
stale = list(stale)
|
|
15
|
+
tag = ""
|
|
16
|
+
if stale:
|
|
17
|
+
tag = r["stale"].format(pictures=", ".join(f"`{x}`" for x in stale), it="it" if len(stale) == 1 else "them")
|
|
18
|
+
note = ""
|
|
19
|
+
if dormant:
|
|
20
|
+
names = ", ".join(f"{v['label']} ({', '.join(v['used_by'])})" for v in dormant.values())
|
|
21
|
+
note = r["dormant"].format(names=names)
|
|
22
|
+
return (
|
|
23
|
+
f"{r['begin']}\n"
|
|
24
|
+
f"{tag}"
|
|
25
|
+
f"\n\n"
|
|
26
|
+
"<details><summary>How this map is made</summary>\n\n"
|
|
27
|
+
f"{r['how'].format(dormant=note)}\n\n</details>\n"
|
|
28
|
+
f"{r['end']}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def update_readme(readme: Path, dormant: Dict[str, dict], cfg: Dict[str, Any], stale: Iterable[str] = ()) -> bool:
|
|
33
|
+
"""Rewrite the section between the README's markers. True when the file changed."""
|
|
34
|
+
r = cfg["readme"]
|
|
35
|
+
text = readme.read_text(encoding="utf-8")
|
|
36
|
+
if r["begin"] not in text or r["end"] not in text:
|
|
37
|
+
raise SystemExit(f"{readme} has no {r['begin']} … {r['end']} section: add the two lines where the map goes")
|
|
38
|
+
start, end = text.index(r["begin"]), text.index(r["end"]) + len(r["end"])
|
|
39
|
+
return write_if_changed(readme, text[:start] + readme_section(dormant, cfg, stale) + text[end:])
|
system_diff/sources.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Where the files come from: a commit, read straight from git's object store (no checkout), or a directory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Iterable, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def git(*args: str, cwd: Path) -> str:
|
|
12
|
+
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True,
|
|
13
|
+
encoding="utf-8", errors="replace").stdout
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def repo_root(start: Optional[Path] = None) -> Path:
|
|
17
|
+
"""The repository holding `start` (default: the current directory), or `start` itself outside one."""
|
|
18
|
+
here = Path(start or Path.cwd()).resolve()
|
|
19
|
+
try:
|
|
20
|
+
return Path(git("rev-parse", "--show-toplevel", cwd=here).strip())
|
|
21
|
+
except (OSError, subprocess.CalledProcessError):
|
|
22
|
+
return here
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitSource:
|
|
26
|
+
"""The files of a commit."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, ref: str, root: Path):
|
|
29
|
+
self.root, self.ref = Path(root), ref
|
|
30
|
+
self.label = git("rev-parse", "--short", ref, cwd=self.root).strip()
|
|
31
|
+
self.files = [f for f in git("ls-tree", "-r", "-z", "--name-only", ref, cwd=self.root).split("\0") if f]
|
|
32
|
+
self._cache: Dict[str, str] = {}
|
|
33
|
+
|
|
34
|
+
def read_many(self, paths: Iterable[str]) -> Dict[str, str]:
|
|
35
|
+
paths = list(paths)
|
|
36
|
+
want = [p for p in paths if p not in self._cache]
|
|
37
|
+
if want:
|
|
38
|
+
out = subprocess.run(["git", "cat-file", "--batch"], cwd=self.root, check=True, capture_output=True,
|
|
39
|
+
input="".join(f"{self.ref}:{p}\n" for p in want).encode()).stdout
|
|
40
|
+
i = 0
|
|
41
|
+
for p in want:
|
|
42
|
+
header_end = out.index(b"\n", i)
|
|
43
|
+
parts = out[i:header_end].split()
|
|
44
|
+
i = header_end + 1
|
|
45
|
+
if len(parts) < 3 or parts[1] == b"missing":
|
|
46
|
+
self._cache[p] = ""
|
|
47
|
+
continue
|
|
48
|
+
size = int(parts[2])
|
|
49
|
+
self._cache[p] = out[i:i + size].decode("utf-8", errors="replace")
|
|
50
|
+
i += size + 1
|
|
51
|
+
return {p: self._cache.get(p, "") for p in paths}
|
|
52
|
+
|
|
53
|
+
def read(self, path: str) -> str:
|
|
54
|
+
return self.read_many([path])[path]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DirSource:
|
|
58
|
+
"""The files of a directory: the working tree, or a test fixture."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, directory: Path, skip: Optional[re.Pattern] = None, label: str = "working tree"):
|
|
61
|
+
self.dir, self.label = Path(directory), label
|
|
62
|
+
files = []
|
|
63
|
+
for top, dirs, names in os.walk(self.dir):
|
|
64
|
+
rel = os.path.relpath(top, self.dir).replace(os.sep, "/")
|
|
65
|
+
dirs[:] = sorted(x for x in dirs if x != ".git" and not (skip and skip.search(f"{rel}/{x}/")))
|
|
66
|
+
files += [(f"{rel}/{n}" if rel != "." else n) for n in names]
|
|
67
|
+
self.files = sorted(files)
|
|
68
|
+
|
|
69
|
+
def read_many(self, paths: Iterable[str]) -> Dict[str, str]:
|
|
70
|
+
return {p: self.read(p) for p in paths}
|
|
71
|
+
|
|
72
|
+
def read(self, path: str) -> str:
|
|
73
|
+
try:
|
|
74
|
+
return (self.dir / path).read_text(encoding="utf-8", errors="replace")
|
|
75
|
+
except OSError:
|
|
76
|
+
return ""
|
system_diff/watch.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""`system-diff watch`: a live page of what changed in the system since the checkpoint, for a person to keep beside the
|
|
2
|
+
editor while they, or an AI coding agent, change the code. It redraws when a file changes; "Mark as seen" moves the
|
|
3
|
+
checkpoint, so the page then shows only what is new to them.
|
|
4
|
+
|
|
5
|
+
Local only: the server listens on 127.0.0.1, and "Mark as seen" needs the page's own token, so no other web page open in
|
|
6
|
+
the same browser can press it.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import secrets
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
from . import graph
|
|
20
|
+
from .compare import checkpoint, checkpoint_sha, compare, worktree_tree
|
|
21
|
+
|
|
22
|
+
PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8"><title>System diff</title>
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
24
|
+
<style>
|
|
25
|
+
:root { --ink:#161c24; --muted:#667385; --line:#e1e5eb; --bg:#f8f9fb; --work:#1f5fbf; }
|
|
26
|
+
* { box-sizing: border-box; }
|
|
27
|
+
body { margin:0; font:14px/1.45 'Segoe UI', FreeSans, 'Helvetica Neue', Arial, sans-serif; color:var(--ink); background:var(--bg); }
|
|
28
|
+
header { position:sticky; top:0; display:flex; flex-wrap:wrap; gap:12px; align-items:center; padding:12px 20px;
|
|
29
|
+
background:#fff; border-bottom:1px solid var(--line); }
|
|
30
|
+
h1 { font-size:15px; margin:0; } .since { color:var(--muted); font-size:12.5px; }
|
|
31
|
+
button { margin-left:auto; border:1px solid var(--work); background:var(--work); color:#fff; border-radius:8px;
|
|
32
|
+
padding:6px 14px; font:inherit; font-weight:600; cursor:pointer; } button:disabled { opacity:.5; cursor:default; }
|
|
33
|
+
main { padding:16px 20px; display:grid; gap:14px; }
|
|
34
|
+
.headline { font-size:16px; font-weight:600; } .error { color:#c4321c; white-space:pre-wrap; }
|
|
35
|
+
.picture { overflow:auto; background:#fff; border:1px solid var(--line); border-radius:10px; padding:8px; }
|
|
36
|
+
.picture svg { display:block; max-width:none; }
|
|
37
|
+
pre { margin:0; padding:12px 14px; background:#fff; border:1px solid var(--line); border-radius:10px; overflow:auto;
|
|
38
|
+
font:12.5px/1.5 Consolas, Inconsolata, 'DejaVu Sans Mono', monospace; }
|
|
39
|
+
.quiet { color:var(--muted); }
|
|
40
|
+
</style></head><body>
|
|
41
|
+
<header><h1>System diff</h1><span class="since" id="since"></span>
|
|
42
|
+
<button id="seen" type="button" title="Move the checkpoint here: the page then shows only what is new">Mark as seen</button></header>
|
|
43
|
+
<main><div class="headline" id="headline">Reading the code…</div><div class="error" id="error"></div>
|
|
44
|
+
<div class="picture" id="picture" hidden></div><pre id="plan" hidden></pre></main>
|
|
45
|
+
<script>
|
|
46
|
+
const TOKEN = "__TOKEN__";
|
|
47
|
+
let version = -1;
|
|
48
|
+
function show(s) {
|
|
49
|
+
version = s.version;
|
|
50
|
+
document.getElementById("since").textContent = "since " + s.base + " · updated " + s.at;
|
|
51
|
+
document.getElementById("headline").textContent = s.headline;
|
|
52
|
+
document.getElementById("error").textContent = s.error || "";
|
|
53
|
+
const pic = document.getElementById("picture"), plan = document.getElementById("plan");
|
|
54
|
+
pic.hidden = !s.svg; pic.innerHTML = s.svg || "";
|
|
55
|
+
plan.hidden = !s.changes; plan.textContent = s.plan;
|
|
56
|
+
document.getElementById("seen").disabled = !s.changes;
|
|
57
|
+
}
|
|
58
|
+
async function poll() {
|
|
59
|
+
try {
|
|
60
|
+
const r = await fetch("state?since=" + version, { cache: "no-store" });
|
|
61
|
+
if (r.status === 200) show(await r.json());
|
|
62
|
+
} catch (e) { document.getElementById("error").textContent = "The watcher stopped: start system-diff watch again."; }
|
|
63
|
+
setTimeout(poll, 1500);
|
|
64
|
+
}
|
|
65
|
+
document.getElementById("seen").onclick = async () => {
|
|
66
|
+
const r = await fetch("seen", { method: "POST", headers: { "X-Token": TOKEN } });
|
|
67
|
+
if (r.status === 200) show(await r.json());
|
|
68
|
+
};
|
|
69
|
+
poll();
|
|
70
|
+
</script></body></html>
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Watcher:
|
|
75
|
+
"""The current diff, recomputed in the background whenever the working tree or the checkpoint moves."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, root: Path, base: str = "checkpoint", config_path: Optional[str] = None, interval: float = 2.0):
|
|
78
|
+
self.root, self.base, self.config_path, self.interval = root, base, config_path, interval
|
|
79
|
+
self.token = secrets.token_urlsafe(16)
|
|
80
|
+
self.lock = threading.Lock()
|
|
81
|
+
self.state: Dict[str, Any] = {"version": 0, "headline": "Reading the code…", "plan": "", "svg": "",
|
|
82
|
+
"changes": 0, "base": base, "at": "", "error": ""}
|
|
83
|
+
self.seen_key: Optional[tuple] = None
|
|
84
|
+
self.stop = threading.Event()
|
|
85
|
+
# One look at the files at a time: otherwise a slow look that started before "Mark as seen" could finish
|
|
86
|
+
# after it and put the old diff back on the page.
|
|
87
|
+
self.busy = threading.Lock()
|
|
88
|
+
|
|
89
|
+
def key(self) -> tuple:
|
|
90
|
+
return worktree_tree(self.root), checkpoint_sha(self.root)
|
|
91
|
+
|
|
92
|
+
def refresh(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
c = compare(self.root, self.base, ".", self.config_path)
|
|
95
|
+
svg = graph.diff_svg(c.diff, c.before, c.after, c.cfg) or ""
|
|
96
|
+
new = {"headline": c.diff["headline"], "plan": c.diff["plan"], "svg": svg,
|
|
97
|
+
"changes": len(c.diff["changes"]), "base": c.base, "error": ""}
|
|
98
|
+
except (OSError, subprocess.CalledProcessError, ValueError, KeyError) as exc:
|
|
99
|
+
new = {"error": f"{type(exc).__name__}: {exc}"}
|
|
100
|
+
with self.lock:
|
|
101
|
+
self.state.update(new, version=self.state["version"] + 1, at=time.strftime("%H:%M:%S"))
|
|
102
|
+
|
|
103
|
+
def tick(self, force: bool = False) -> None:
|
|
104
|
+
with self.busy:
|
|
105
|
+
try:
|
|
106
|
+
key = self.key()
|
|
107
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
108
|
+
with self.lock:
|
|
109
|
+
self.state.update(error=f"git: {exc}", version=self.state["version"] + 1)
|
|
110
|
+
return
|
|
111
|
+
if force or key != self.seen_key:
|
|
112
|
+
self.seen_key = key
|
|
113
|
+
self.refresh()
|
|
114
|
+
|
|
115
|
+
def loop(self) -> None:
|
|
116
|
+
while not self.stop.is_set():
|
|
117
|
+
self.tick()
|
|
118
|
+
self.stop.wait(self.interval)
|
|
119
|
+
|
|
120
|
+
def mark_seen(self) -> None:
|
|
121
|
+
with self.busy:
|
|
122
|
+
checkpoint(self.root)
|
|
123
|
+
self.tick(force=True) # the page's answer is the diff from the new checkpoint, never an older look
|
|
124
|
+
|
|
125
|
+
def snapshot_state(self) -> Dict[str, Any]:
|
|
126
|
+
with self.lock:
|
|
127
|
+
return dict(self.state)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def handler(w: Watcher):
|
|
131
|
+
class Handler(BaseHTTPRequestHandler):
|
|
132
|
+
def log_message(self, *a): # quiet: the terminal shows the URL, not every poll
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def _send(self, code: int, body: bytes, kind: str) -> None:
|
|
136
|
+
self.send_response(code)
|
|
137
|
+
self.send_header("Content-Type", kind)
|
|
138
|
+
self.send_header("Content-Length", str(len(body)))
|
|
139
|
+
self.send_header("Cache-Control", "no-store")
|
|
140
|
+
self.end_headers()
|
|
141
|
+
self.wfile.write(body)
|
|
142
|
+
|
|
143
|
+
def do_GET(self):
|
|
144
|
+
if self.path in ("/", "/index.html"):
|
|
145
|
+
return self._send(200, PAGE.replace("__TOKEN__", w.token).encode(), "text/html; charset=utf-8")
|
|
146
|
+
if self.path.startswith("/state"):
|
|
147
|
+
return self._send(200, json.dumps(w.snapshot_state()).encode(), "application/json")
|
|
148
|
+
return self._send(404, b"not found", "text/plain")
|
|
149
|
+
|
|
150
|
+
def do_POST(self):
|
|
151
|
+
if self.path != "/seen" or self.headers.get("X-Token") != w.token:
|
|
152
|
+
return self._send(403, b"forbidden", "text/plain")
|
|
153
|
+
w.mark_seen()
|
|
154
|
+
return self._send(200, json.dumps(w.snapshot_state()).encode(), "application/json")
|
|
155
|
+
|
|
156
|
+
return Handler
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def serve(root: Path, base: str = "checkpoint", port: int = 8765, config_path: Optional[str] = None,
|
|
160
|
+
interval: float = 2.0, ready=None) -> None:
|
|
161
|
+
"""Run until interrupted. `ready(url)` is called once the server listens (tests use port 0)."""
|
|
162
|
+
w = Watcher(root, base, config_path, interval)
|
|
163
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), handler(w))
|
|
164
|
+
thread = threading.Thread(target=w.loop, daemon=True)
|
|
165
|
+
thread.start()
|
|
166
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/"
|
|
167
|
+
if ready:
|
|
168
|
+
ready(url, server, w)
|
|
169
|
+
else:
|
|
170
|
+
print(f"system-diff watch: {url} (Ctrl-C to stop)", flush=True)
|
|
171
|
+
try:
|
|
172
|
+
server.serve_forever()
|
|
173
|
+
except KeyboardInterrupt:
|
|
174
|
+
pass
|
|
175
|
+
finally:
|
|
176
|
+
w.stop.set()
|
|
177
|
+
server.server_close()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: system-diff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Draws your system from its code (parts, entry points, data, outside systems) and what each change adds, changes or removes. No dependencies; SVG pictures for PRs and READMEs.
|
|
5
|
+
Project-URL: Homepage, https://github.com/simplifiedmachine/system-diff
|
|
6
|
+
Project-URL: Documentation, https://github.com/simplifiedmachine/system-diff/wiki
|
|
7
|
+
Project-URL: Issues, https://github.com/simplifiedmachine/system-diff/issues
|
|
8
|
+
Author: simplifiedmachine
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: architecture,code-review,code-visualization,developer-tools,diagrams,pull-requests,system-design
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mcp>=1.10; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
24
|
+
Provides-Extra: mcp
|
|
25
|
+
Requires-Dist: mcp>=1.10; extra == 'mcp'
|
|
26
|
+
Provides-Extra: png
|
|
27
|
+
Requires-Dist: playwright>=1.40; extra == 'png'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# system-diff
|
|
31
|
+
|
|
32
|
+
Draws your system from its code (parts, entry points, data, outside systems) and what each change adds, changes or
|
|
33
|
+
removes. No dependencies; SVG pictures for PRs and READMEs.
|
|
34
|
+
|
|
35
|
+
<!-- system-map:begin -->
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
<details><summary>How this map is made</summary>
|
|
39
|
+
|
|
40
|
+
Regenerated by `system-diff` from the code: the parts and their columns from `system-diff.toml`, the connections from imports, API calls and the settings each part reads, the outside systems from the settings a deploy configuration sets, and the tables from the migrations. Parts used everywhere are drawn without the arrows into them, and each part keeps only its strongest connections; `system-diff map` writes all of them to `system.json`.
|
|
41
|
+
|
|
42
|
+
Read by the code but set by no deploy configuration, so not drawn: Headless Chromium (Docker or Playwright) (png).
|
|
43
|
+
|
|
44
|
+
</details>
|
|
45
|
+
<!-- system-map:end -->
|
|
46
|
+
|
|
47
|
+
## What it does
|
|
48
|
+
|
|
49
|
+
- **`map`**: reads a commit straight from git (no checkout) or a folder, and writes `system.json` (everything it
|
|
50
|
+
found) and `system.svg` (the picture): a column per role, from what people use through entry points, logic and data
|
|
51
|
+
access to stored data and outside systems.
|
|
52
|
+
- **`diff`**: the typed difference between two commits, or a commit and your working tree: parts, endpoints,
|
|
53
|
+
connections, outside systems, tables, settings and deployment files added, changed or removed. One sentence
|
|
54
|
+
(`headline.txt`), the exact list (`plan.txt`, like `terraform plan`), and a picture of only what changed, with the
|
|
55
|
+
parts it touches beside it (`diff.svg`). `--narrate` adds a plain-words summary from your own LLM.
|
|
56
|
+
- **`readme`**: keeps a generated system map in your README, between two markers.
|
|
57
|
+
- **`draw`**: hand-made pictures in the same look, from small JSON specs: maps, stages of a process, sequences.
|
|
58
|
+
|
|
59
|
+
Deterministic: the same code gives the same picture, byte for byte, and a part that did not change keeps its place.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
pipx install system-diff
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Python 3.11 or newer, nothing else. `pip install "system-diff[png]"` adds PNG output through Playwright; `system-diff
|
|
68
|
+
png` also uses Docker when it is there.
|
|
69
|
+
|
|
70
|
+
## Use
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
system-diff map --dir . --out docs/diagrams # the system as it is now
|
|
74
|
+
system-diff diff --base origin/main --out out # what this branch does to it
|
|
75
|
+
system-diff diff --base HEAD --head . --out out # what your uncommitted work does to it
|
|
76
|
+
system-diff readme --json docs/diagrams/system.json # refresh the map in README.md
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With no config, every folder of code is a part named by its path. A `system-diff.toml` at the repository root names the
|
|
80
|
+
parts and says what is what:
|
|
81
|
+
|
|
82
|
+
```toml
|
|
83
|
+
tests = ["tests/**"]
|
|
84
|
+
|
|
85
|
+
[code]
|
|
86
|
+
roots = ["app/", "web/src/"]
|
|
87
|
+
|
|
88
|
+
[[components]]
|
|
89
|
+
id = "api"
|
|
90
|
+
label = "API"
|
|
91
|
+
kind = "interface" # ui | interface | logic | data: the column it is drawn in
|
|
92
|
+
paths = ["app/api/**"]
|
|
93
|
+
|
|
94
|
+
[[externals]]
|
|
95
|
+
id = "postgres"
|
|
96
|
+
label = "PostgreSQL"
|
|
97
|
+
shape = "db"
|
|
98
|
+
env = ["DATABASE_URL"] # the settings that reach it
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Every key, and how the code is read: [the wiki](https://github.com/simplifiedmachine/system-diff/wiki).
|
|
102
|
+
|
|
103
|
+
## In pull requests
|
|
104
|
+
|
|
105
|
+
This repository runs system-diff on itself: the map above is regenerated from its own code, CI fails when it is out of
|
|
106
|
+
date, and every pull request gets its system diff as a comment (`.github/workflows/system-diff.yml`, which you can
|
|
107
|
+
copy).
|
|
108
|
+
|
|
109
|
+
## With AI coding agents
|
|
110
|
+
|
|
111
|
+
When an agent changes the code with you, the question is what it did to the system. system-diff answers it as you go:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
pipx install system-diff
|
|
115
|
+
pipx inject system-diff mcp # the MCP server's one dependency
|
|
116
|
+
system-diff setup claude # the hook, a skill and the MCP server, in one step (--scope project, --undo)
|
|
117
|
+
system-diff watch # http://127.0.0.1:8765/ : the system diff since you last marked it seen
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- **After every Claude Code turn**, one line says what changed in the system since your checkpoint ("Adds 1 endpoint
|
|
121
|
+
(DELETE /api/orders/{id}); changes 1 table (public.orders)."), or nothing when nothing structural did.
|
|
122
|
+
- **The live page** redraws as files change. Press **Mark as seen** once you have understood it: the next diff then
|
|
123
|
+
shows only what is new to you. `system-diff checkpoint` does the same from the terminal.
|
|
124
|
+
- **The MCP server** gives the agent `system_diff` (the exact plan to explain from, and the picture), `system_map`,
|
|
125
|
+
`draw` (pictures of processes and sequences in the same look) and `checkpoint`.
|
|
126
|
+
- The working tree is read as a snapshot (untracked files included) without touching your index, HEAD or branches,
|
|
127
|
+
and the checkpoint lives under `refs/system-diff/`, in your clone only.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
system_diff/__init__.py,sha256=QegtiHEdVJe5UC9fwHjqYi5Q8zbvDRbAViIa6MEHz9I,289
|
|
2
|
+
system_diff/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
system_diff/agent.py,sha256=mYN3nlxyx4U5vpNtm0UbSZSMym6Zcea7pbz00YcXKC4,9037
|
|
4
|
+
system_diff/cli.py,sha256=3_VPJg4n_R56oy2OZANu3bPPMhrzlB-QDpHjogwiNcI,10958
|
|
5
|
+
system_diff/compare.py,sha256=hf1VOJPVwC1BK4gUAlv98wX07X44fX2eawRS0piDhDU,4578
|
|
6
|
+
system_diff/config.py,sha256=psPCQJLQDiNQ1FIsl6-PKP77hYu51__fRGJgru4oU1A,5961
|
|
7
|
+
system_diff/diff.py,sha256=gcUDrMjjQbq8uloGYs_8O5RnZ_yqqCDjdsNWmuBcqvQ,9020
|
|
8
|
+
system_diff/extract.py,sha256=yUYkyUwhpmhp3sXBySTjWHuHtxvPUeqnv1MuxCfIOIw,13034
|
|
9
|
+
system_diff/graph.py,sha256=OPrMYiWcth_9Yts08hyzG-u0oT8ubkc4V5T59tsH7is,11086
|
|
10
|
+
system_diff/mcp_server.py,sha256=ISLUqwYehOoGsMjQhcakieQc-1-NywKLP9fav0_XMo4,8954
|
|
11
|
+
system_diff/narrate.py,sha256=wRq2oRPt0TZyEdsd-rQcQY0EpiHaFpyKDM3svqMioCA,7691
|
|
12
|
+
system_diff/pictures.py,sha256=G4a8DmvkOttuW962vcGm6CnsbeOTIN_WLrl6ZK0lQGQ,28479
|
|
13
|
+
system_diff/png.py,sha256=HCxc23roQNxHdcsULmMQnvpqLA1Ejkkc1k9lR_Swa14,5775
|
|
14
|
+
system_diff/readme.py,sha256=3qOXsxgaBE8aIbbuK2YDrRy9J3NjbwdzfR0EB3xqgAY,1823
|
|
15
|
+
system_diff/sources.py,sha256=ZtFE89tv7Cscb3qKO95PjbY0OUZm30hj9IeBxCvutxU,3085
|
|
16
|
+
system_diff/watch.py,sha256=VDW4L1HQ6q0i5BMpwfy9qOGE1PW8YBEe9dSYqCBrTog,8303
|
|
17
|
+
system_diff-0.1.0.dist-info/METADATA,sha256=krlsARSg-d2WIEGUVQAlR6KwWZCTbt1lKlGvcyREN-c,6094
|
|
18
|
+
system_diff-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
19
|
+
system_diff-0.1.0.dist-info/entry_points.txt,sha256=8fw6tkuFfZI2L7ScWUXVxj28LK9p6UiImnnB8E1om1E,84
|
|
20
|
+
system_diff-0.1.0.dist-info/licenses/LICENSE,sha256=QHQ-0t-7_4_K7j92CTlQScHiJiDZ91DcpV6WIOIFYQE,1074
|
|
21
|
+
system_diff-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 simplifiedmachine
|
|
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.
|