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/diff.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""The typed difference between two blueprints, in one sentence (headline) and exactly (plan, `terraform plan`-style)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
8
|
+
|
|
9
|
+
from .extract import Code, matches
|
|
10
|
+
from .sources import git
|
|
11
|
+
|
|
12
|
+
IMPACT_WORDS = {
|
|
13
|
+
"new-component": "adds a part", "removed-component": "removes a part",
|
|
14
|
+
"new-interface": "adds an endpoint", "breaking-interface": "removes an endpoint callers may rely on",
|
|
15
|
+
"new-external": "talks to an outside system it did not before", "removed-external": "stops using an outside system",
|
|
16
|
+
"data-schema-change": "changes stored data", "deployment-change": "changes what is deployed",
|
|
17
|
+
"new-flow": "connects parts that were not connected", "removed-flow": "disconnects parts",
|
|
18
|
+
"new-config": "reads new settings", "test-gap": "changes code without changing tests",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def files_word(n: int) -> str:
|
|
23
|
+
return f"{n} file" + ("" if n == 1 else "s")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_test_path(path: str, code: Optional[Code] = None) -> bool:
|
|
27
|
+
"""A test file: one the config's `tests` globs name, under tests/, or named like `x.test.ts`."""
|
|
28
|
+
return path.startswith("tests/") or ".test." in path or bool(code and code.is_test(path))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def numstat(root: Path, base: str, head: Optional[str], cfg: Dict[str, Any]) -> Dict[str, tuple]:
|
|
32
|
+
"""{path: (lines added, lines removed, owning part or None)} for every file the change touched; `head` None is
|
|
33
|
+
the working tree."""
|
|
34
|
+
code = Code(cfg)
|
|
35
|
+
out = {}
|
|
36
|
+
for line in git("diff", "--numstat", "-M", base, *([head] if head else []), cwd=root).splitlines():
|
|
37
|
+
parts = line.split("\t")
|
|
38
|
+
if len(parts) != 3:
|
|
39
|
+
continue
|
|
40
|
+
plus, minus, path = parts
|
|
41
|
+
path = re.sub(r"\{([^}]*) => ([^}]*)\}", r"\2", path).split(" => ")[-1]
|
|
42
|
+
comp = code.part_of(path) if code.is_code(path) and not is_test_path(path, code) else None
|
|
43
|
+
out[path] = (int(plus) if plus.isdigit() else 0, int(minus) if minus.isdigit() else 0, comp)
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def diff(a: dict, b: dict, numstat: Optional[Dict[str, tuple]] = None, deploy_globs: Iterable[str] = (),
|
|
48
|
+
code: Optional[Code] = None) -> dict:
|
|
49
|
+
changes: List[dict] = []
|
|
50
|
+
|
|
51
|
+
def add(op, entity, key, label="", detail=(), impact=()):
|
|
52
|
+
changes.append({"op": op, "entity": entity, "id": key, "label": label or key,
|
|
53
|
+
"detail": list(detail), "impact": list(impact)})
|
|
54
|
+
|
|
55
|
+
ca, cb = a["components"], b["components"]
|
|
56
|
+
lines = defaultdict(lambda: [0, 0, 0])
|
|
57
|
+
code_changed = tests_changed = False
|
|
58
|
+
for path, (plus, minus, comp) in (numstat or {}).items():
|
|
59
|
+
if comp:
|
|
60
|
+
lines[comp][0] += plus
|
|
61
|
+
lines[comp][1] += minus
|
|
62
|
+
lines[comp][2] += 1
|
|
63
|
+
code_changed = True
|
|
64
|
+
elif is_test_path(path, code):
|
|
65
|
+
tests_changed = True
|
|
66
|
+
for cid in sorted(set(ca) | set(cb)):
|
|
67
|
+
if cid not in ca:
|
|
68
|
+
add("add", "component", cid, cb[cid]["label"], [files_word(cb[cid]["files"])], ["new-component"])
|
|
69
|
+
elif cid not in cb:
|
|
70
|
+
add("remove", "component", cid, ca[cid]["label"], [], ["removed-component"])
|
|
71
|
+
elif cid in lines:
|
|
72
|
+
p, m, n = lines[cid]
|
|
73
|
+
add("change", "component", cid, cb[cid]["label"], [f"+{p} −{m} lines in {files_word(n)}"])
|
|
74
|
+
for kind, impact_add, impact_remove in (("flows", "new-flow", "removed-flow"), ("api", "new-flow", "removed-flow"),
|
|
75
|
+
("internal", "new-flow", "removed-flow")):
|
|
76
|
+
for key in sorted(set(a[kind]) ^ set(b[kind])):
|
|
77
|
+
src, dst = key.split(">")
|
|
78
|
+
label = f"{name_of(b, a, src)} → {name_of(b, a, dst)}"
|
|
79
|
+
add("add" if key in b[kind] else "remove", "flow", key, label, [],
|
|
80
|
+
[impact_add if key in b[kind] else impact_remove])
|
|
81
|
+
for route in sorted(set(a["routes"]) ^ set(b["routes"])):
|
|
82
|
+
if route in b["routes"]:
|
|
83
|
+
add("add", "route", route, route, [name_of(b, a, b["routes"][route])], ["new-interface"])
|
|
84
|
+
else:
|
|
85
|
+
add("remove", "route", route, route, [name_of(b, a, a["routes"][route])], ["breaking-interface"])
|
|
86
|
+
pairs = lambda bp: {(e, u) for e, v in bp["externals"].items() for u in v["used_by"]} # noqa: E731
|
|
87
|
+
for ext, user in sorted(pairs(a) ^ pairs(b)):
|
|
88
|
+
new = (ext, user) in pairs(b)
|
|
89
|
+
meta = (b if new else a)["externals"][ext]
|
|
90
|
+
add("add" if new else "remove", "external", f"{user}>{ext}", f"{name_of(b, a, user)} → {meta['label']}", [],
|
|
91
|
+
["new-external" if new else "removed-external"])
|
|
92
|
+
for name in sorted(set(a["containers"]) | set(b["containers"])):
|
|
93
|
+
x, y = a["containers"].get(name), b["containers"].get(name)
|
|
94
|
+
if x != y:
|
|
95
|
+
op = "add" if x is None else "remove" if y is None else "change"
|
|
96
|
+
detail = [] if op != "change" else [f"{k}: {x.get(k)!r} → {y.get(k)!r}" for k in y if x.get(k) != y.get(k)]
|
|
97
|
+
add(op, "container", name, name, detail, ["deployment-change"])
|
|
98
|
+
for table in sorted(set(a["tables"]) | set(b["tables"])):
|
|
99
|
+
if table not in a["tables"]:
|
|
100
|
+
add("add", "table", table, table, [], ["data-schema-change"])
|
|
101
|
+
elif table not in b["tables"]:
|
|
102
|
+
add("remove", "table", table, table, [], ["data-schema-change"])
|
|
103
|
+
else:
|
|
104
|
+
cols = sorted(set(b["tables"][table]) - set(a["tables"][table]))
|
|
105
|
+
if cols:
|
|
106
|
+
add("change", "table", table, table, [f"+ column {c}" for c in cols], ["data-schema-change"])
|
|
107
|
+
for m in sorted(set(b["migrations"]) - set(a["migrations"])):
|
|
108
|
+
add("add", "migration", m, m, [], ["data-schema-change"])
|
|
109
|
+
for path, (plus, minus, _comp) in sorted((numstat or {}).items()):
|
|
110
|
+
if matches(path, deploy_globs):
|
|
111
|
+
add("change", "deploy", path, path, [f"+{plus} −{minus} lines"], ["deployment-change"])
|
|
112
|
+
for comp in sorted(set(b["env"]) & set(a["env"])):
|
|
113
|
+
new_vars = sorted(set(b["env"][comp]) - set(a["env"][comp]))
|
|
114
|
+
if new_vars:
|
|
115
|
+
add("change", "settings", comp, name_of(b, a, comp), [", ".join(new_vars)], ["new-config"])
|
|
116
|
+
|
|
117
|
+
impacts = Counter(i for c in changes for i in c["impact"])
|
|
118
|
+
if code_changed and not tests_changed:
|
|
119
|
+
impacts["test-gap"] += 1
|
|
120
|
+
return {"from": a["ref"], "to": b["ref"], "changes": changes, "impacts": dict(impacts),
|
|
121
|
+
"headline": headline(changes), "plan": plan(changes, impacts),
|
|
122
|
+
"dormant": sorted(b["dormant"]), "undeclared": sorted(c for c, v in cb.items() if not v["declared"])}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def name_of(b: dict, a: dict, ref: str) -> str:
|
|
126
|
+
for bp in (b, a):
|
|
127
|
+
if ref in bp["components"]:
|
|
128
|
+
return bp["components"][ref]["label"]
|
|
129
|
+
if ref in bp.get("container_labels", {}):
|
|
130
|
+
return bp["container_labels"][ref]
|
|
131
|
+
return ref
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
NOUNS = {"deploy": ("deployment file", "deployment files"), "component": ("part", "parts"), "route": ("endpoint", "endpoints"),
|
|
135
|
+
"external": ("outside connection", "outside connections"), "container": ("container", "containers"),
|
|
136
|
+
"table": ("table", "tables"), "migration": ("migration", "migrations"), "flow": ("connection", "connections"),
|
|
137
|
+
"settings": ("set of settings", "sets of settings")}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def headline(changes: List[dict]) -> str:
|
|
141
|
+
"""One sentence: what the change does to the system's structure."""
|
|
142
|
+
parts = []
|
|
143
|
+
for op, verb in (("add", "adds"), ("change", "changes"), ("remove", "removes")):
|
|
144
|
+
phrases = []
|
|
145
|
+
for entity in ("component", "route", "table", "migration", "external", "container", "deploy", "flow", "settings"):
|
|
146
|
+
items = [c for c in changes if c["op"] == op and c["entity"] == entity]
|
|
147
|
+
if items:
|
|
148
|
+
one, many = NOUNS[entity]
|
|
149
|
+
names = [c["label"] for c in items]
|
|
150
|
+
shown = "" if entity == "flow" else " (" + ", ".join(names[:3]) + (f" +{len(names) - 3} more" if len(names) > 3 else "") + ")"
|
|
151
|
+
phrases.append(f"{len(items)} {one if len(items) == 1 else many}{shown}")
|
|
152
|
+
if phrases:
|
|
153
|
+
parts.append(verb + " " + (", ".join(phrases[:-1]) + " and " + phrases[-1] if len(phrases) > 1 else phrases[0]))
|
|
154
|
+
if not parts:
|
|
155
|
+
return "No change to the system's structure."
|
|
156
|
+
s = "; ".join(parts)
|
|
157
|
+
return s[0].upper() + s[1:] + "."
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def plan(changes: List[dict], impacts: Counter) -> str:
|
|
161
|
+
"""The same diff as `terraform plan`-style text: exact, and readable anywhere."""
|
|
162
|
+
sym = {"add": "+", "change": "~", "remove": "-"}
|
|
163
|
+
out = []
|
|
164
|
+
for c in changes:
|
|
165
|
+
tag = f" [{', '.join(c['impact'])}]" if c["impact"] else ""
|
|
166
|
+
label = f" — {c['label']}" if c["label"] != c["id"] else ""
|
|
167
|
+
out.append(f"{sym[c['op']]} {c['entity']} {c['id']}{label}{tag}")
|
|
168
|
+
out += [f" {x}" for x in c["detail"]]
|
|
169
|
+
if impacts.get("test-gap"):
|
|
170
|
+
out.append("! test-gap: code changed and no test file did")
|
|
171
|
+
return "\n".join(out) or "no change to the system's structure"
|
system_diff/extract.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""The system's structure as one JSON-able dict (a "blueprint"), read from the code of one commit or directory.
|
|
2
|
+
|
|
3
|
+
Observed, not declared: the imports between parts, FastAPI-style routes, the API paths the front end calls, the settings
|
|
4
|
+
each part reads (which name the outside systems it talks to), the deployed containers (a compose file) and the tables the
|
|
5
|
+
migrations create. Declared in the config: what the parts are called, which column each belongs in, and which settings
|
|
6
|
+
mean which outside system. An outside system is drawn only when some deploy configuration sets one of its settings;
|
|
7
|
+
read by the code but configured nowhere, it is DORMANT: listed, not drawn.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from collections import Counter, defaultdict
|
|
14
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
15
|
+
|
|
16
|
+
from .config import segment_rx
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def glob_rx(pattern: str) -> re.Pattern:
|
|
20
|
+
"""* stays within one directory, ** crosses them, ? is one character."""
|
|
21
|
+
out, i = "", 0
|
|
22
|
+
while i < len(pattern):
|
|
23
|
+
if pattern.startswith("**/", i):
|
|
24
|
+
out, i = out + "(?:.*/)?", i + 3
|
|
25
|
+
elif pattern.startswith("**", i):
|
|
26
|
+
out, i = out + ".*", i + 2
|
|
27
|
+
elif pattern[i] == "*":
|
|
28
|
+
out, i = out + "[^/]*", i + 1
|
|
29
|
+
elif pattern[i] == "?":
|
|
30
|
+
out, i = out + "[^/]", i + 1
|
|
31
|
+
else:
|
|
32
|
+
out, i = out + re.escape(pattern[i]), i + 1
|
|
33
|
+
return re.compile(out + r"\Z")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def matches(path: str, patterns: Iterable[str]) -> bool:
|
|
37
|
+
return any(glob_rx(p).match(path) for p in patterns)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PY_FROM = re.compile(r"^[ \t]*from[ \t]+(\.*)([\w.]*)[ \t]+import[ \t]+\(?([^)#\n]+)", re.M)
|
|
41
|
+
PY_IMPORT = re.compile(r"^[ \t]*import[ \t]+([\w.]+(?:[ \t]*,[ \t]*[\w.]+)*)", re.M)
|
|
42
|
+
TS_IMPORT = re.compile(r"""(?:from\s+|import\s*\(\s*|require\(\s*)["']([^"']+)["']""")
|
|
43
|
+
ROUTE = re.compile(r"""^[ \t]*@(?:router|app)\.(get|post|put|patch|delete)\(\s*[rfb]?["']([^"']*)["']""", re.M)
|
|
44
|
+
PREFIX = re.compile(r"""APIRouter\([^)]*prefix\s*=\s*["']([^"']+)["']""")
|
|
45
|
+
ENV_PY = re.compile(r"""(?:getenv|environ\.get|environ\.setdefault)\(\s*["']([A-Z][A-Z0-9_]+)["']|environ\[\s*["']([A-Z][A-Z0-9_]+)["']\s*\]""")
|
|
46
|
+
ENV_TS = re.compile(r"(?:import\.meta\.env|process\.env)\.([A-Z][A-Z0-9_]+)")
|
|
47
|
+
API_STR = re.compile(r"""["'`](/api/[^"'`\s]*)""")
|
|
48
|
+
CREATE_TABLE = re.compile(r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w.\"]+)", re.I)
|
|
49
|
+
ADD_COLUMN = re.compile(r"ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?([\w.\"]+)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?\"?(\w+)", re.I)
|
|
50
|
+
SQL_COMMENT = re.compile(r"--[^\n]*|/\*.*?\*/", re.S)
|
|
51
|
+
CONFIG_KEY = re.compile(r"^\s*(?:-\s*)?\"?(?:export\s+)?([A-Z][A-Z0-9_]+)\s*[=:]", re.M)
|
|
52
|
+
TS_EXT = (".ts", ".tsx", ".js", ".jsx")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def py_targets(path: str, text: str, files: set, python_paths: Iterable[str] = ("",)) -> List[str]:
|
|
56
|
+
"""Files a Python file imports, among `files`; imports of anything else (the standard library,
|
|
57
|
+
third-party packages) resolve to nothing and are ignored."""
|
|
58
|
+
package = path.split("/")[:-1]
|
|
59
|
+
|
|
60
|
+
def module_file(dotted: str) -> Optional[str]:
|
|
61
|
+
stem = dotted.replace(".", "/")
|
|
62
|
+
return next((c for base in python_paths for c in (base + stem + ".py", base + stem + "/__init__.py")
|
|
63
|
+
if c in files), None)
|
|
64
|
+
|
|
65
|
+
def relative_file(dotted: str) -> Optional[str]:
|
|
66
|
+
stem = dotted.replace(".", "/")
|
|
67
|
+
return next((c for c in (stem + ".py", stem + "/__init__.py") if c in files), None)
|
|
68
|
+
|
|
69
|
+
def nearest(dotted: str, find=module_file) -> Optional[str]: # `import a.b.c` where only a/b.py exists
|
|
70
|
+
parts = dotted.split(".")
|
|
71
|
+
while parts:
|
|
72
|
+
hit = find(".".join(parts))
|
|
73
|
+
if hit:
|
|
74
|
+
return hit
|
|
75
|
+
parts.pop()
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
found: List[Optional[str]] = []
|
|
79
|
+
for dots, mod, names in PY_FROM.findall(text):
|
|
80
|
+
base = package[: len(package) - (len(dots) - 1)] if dots else []
|
|
81
|
+
stem = ".".join(base + ([mod] if mod else []))
|
|
82
|
+
find = relative_file if dots else module_file
|
|
83
|
+
modules = [find(f"{stem}.{n}") for n in
|
|
84
|
+
(x.strip().split(" as ")[0].strip() for x in names.split(",")) if n and n != "*"]
|
|
85
|
+
modules = [m for m in modules if m]
|
|
86
|
+
found += modules or [nearest(stem, find)] # `from x import name`: x itself, unless the names are modules
|
|
87
|
+
for mods in PY_IMPORT.findall(text):
|
|
88
|
+
found += [nearest(m.strip()) for m in mods.split(",")]
|
|
89
|
+
return [f for f in found if f]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ts_targets(path: str, text: str, files: set, aliases: Optional[Dict[str, str]] = None) -> List[str]:
|
|
93
|
+
base = path.rsplit("/", 1)[0]
|
|
94
|
+
out = []
|
|
95
|
+
for spec in TS_IMPORT.findall(text):
|
|
96
|
+
alias = next((a for a in (aliases or {}) if spec.startswith(a)), None)
|
|
97
|
+
if alias:
|
|
98
|
+
stem = aliases[alias] + spec[len(alias):]
|
|
99
|
+
elif spec.startswith("."):
|
|
100
|
+
stem = os.path.normpath(os.path.join(base, spec)).replace(os.sep, "/")
|
|
101
|
+
else:
|
|
102
|
+
continue
|
|
103
|
+
for cand in (stem, *(stem + e for e in TS_EXT), *(stem + "/index" + e for e in TS_EXT)):
|
|
104
|
+
if cand in files:
|
|
105
|
+
out.append(cand)
|
|
106
|
+
break
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def compose_services(text: str) -> Dict[str, dict]:
|
|
111
|
+
"""The services of a compose file: just enough YAML to see what changed in the deployment."""
|
|
112
|
+
services: Dict[str, dict] = {}
|
|
113
|
+
current, in_services, key = None, False, None
|
|
114
|
+
for line in text.splitlines():
|
|
115
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
116
|
+
continue
|
|
117
|
+
if re.match(r"^\S", line):
|
|
118
|
+
in_services, current = line.startswith("services:"), None
|
|
119
|
+
continue
|
|
120
|
+
if not in_services:
|
|
121
|
+
continue
|
|
122
|
+
m = re.match(r"^ ([A-Za-z0-9_.-]+):\s*$", line)
|
|
123
|
+
if m:
|
|
124
|
+
current = services.setdefault(m.group(1), {"image": "", "command": "", "ports": [], "replicas": ""})
|
|
125
|
+
continue
|
|
126
|
+
if current is None:
|
|
127
|
+
continue
|
|
128
|
+
m = re.match(r"^\s{4}(image|command|ports):\s*(.*)$", line)
|
|
129
|
+
if m:
|
|
130
|
+
key = m.group(1)
|
|
131
|
+
if key != "ports" and m.group(2):
|
|
132
|
+
current[key] = m.group(2).strip().strip("\"'")
|
|
133
|
+
continue
|
|
134
|
+
m = re.match(r"^\s+replicas:\s*(\S+)", line)
|
|
135
|
+
if m:
|
|
136
|
+
current["replicas"] = m.group(1)
|
|
137
|
+
continue
|
|
138
|
+
m = re.match(r"^\s{4,}-\s*\"?([^\"#]+?)\"?\s*$", line)
|
|
139
|
+
if m and key == "ports":
|
|
140
|
+
current["ports"].append(m.group(1))
|
|
141
|
+
elif re.match(r"^\s{4}\w", line):
|
|
142
|
+
key = None
|
|
143
|
+
return services
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Code:
|
|
147
|
+
"""Which files are code, which are tests, and which part owns each: one answer for the map and the diff."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, cfg: Dict[str, Any]):
|
|
150
|
+
c = cfg["code"]
|
|
151
|
+
self.skip = segment_rx(c["skip"])
|
|
152
|
+
self.roots = tuple(c["roots"])
|
|
153
|
+
self.ext = tuple(c["extensions"])
|
|
154
|
+
self.group = c["group"]
|
|
155
|
+
self.strip = tuple(c["strip"] if c.get("strip") is not None else c["extensions"])
|
|
156
|
+
self.tests = [glob_rx(p) for p in cfg.get("tests", [])]
|
|
157
|
+
self.declared = [(d, [glob_rx(p) for p in d.get("paths", [])]) for d in cfg.get("components", [])]
|
|
158
|
+
|
|
159
|
+
def is_code(self, path: str) -> bool:
|
|
160
|
+
return path.endswith(self.ext) and path.startswith(self.roots) and not self.skip.search(path)
|
|
161
|
+
|
|
162
|
+
def is_test(self, path: str) -> bool:
|
|
163
|
+
return any(r.match(path) for r in self.tests)
|
|
164
|
+
|
|
165
|
+
def part_of(self, path: str) -> str:
|
|
166
|
+
"""The declared part claiming `path`; else one named by its directory."""
|
|
167
|
+
hit = next((d["id"] for d, rs in self.declared if any(r.match(path) for r in rs)), None)
|
|
168
|
+
if hit is not None:
|
|
169
|
+
return hit
|
|
170
|
+
parts = path.split("/")
|
|
171
|
+
name = "/".join(parts[: int(self.group.get(parts[0], self.group.get("default", 2)))])
|
|
172
|
+
return next((name[: -len(s)] for s in self.strip if name.endswith(s)), name)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def extract(source, cfg: Dict[str, Any]) -> dict:
|
|
176
|
+
"""The blueprint of one source."""
|
|
177
|
+
code_of = Code(cfg)
|
|
178
|
+
comps = cfg.get("components", [])
|
|
179
|
+
files = [f for f in source.files if not code_of.skip.search(f)]
|
|
180
|
+
fileset = set(files)
|
|
181
|
+
code = [f for f in files if code_of.is_code(f) and not code_of.is_test(f)]
|
|
182
|
+
aliases, python_paths = cfg["code"]["aliases"], cfg["code"]["python_paths"]
|
|
183
|
+
|
|
184
|
+
owner: Dict[str, str] = {}
|
|
185
|
+
components: Dict[str, dict] = {}
|
|
186
|
+
for f in code:
|
|
187
|
+
cid = code_of.part_of(f)
|
|
188
|
+
owner[f] = cid
|
|
189
|
+
meta = next((c for c in comps if c["id"] == cid), None)
|
|
190
|
+
entry = components.setdefault(cid, {
|
|
191
|
+
"label": (meta or {}).get("label", cid), "layer": meta.get("layer", "") if meta else "undeclared",
|
|
192
|
+
"kind": (meta or {}).get("kind", ""), "order": comps.index(meta) if meta else len(comps),
|
|
193
|
+
"container": (meta or {}).get("container", ""), "declared": meta is not None, "files": 0, "lines": 0,
|
|
194
|
+
"hub": bool((meta or {}).get("hub")), "quiet": bool((meta or {}).get("quiet"))})
|
|
195
|
+
entry["files"] += 1
|
|
196
|
+
|
|
197
|
+
texts = source.read_many(code)
|
|
198
|
+
flows, api, env_by_comp, routes = Counter(), Counter(), defaultdict(Counter), {}
|
|
199
|
+
for f in code:
|
|
200
|
+
text, cid = texts[f], owner[f]
|
|
201
|
+
components[cid]["lines"] += text.count("\n")
|
|
202
|
+
python = f.endswith(".py")
|
|
203
|
+
targets = py_targets(f, text, fileset, python_paths) if python else ts_targets(f, text, fileset, aliases)
|
|
204
|
+
for t in targets:
|
|
205
|
+
if t in owner and owner[t] != cid:
|
|
206
|
+
flows[f"{cid}>{owner[t]}"] += 1
|
|
207
|
+
if python:
|
|
208
|
+
prefix = (PREFIX.search(text) or [None, ""])[1]
|
|
209
|
+
for method, path in ROUTE.findall(text):
|
|
210
|
+
routes[f"{method.upper()} {prefix}{path}"] = cid
|
|
211
|
+
env_by_comp[cid].update(a or b for a, b in ENV_PY.findall(text))
|
|
212
|
+
else:
|
|
213
|
+
env_by_comp[cid].update(ENV_TS.findall(text))
|
|
214
|
+
for s in API_STR.findall(text):
|
|
215
|
+
target = next((a["component"] for a in cfg.get("api", []) if s.startswith(a["prefix"])), None)
|
|
216
|
+
if target and target != cid:
|
|
217
|
+
api[f"{cid}>{target}"] += 1
|
|
218
|
+
|
|
219
|
+
config_rx = [glob_rx(p) for p in cfg.get("config_files", [])]
|
|
220
|
+
configured = set()
|
|
221
|
+
for f in files:
|
|
222
|
+
if any(r.match(f) for r in config_rx):
|
|
223
|
+
configured |= set(CONFIG_KEY.findall(source.read(f)))
|
|
224
|
+
externals, dormant = {}, {}
|
|
225
|
+
for ext in cfg.get("externals", []):
|
|
226
|
+
reads = {c: sum(n for v, n in vs.items() if matches(v, ext["env"])) for c, vs in env_by_comp.items()}
|
|
227
|
+
users = sorted((c for c, n in reads.items() if n), key=lambda c: (-reads[c], c)) # heaviest reader first
|
|
228
|
+
if not users:
|
|
229
|
+
continue
|
|
230
|
+
live = any(matches(v, ext["env"]) for v in configured)
|
|
231
|
+
(externals if live else dormant)[ext["id"]] = {"label": ext["label"], "shape": ext.get("shape", ""), "used_by": users}
|
|
232
|
+
internal = Counter()
|
|
233
|
+
for var, container in cfg.get("internal_env", {}).items():
|
|
234
|
+
for c, vs in env_by_comp.items():
|
|
235
|
+
if var in vs:
|
|
236
|
+
internal[f"{c}>{container}"] += 1
|
|
237
|
+
|
|
238
|
+
compose = cfg.get("compose", "")
|
|
239
|
+
containers = compose_services(source.read(compose)) if compose in fileset else {}
|
|
240
|
+
|
|
241
|
+
tables, migrations = defaultdict(set), []
|
|
242
|
+
mig = cfg.get("migrations", {})
|
|
243
|
+
mig_files = sorted(f for f in files if mig and glob_rx(mig.get("glob", "")).match(f))
|
|
244
|
+
mig_texts = source.read_many(mig_files)
|
|
245
|
+
for f in mig_files:
|
|
246
|
+
name = f.rsplit("/", 1)[1]
|
|
247
|
+
migrations.append(name)
|
|
248
|
+
num = int(re.match(r"\d+", name).group(0)) if re.match(r"\d+", name) else 0
|
|
249
|
+
schema = next((s for lo, hi, s in mig.get("schemas", []) if lo <= num <= hi), "public")
|
|
250
|
+
text = SQL_COMMENT.sub("", mig_texts[f]) # "CREATE TABLE IF NOT EXISTS only." in a comment is not a table
|
|
251
|
+
for table in CREATE_TABLE.findall(text):
|
|
252
|
+
tables[qualify(table, schema)] |= set()
|
|
253
|
+
for table, col in ADD_COLUMN.findall(text):
|
|
254
|
+
tables[qualify(table, schema)].add(col)
|
|
255
|
+
|
|
256
|
+
test_files = Counter(("/".join(f.split("/")[:2]) if f.startswith("tests/") else f.split("/")[0])
|
|
257
|
+
for f in files if code_of.is_test(f) and f.endswith(code_of.ext))
|
|
258
|
+
return {
|
|
259
|
+
"ref": source.label,
|
|
260
|
+
"components": components,
|
|
261
|
+
"flows": dict(sorted(flows.items())),
|
|
262
|
+
"api": dict(sorted(api.items())),
|
|
263
|
+
"routes": dict(sorted(routes.items())),
|
|
264
|
+
"externals": externals,
|
|
265
|
+
"dormant": dormant,
|
|
266
|
+
"internal": dict(sorted(internal.items())),
|
|
267
|
+
"env": {c: sorted(v) for c, v in sorted(env_by_comp.items()) if v},
|
|
268
|
+
"containers": containers,
|
|
269
|
+
"container_labels": cfg.get("containers", {}),
|
|
270
|
+
"tables": {t: sorted(c) for t, c in sorted(tables.items())},
|
|
271
|
+
"store": mig.get("store", "postgres"), # the outside system the migrations' tables live in
|
|
272
|
+
"migrations": migrations,
|
|
273
|
+
"tests": dict(sorted(test_files.items())),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def qualify(name: str, schema: str) -> str:
|
|
278
|
+
name = name.replace('"', "")
|
|
279
|
+
return name if "." in name else f"{schema}.{name}"
|
system_diff/graph.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""From a blueprint (or a diff) to the graph a picture draws: a column per role, each part where the config declares it,
|
|
2
|
+
so a part that did not change keeps its place from one version to the next. Drawn by `pictures.map_svg`."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from . import pictures
|
|
9
|
+
from .diff import files_word
|
|
10
|
+
|
|
11
|
+
PART_KINDS = ("ui", "interface", "logic", "data")
|
|
12
|
+
LAYER_RANK = {"new": 0, "shared": 1, "legacy": 2}
|
|
13
|
+
def drawn_flows(bp: dict, cfg: Dict[str, Any]) -> List[tuple]:
|
|
14
|
+
"""The connections worth drawing: not into a hub, not from or to a quiet part, not between two parts of the
|
|
15
|
+
front end (that is page composition, not architecture), strong enough (`min_flow` import sites), and each part's
|
|
16
|
+
strongest few (`top_flows`). Weaker links stay in system.json and out of the picture."""
|
|
17
|
+
comps = bp["components"]
|
|
18
|
+
ui = set(cfg["picture"]["ui_containers"])
|
|
19
|
+
min_flow, top_flows = cfg["picture"]["min_flow"], cfg["picture"]["top_flows"]
|
|
20
|
+
outgoing = defaultdict(list)
|
|
21
|
+
for key, n in bp["flows"].items():
|
|
22
|
+
src, dst = key.split(">")
|
|
23
|
+
a, b = comps.get(src, {}), comps.get(dst, {})
|
|
24
|
+
if n < min_flow or b.get("hub") or a.get("quiet") or b.get("quiet"):
|
|
25
|
+
continue
|
|
26
|
+
if a.get("container") in ui and b.get("container") in ui:
|
|
27
|
+
continue
|
|
28
|
+
outgoing[src].append((-n, dst))
|
|
29
|
+
return [(src, dst) for src in sorted(outgoing) for _, dst in sorted(outgoing[src])[:top_flows]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def drawn_api(bp: dict) -> List[tuple]:
|
|
33
|
+
"""Each front-end part's main API target, plus any other it calls often."""
|
|
34
|
+
by_src = defaultdict(list)
|
|
35
|
+
for key, n in bp["api"].items():
|
|
36
|
+
src, dst = key.split(">")
|
|
37
|
+
by_src[src].append((-n, dst))
|
|
38
|
+
out = []
|
|
39
|
+
for src in sorted(by_src):
|
|
40
|
+
ranked = sorted(by_src[src])
|
|
41
|
+
out += [(src, dst) for i, (neg, dst) in enumerate(ranked) if i == 0 or -neg >= 10]
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def part_kind(cid: str, c: dict, bp: dict, cfg: Dict[str, Any]) -> str:
|
|
46
|
+
"""The column: declared in the config, else the front end is what people use and a part with routes an entry point."""
|
|
47
|
+
if c.get("kind") in PART_KINDS:
|
|
48
|
+
return c["kind"]
|
|
49
|
+
if c.get("container") and c.get("container") in cfg["picture"]["ui_containers"]:
|
|
50
|
+
return "ui"
|
|
51
|
+
return "interface" if cid in set(bp["routes"].values()) else "logic"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def route_lines(bp: dict, cid: str) -> List[dict]:
|
|
55
|
+
"""A part's endpoints grouped by their first three path segments, the largest group first."""
|
|
56
|
+
groups: Counter = Counter()
|
|
57
|
+
for route, owner in bp["routes"].items():
|
|
58
|
+
if owner == cid:
|
|
59
|
+
segments = [s for s in route.split(" ", 1)[1].split("/") if s][:3]
|
|
60
|
+
groups["/" + "/".join(segments)] += 1
|
|
61
|
+
return [{"text": f"{g} ({n})", "code": True} for g, n in sorted(groups.items(), key=lambda kv: (-kv[1], kv[0]))]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def part_order(c: dict, cid: str) -> tuple:
|
|
65
|
+
return LAYER_RANK.get(c.get("layer"), 3), c.get("order", 10 ** 6), cid
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def part_node(cid: str, c: dict, bp: dict, cfg: Dict[str, Any]) -> dict:
|
|
69
|
+
sub = cfg["picture"]["hub_note"] if c.get("hub") else files_word(c["files"])
|
|
70
|
+
container = c.get("container", "")
|
|
71
|
+
if container and container not in cfg["picture"]["main_containers"]:
|
|
72
|
+
sub = f"{bp.get('container_labels', {}).get(container, container)} · {sub}"
|
|
73
|
+
kind = part_kind(cid, c, bp, cfg)
|
|
74
|
+
return {"id": cid, "col": kind, "title": c["label"], "sub": sub, "type": "component", "style": c.get("layer", ""),
|
|
75
|
+
"lines": route_lines(bp, cid) if kind == "interface" else []}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def outside_node(ext_id: str, e: dict, bp: dict) -> dict:
|
|
79
|
+
n = len(e["used_by"])
|
|
80
|
+
db = e.get("shape") == "db"
|
|
81
|
+
node = {"id": "x:" + ext_id, "col": "store" if db else "external", "type": "store" if db else "host",
|
|
82
|
+
"title": e["label"], "sub": f"used by {n} part{'' if n == 1 else 's'}", "lines": []}
|
|
83
|
+
if ext_id == bp.get("store", "postgres") and bp["tables"]:
|
|
84
|
+
schemas = Counter(t.split(".", 1)[0] for t in bp["tables"])
|
|
85
|
+
node["sub"] = f"{len(bp['tables'])} tables · {node['sub']}"
|
|
86
|
+
node["lines"] = [{"text": f"{s}: {k} table{'' if k == 1 else 's'}"} for s, k in schemas.most_common()]
|
|
87
|
+
return node
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def edges(bp: dict, cfg: Dict[str, Any]) -> List[dict]:
|
|
91
|
+
"""The connections a picture draws: each part's strongest calls, the API, and the heaviest users of each outside
|
|
92
|
+
system (`max_ext_edges`; the node counts the rest, and system.json lists them)."""
|
|
93
|
+
comps = bp["components"]
|
|
94
|
+
out_edges = [{"from": s, "to": t, "kind": "call"} for s, t in drawn_flows(bp, cfg) + drawn_api(bp)]
|
|
95
|
+
for key in sorted(bp["internal"]):
|
|
96
|
+
src, container = key.split(">")
|
|
97
|
+
out_edges += [{"from": src, "to": cid, "kind": "call"} for cid, c in sorted(comps.items())
|
|
98
|
+
if c.get("container") == container and not c.get("quiet")]
|
|
99
|
+
for ext_id, e in bp["externals"].items():
|
|
100
|
+
users = [u for u in e["used_by"] if not comps.get(u, {}).get("quiet")]
|
|
101
|
+
kind = "data" if e.get("shape") == "db" else "network"
|
|
102
|
+
out_edges += [{"from": u, "to": "x:" + ext_id, "kind": kind} for u in users[: cfg["picture"]["max_ext_edges"]]]
|
|
103
|
+
seen, out = set(), []
|
|
104
|
+
for e in out_edges:
|
|
105
|
+
if (e["from"], e["to"]) not in seen:
|
|
106
|
+
seen.add((e["from"], e["to"]))
|
|
107
|
+
out.append(e)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def map_legend(nodes: List[dict], drawn: List[dict], cfg: Dict[str, Any]) -> List[tuple]:
|
|
112
|
+
"""The legend of a system map: a mark for each kind of part the map has, then each kind of line it draws."""
|
|
113
|
+
words = cfg["picture"]["legend"]
|
|
114
|
+
styles = {n.get("style") for n in nodes}
|
|
115
|
+
kinds = {e["kind"] for e in drawn}
|
|
116
|
+
marks = [("band", "new", words.get("new", "new")), ("dashed", "", words.get("legacy", "legacy")),
|
|
117
|
+
("band", "undeclared", words.get("undeclared", "undeclared"))]
|
|
118
|
+
present = [m for m, s in zip(marks, ("new", "legacy", "undeclared")) if s in styles]
|
|
119
|
+
return present + [m for m in pictures.MAP_LEGEND if m[0] == "line" and m[1] in kinds]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def system_graph(bp: dict, cfg: Dict[str, Any]) -> dict:
|
|
123
|
+
comps = bp["components"]
|
|
124
|
+
nodes = [part_node(cid, comps[cid], bp, cfg) for cid in sorted(comps, key=lambda k: part_order(comps[k], k))]
|
|
125
|
+
nodes += [outside_node(x, e, bp) for x, e in bp["externals"].items()]
|
|
126
|
+
drawn = edges(bp, cfg)
|
|
127
|
+
return {"columns": cfg["picture"]["columns"], "nodes": nodes, "edges": drawn, "legend": map_legend(nodes, drawn, cfg)}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def system_svg(bp: dict, cfg: Dict[str, Any]) -> str:
|
|
131
|
+
return pictures.map_svg(system_graph(bp, cfg), label="System map")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def diff_graph(d: dict, a: dict, b: dict, cfg: Dict[str, Any]) -> Optional[dict]:
|
|
135
|
+
"""Only what changed, lit, with the parts it touches beside it for context; None when nothing structural changed."""
|
|
136
|
+
status: Dict[str, str] = {}
|
|
137
|
+
lines: Dict[str, List[dict]] = defaultdict(list)
|
|
138
|
+
subs: Dict[str, str] = {}
|
|
139
|
+
changed: Dict[tuple, tuple] = {} # (from, to) -> (kind, status)
|
|
140
|
+
comps = {**a["components"], **b["components"]}
|
|
141
|
+
containers = set(a.get("container_labels", {})) | set(b.get("container_labels", {}))
|
|
142
|
+
store = "x:" + b.get("store", "postgres")
|
|
143
|
+
for c in d["changes"]:
|
|
144
|
+
op, entity = {"add": "added", "remove": "removed", "change": "changed"}[c["op"]], c["entity"]
|
|
145
|
+
if entity == "component":
|
|
146
|
+
status[c["id"]] = op
|
|
147
|
+
if c["detail"]:
|
|
148
|
+
subs[c["id"]] = c["detail"][0]
|
|
149
|
+
elif entity == "route":
|
|
150
|
+
owner = b["routes"].get(c["id"]) or a["routes"].get(c["id"])
|
|
151
|
+
lines[owner].append({"text": c["id"], "code": True, "status": op})
|
|
152
|
+
status.setdefault(owner, "changed")
|
|
153
|
+
elif entity == "settings":
|
|
154
|
+
lines[c["id"]].append({"text": "reads " + c["detail"][0], "code": True, "status": "added"})
|
|
155
|
+
status.setdefault(c["id"], "changed")
|
|
156
|
+
elif entity == "flow":
|
|
157
|
+
src, dst = c["id"].split(">")
|
|
158
|
+
targets = [k for k, v in sorted(comps.items()) if v.get("container") == dst] if dst in containers else [dst]
|
|
159
|
+
for t in targets:
|
|
160
|
+
changed[(src, t)] = ("call", op)
|
|
161
|
+
elif entity == "external":
|
|
162
|
+
user, ext = c["id"].split(">")
|
|
163
|
+
meta = b["externals"].get(ext) or a["externals"].get(ext) or {}
|
|
164
|
+
changed[(user, "x:" + ext)] = ("data" if meta.get("shape") == "db" else "network", op)
|
|
165
|
+
elif entity in ("table", "migration"):
|
|
166
|
+
detail = ", ".join(x.replace("+ column ", "+ ") for x in c["detail"])
|
|
167
|
+
lines[store].append({"text": c["id"] + (f": {detail}" if detail else ""), "code": True, "status": op})
|
|
168
|
+
status[store] = "changed"
|
|
169
|
+
for ext in set(a["externals"]) ^ set(b["externals"]):
|
|
170
|
+
status["x:" + ext] = "added" if ext in b["externals"] else "removed"
|
|
171
|
+
hot = set(status) | {end for pair in changed for end in pair}
|
|
172
|
+
if not hot:
|
|
173
|
+
return None
|
|
174
|
+
context = [e for e in edges(b, cfg) if (e["from"], e["to"]) not in changed]
|
|
175
|
+
context += [e for e in edges(a, cfg) if status.get(e["from"]) == "removed" or status.get(e["to"]) == "removed"]
|
|
176
|
+
shown = set(hot)
|
|
177
|
+
for e in context:
|
|
178
|
+
if e["from"] in hot or e["to"] in hot:
|
|
179
|
+
shown |= {e["from"], e["to"]}
|
|
180
|
+
nodes = []
|
|
181
|
+
for cid in sorted(comps, key=lambda k: part_order(comps[k], k)):
|
|
182
|
+
if cid in shown:
|
|
183
|
+
n = part_node(cid, comps[cid], b if cid in b["components"] else a, cfg)
|
|
184
|
+
n.update(status=status.get(cid, "same"), lines=lines.get(cid, []), sub=subs.get(cid, n["sub"]))
|
|
185
|
+
nodes.append(n)
|
|
186
|
+
outside = {**{x: (e, a) for x, e in a["externals"].items()}, **{x: (e, b) for x, e in b["externals"].items()}}
|
|
187
|
+
for ext, (e, side) in outside.items():
|
|
188
|
+
if "x:" + ext in shown:
|
|
189
|
+
n = outside_node(ext, e, side)
|
|
190
|
+
n.update(status=status.get(n["id"], "same"), lines=lines.get(n["id"], []))
|
|
191
|
+
nodes.append(n)
|
|
192
|
+
if store in shown and store not in {n["id"] for n in nodes}:
|
|
193
|
+
nodes.append({"id": store, "col": "store", "type": "store", "title": "database schema", "sub": "",
|
|
194
|
+
"status": "changed", "lines": lines[store]})
|
|
195
|
+
out_edges = [{"from": s, "to": t, "kind": k, "status": st} for (s, t), (k, st) in changed.items()]
|
|
196
|
+
seen = set(changed)
|
|
197
|
+
for e in context:
|
|
198
|
+
if (e["from"], e["to"]) not in seen and e["from"] in shown and e["to"] in shown:
|
|
199
|
+
seen.add((e["from"], e["to"]))
|
|
200
|
+
out_edges.append({**e, "status": "same"})
|
|
201
|
+
deploy = [f"the {c['id']} container" if c["entity"] == "container" else c["id"]
|
|
202
|
+
for c in d["changes"] if c["entity"] in ("deploy", "container")]
|
|
203
|
+
return {"columns": cfg["picture"]["columns"], "nodes": nodes, "edges": out_edges,
|
|
204
|
+
"footer": ["Also changed, not drawn: " + ", ".join(deploy)] if deploy else []}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def diff_svg(d: dict, a: dict, b: dict, cfg: Dict[str, Any]) -> Optional[str]:
|
|
208
|
+
graph = diff_graph(d, a, b, cfg)
|
|
209
|
+
return pictures.map_svg(graph, diff=True, label="System diff") if graph else None
|