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/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