system-diff 0.2.0__py3-none-any.whl → 0.3.4__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 +1 -1
- system_diff/activity.py +17 -5
- system_diff/agent.py +21 -2
- system_diff/architecture.py +324 -0
- system_diff/canvas.py +521 -0
- system_diff/cli.py +12 -1
- system_diff/compare.py +3 -3
- system_diff/config.py +20 -8
- system_diff/design.py +486 -0
- system_diff/extract.py +793 -52
- system_diff/inside.py +209 -0
- system_diff/pane.py +383 -0
- system_diff/panel.py +5 -8
- system_diff/profile.py +41 -0
- system_diff/topics.py +31 -0
- {system_diff-0.2.0.dist-info → system_diff-0.3.4.dist-info}/METADATA +60 -32
- system_diff-0.3.4.dist-info/RECORD +35 -0
- system_diff-0.2.0.dist-info/RECORD +0 -29
- {system_diff-0.2.0.dist-info → system_diff-0.3.4.dist-info}/WHEEL +0 -0
- {system_diff-0.2.0.dist-info → system_diff-0.3.4.dist-info}/entry_points.txt +0 -0
- {system_diff-0.2.0.dist-info → system_diff-0.3.4.dist-info}/licenses/LICENSE +0 -0
system_diff/__init__.py
CHANGED
system_diff/activity.py
CHANGED
|
@@ -25,6 +25,15 @@ MAX_BYTES, KEEP = 256 * 1024, 400 # past MAX_BYTES, only the newest KEEP event
|
|
|
25
25
|
EDIT, READ, PROMPT, STOP = "edit", "read", "prompt", "stop"
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
SYSTEM_PROMPTS = ("<task-notification", "<command-name", "<command-message", "<local-command", "<system-reminder",
|
|
29
|
+
"<bash-input", "<bash-stdout", "<user-memory-input")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_system(text: str) -> bool:
|
|
33
|
+
"""A prompt the person did not type: a background task's notice, a slash command's echo, a reminder."""
|
|
34
|
+
return (text or "").lstrip().startswith(SYSTEM_PROMPTS)
|
|
35
|
+
|
|
36
|
+
|
|
28
37
|
def record(root: Path, event: Dict[str, Any]) -> None:
|
|
29
38
|
"""Add one event, e.g. {"event": "edit", "path": "app/api/routes.py"}, stamped with the time."""
|
|
30
39
|
path = git_dir(root) / LOG
|
|
@@ -75,7 +84,7 @@ def now(evs: Iterable[dict], code: Code) -> Dict[str, Any]:
|
|
|
75
84
|
touched = []
|
|
76
85
|
for e in evs:
|
|
77
86
|
kind = e.get("event")
|
|
78
|
-
if kind == PROMPT:
|
|
87
|
+
if kind == PROMPT and not is_system(e.get("text", "")):
|
|
79
88
|
start, out["prompt"], out["working"] = e["at"], e.get("text", ""), True
|
|
80
89
|
elif kind == STOP:
|
|
81
90
|
out["said"], out["working"] = e.get("text", ""), False
|
|
@@ -107,7 +116,7 @@ def feed(evs: Iterable[dict], code: Code, cfg: Dict[str, Any], most: int = 14) -
|
|
|
107
116
|
out: List[Dict[str, Any]] = []
|
|
108
117
|
for e in sorted(evs, key=lambda e: e["at"]):
|
|
109
118
|
kind, path = e.get("event"), e.get("path", "")
|
|
110
|
-
if kind == PROMPT:
|
|
119
|
+
if kind == PROMPT and not is_system(e.get("text", "")):
|
|
111
120
|
out.append({"at": e["at"], "kind": kind, "text": f"You asked: {e.get('text', '')}"})
|
|
112
121
|
elif kind in (READ, EDIT) and path and e.get("source") != "files":
|
|
113
122
|
part = part_of(path, code)
|
|
@@ -196,9 +205,10 @@ def ago(at: float, clock: Optional[float] = None) -> str:
|
|
|
196
205
|
|
|
197
206
|
# ── state.json: the latest diff in a few words ───────────────────────────────
|
|
198
207
|
|
|
199
|
-
def write_state(root: Path, since: Optional[dict], label_since: str = "checkpoint", turn: Optional[dict] = None
|
|
208
|
+
def write_state(root: Path, since: Optional[dict], label_since: str = "checkpoint", turn: Optional[dict] = None,
|
|
209
|
+
noted: Optional[List[str]] = None) -> None:
|
|
200
210
|
"""Keep the diff since the checkpoint, and this turn's, in a few words and as sentences (learn.story); None for
|
|
201
|
-
nothing new."""
|
|
211
|
+
nothing new. `noted`: the turn's design notes (design.py) by their text, for telling what the next edit adds."""
|
|
202
212
|
from .learn import story
|
|
203
213
|
|
|
204
214
|
def brief(d: Optional[dict]) -> Dict[str, Any]:
|
|
@@ -208,6 +218,8 @@ def write_state(root: Path, since: Optional[dict], label_since: str = "checkpoin
|
|
|
208
218
|
"told": [t["text"] for t in told][:500], # every sentence, for telling what the next edit adds
|
|
209
219
|
"headline": (d or {}).get("headline", "No change to the system's structure.")}
|
|
210
220
|
state = {"at": round(time.time(), 3), "since": label_since, **brief(since), "turn": brief(turn) if turn else None}
|
|
221
|
+
if state["turn"] is not None:
|
|
222
|
+
state["turn"]["noted"] = list(noted or [])[:500]
|
|
211
223
|
_replace(git_dir(root) / STATE, json.dumps(state, ensure_ascii=False) + "\n")
|
|
212
224
|
|
|
213
225
|
|
|
@@ -215,7 +227,7 @@ def new_turn(root: Path) -> None:
|
|
|
215
227
|
"""A prompt was sent: the turn has changed nothing yet."""
|
|
216
228
|
state = read_state(root)
|
|
217
229
|
if state:
|
|
218
|
-
state["turn"] = {"changes": 0, "short": "", "story": [], "count": 0, "told": [],
|
|
230
|
+
state["turn"] = {"changes": 0, "short": "", "story": [], "count": 0, "told": [], "noted": [],
|
|
219
231
|
"headline": "No change to the system's structure."}
|
|
220
232
|
_replace(git_dir(root) / STATE, json.dumps(state, ensure_ascii=False) + "\n")
|
|
221
233
|
|
system_diff/agent.py
CHANGED
|
@@ -18,7 +18,7 @@ import sys
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
20
20
|
|
|
21
|
-
from . import activity, companion, config, learn
|
|
21
|
+
from . import activity, companion, config, design, learn
|
|
22
22
|
from .compare import compare_worktree, mark_turn, turn_sha
|
|
23
23
|
from .extract import Code
|
|
24
24
|
from .sources import git
|
|
@@ -122,6 +122,10 @@ def on_start(root: Path, payload: Dict[str, Any], env=os.environ) -> Optional[st
|
|
|
122
122
|
|
|
123
123
|
|
|
124
124
|
def on_prompt(root: Path, payload: Dict[str, Any]) -> None:
|
|
125
|
+
"""The person sent a prompt: a new turn starts. A notice Claude Code sends as a prompt (a background task done)
|
|
126
|
+
is not one: the turn goes on."""
|
|
127
|
+
if activity.is_system(payload.get("prompt") or ""):
|
|
128
|
+
return
|
|
125
129
|
mark_turn(root)
|
|
126
130
|
activity.new_turn(root)
|
|
127
131
|
activity.record(root, {"event": activity.PROMPT, "text": _clip(payload.get("prompt"), 300),
|
|
@@ -155,10 +159,25 @@ def on_tool(root: Path, payload: Dict[str, Any]) -> Optional[str]:
|
|
|
155
159
|
last = activity.read_state(root).get("turn") or {}
|
|
156
160
|
before = last.get("told") or [s["text"] for s in last.get("story") or []]
|
|
157
161
|
since, turn = compare_worktree(root, ["checkpoint", "turn"])
|
|
158
|
-
|
|
162
|
+
notes = design.Reading(turn).notes()
|
|
163
|
+
record_step(root, path, part, last.get("noted") or [], notes)
|
|
164
|
+
activity.write_state(root, since.diff, since.base, turn.diff, noted=[n["text"] for n in notes])
|
|
159
165
|
return step_message(before, learn.story(turn.diff))
|
|
160
166
|
|
|
161
167
|
|
|
168
|
+
def record_step(root: Path, path: str, part: str, before: Iterable[str], notes: List[Dict[str, Any]]) -> None:
|
|
169
|
+
"""What one edit did to the system's design, kept as a step of what just happened: each change it made, said as
|
|
170
|
+
a design note with why it matters, and each change of the turn it undid."""
|
|
171
|
+
had = set(before)
|
|
172
|
+
now = {n["text"] for n in notes}
|
|
173
|
+
items = [{"text": n["text"], "why": n["why"], "sign": n["sign"], "status": n["status"], "weight": n["weight"],
|
|
174
|
+
"change": n.get("change")} for n in notes if n["text"] not in had]
|
|
175
|
+
items += [{"text": t, "why": "undone: the turn no longer makes this change", "sign": "↺", "status": "muted"}
|
|
176
|
+
for t in sorted(had - now)]
|
|
177
|
+
if items:
|
|
178
|
+
activity.record(root, {"event": "change", "path": path, "part": part, "items": items, "source": "hook"})
|
|
179
|
+
|
|
180
|
+
|
|
162
181
|
def on_stop(root: Path, payload: Dict[str, Any]) -> Optional[str]:
|
|
163
182
|
turn_known = turn_sha(root) is not None
|
|
164
183
|
found = compare_worktree(root, ["checkpoint", "turn"] if turn_known else ["checkpoint"])
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""The system as a system designer draws it, C4-style, rather than as a map of every module: who uses it (ACTORS,
|
|
2
|
+
people and programs), where they come in (ENTRY POINTS), the containers that do the work with their COMPONENTS listed
|
|
3
|
+
inside, the data it keeps (STORES) and the systems outside it; joined by FLOWS named for what travels on them
|
|
4
|
+
(HTTPS, MCP, SQL, a terminal). Two levels:
|
|
5
|
+
|
|
6
|
+
system every actor, entry point, container, store and outside system; a flow between two boxes stands for
|
|
7
|
+
all the connections between what they hold
|
|
8
|
+
container one container's components as boxes with the connections between them, and every box outside it
|
|
9
|
+
they connect to (an entry point, another container, a store), with the flows to it
|
|
10
|
+
|
|
11
|
+
Declared in system-diff.toml where the project says so: `[[actors]]` (id, label, kind person|program, uses = [part
|
|
12
|
+
ids], via = "HTTPS"), a component's `group` (its container) and `about` (one line on what it does), an outside
|
|
13
|
+
system's `used_by` and `via` where no setting names it. Inferred where it does not: the front end's people, an API's
|
|
14
|
+
callers, an MCP server's AI agents, a command line's developer, from the technology each entry point imports; the
|
|
15
|
+
parts of a crowded column held in one container named for it.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
21
|
+
|
|
22
|
+
from . import graph
|
|
23
|
+
from .design import kinds
|
|
24
|
+
|
|
25
|
+
COLUMNS_FIRST = [("actors", "Who uses it")]
|
|
26
|
+
SYSTEM_COLUMN = {"logic": "core", "data": "core", "store": "outside", "external": "outside"} # four, at the top level
|
|
27
|
+
MAIN_FLOWS = 4 # flows drawn from each box at the system level, strongest first
|
|
28
|
+
CROWDED = 7 # more parts than this in a column, none grouped: they go in one container
|
|
29
|
+
PROTOCOL = (("postgres", "SQL"), ("mysql", "SQL"), ("maria", "SQL"), ("sqlite", "SQL"), ("database", "SQL"),
|
|
30
|
+
("redis", "Redis"), ("valkey", "Redis"), ("mongo", "MongoDB"), ("smtp", "SMTP"), ("mail", "SMTP"),
|
|
31
|
+
("kafka", "Kafka"), ("rabbit", "AMQP"), ("s3", "S3 API"), ("minio", "S3 API"), ("git", "git"),
|
|
32
|
+
("elastic", "HTTP"), ("search", "HTTP"))
|
|
33
|
+
ACTOR_BY_TECH = ( # an entry point importing this is used by: (actor id, label, kind, via)
|
|
34
|
+
("mcp", ("agents", "AI agents", "program", "MCP")),
|
|
35
|
+
("fastapi", ("clients", "API clients", "program", "HTTPS")),
|
|
36
|
+
("flask", ("clients", "API clients", "program", "HTTPS")),
|
|
37
|
+
("django", ("clients", "API clients", "program", "HTTPS")),
|
|
38
|
+
("express", ("clients", "API clients", "program", "HTTPS")),
|
|
39
|
+
("http.server", ("browser", "Browser", "person", "HTTP")),
|
|
40
|
+
("argparse", ("developer", "Developer", "person", "terminal")),
|
|
41
|
+
("click", ("developer", "Developer", "person", "terminal")),
|
|
42
|
+
("typer", ("developer", "Developer", "person", "terminal")),
|
|
43
|
+
("textual", ("developer", "Developer", "person", "terminal")),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def protocol(label: str, shape: str = "") -> str:
|
|
48
|
+
low = label.lower()
|
|
49
|
+
hit = next((p for key, p in PROTOCOL if key in low), "")
|
|
50
|
+
return hit or ("SQL" if shape == "db" else "HTTPS")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _meta(cfg: Dict[str, Any]) -> Dict[str, dict]:
|
|
54
|
+
return {c["id"]: c for c in cfg.get("components", []) if "id" in c}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def groups_of(bp: dict, cfg: Dict[str, Any], kind: Dict[str, str]) -> Dict[str, str]:
|
|
58
|
+
"""Part -> its container ("g:<name>"), as declared; a crowded column with none declared is one container."""
|
|
59
|
+
meta = _meta(cfg)
|
|
60
|
+
out = {cid: "g:" + meta[cid]["group"] for cid in bp["components"] if meta.get(cid, {}).get("group")}
|
|
61
|
+
by_col: Dict[str, List[str]] = defaultdict(list)
|
|
62
|
+
for cid in bp["components"]:
|
|
63
|
+
if cid not in out and not bp["components"][cid].get("quiet"):
|
|
64
|
+
by_col[kind.get(cid, "logic")].append(cid)
|
|
65
|
+
heading = dict(cfg["picture"]["columns"])
|
|
66
|
+
for col, parts in by_col.items():
|
|
67
|
+
if col in ("logic", "data") and len(parts) > CROWDED and not any(kind.get(p) == col for p in out):
|
|
68
|
+
for p in parts:
|
|
69
|
+
out[p] = "g:" + heading.get(col, col)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _stack(bp: dict, part: str, extra: Optional[Dict[str, Dict[str, int]]]) -> Set[str]:
|
|
74
|
+
names = set((bp.get("stack") or {}).get(part, {})) | set((extra or {}).get(part, {}))
|
|
75
|
+
return names | {n.split(".")[0] for n in names}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def actors(bp: dict, cfg: Dict[str, Any], kind: Dict[str, str],
|
|
79
|
+
stack: Optional[Dict[str, Dict[str, int]]] = None) -> Tuple[List[dict], List[dict]]:
|
|
80
|
+
"""The actors and their flows into the entry points: as declared, else inferred from what each entry point is
|
|
81
|
+
built with. A front end's people use it in a browser; an API a front end calls has the front end as its caller."""
|
|
82
|
+
declared = cfg.get("actors") or []
|
|
83
|
+
nodes, flows = [], []
|
|
84
|
+
if declared:
|
|
85
|
+
for a in declared:
|
|
86
|
+
aid = "a:" + a["id"]
|
|
87
|
+
nodes.append({"id": aid, "col": "actors", "type": "actor", "title": a.get("label", a["id"]),
|
|
88
|
+
"sub": a.get("about", a.get("kind", "")), "status": "same"})
|
|
89
|
+
for use in a.get("uses", []):
|
|
90
|
+
part, _, via = str(use).partition(":")
|
|
91
|
+
if part in bp["components"]:
|
|
92
|
+
flows.append({"from": aid, "to": part, "label": via or a.get("via", ""), "kind": "call"})
|
|
93
|
+
return nodes, flows
|
|
94
|
+
called_by_ui = {key.split(">")[1] for key in bp["api"]}
|
|
95
|
+
found: Dict[str, dict] = {}
|
|
96
|
+
for cid in sorted(bp["components"], key=lambda c: graph.part_order(bp["components"][c], c)):
|
|
97
|
+
k = kind.get(cid)
|
|
98
|
+
if k == "ui":
|
|
99
|
+
hit = ("people", "People", "person", "browser")
|
|
100
|
+
elif k == "interface":
|
|
101
|
+
names = _stack(bp, cid, stack)
|
|
102
|
+
hit = next((a for tech, a in ACTOR_BY_TECH if tech in names), None)
|
|
103
|
+
if hit and hit[0] == "clients" and cid in called_by_ui:
|
|
104
|
+
hit = None # its front end is its caller, drawn as a flow already
|
|
105
|
+
if hit is None and any(o == cid for o in bp["routes"].values()) and cid not in called_by_ui:
|
|
106
|
+
hit = ("clients", "API clients", "program", "HTTPS")
|
|
107
|
+
else:
|
|
108
|
+
continue
|
|
109
|
+
if hit:
|
|
110
|
+
aid = "a:" + hit[0]
|
|
111
|
+
found.setdefault(aid, {"id": aid, "col": "actors", "type": "actor", "title": hit[1], "sub": hit[2],
|
|
112
|
+
"status": "same"})
|
|
113
|
+
flows.append({"from": aid, "to": cid, "label": hit[3], "kind": "call"})
|
|
114
|
+
return list(found.values()), flows
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _entry_sub(bp: dict, cid: str, cfg: Dict[str, Any]) -> str:
|
|
118
|
+
about = _meta(cfg).get(cid, {}).get("about", "")
|
|
119
|
+
routes = sum(1 for o in bp["routes"].values() if o == cid)
|
|
120
|
+
if routes:
|
|
121
|
+
return f"{routes} endpoint{'' if routes == 1 else 's'}" + (f" · {about}" if about else "")
|
|
122
|
+
return about or graph.files_word(bp["components"][cid]["files"])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def outside_nodes(bp: dict, cfg: Dict[str, Any], before: Optional[dict] = None) -> Tuple[List[dict], List[dict]]:
|
|
126
|
+
"""Stores and outside systems with the flows from the parts that use them: those the settings name (the
|
|
127
|
+
blueprint's), and those the config ties to parts itself (`used_by`)."""
|
|
128
|
+
nodes: Dict[str, dict] = {}
|
|
129
|
+
flows = []
|
|
130
|
+
for side in [s for s in (before, bp) if s]:
|
|
131
|
+
for x, e in side["externals"].items():
|
|
132
|
+
db = e.get("shape") == "db"
|
|
133
|
+
nodes["x:" + x] = {"id": "x:" + x, "col": "store" if db else "external", "type": "store" if db else "host",
|
|
134
|
+
"title": e["label"], "status": "same", "sub": ""}
|
|
135
|
+
for x, e in bp["externals"].items():
|
|
136
|
+
tables = len(bp["tables"]) if x == bp.get("store", "postgres") else 0
|
|
137
|
+
nodes["x:" + x]["sub"] = f"{tables} tables" if tables else f"used by {len(e['used_by'])}"
|
|
138
|
+
for u in e["used_by"]:
|
|
139
|
+
flows.append({"from": u, "to": "x:" + x, "label": protocol(e["label"], e.get("shape", "")),
|
|
140
|
+
"kind": "data" if e.get("shape") == "db" else "network"})
|
|
141
|
+
for e in cfg.get("externals", []):
|
|
142
|
+
users = [u for u in e.get("used_by", []) if u in bp["components"]]
|
|
143
|
+
if not users or "x:" + e["id"] in nodes and "x:" + e["id"] in {f["to"] for f in flows}:
|
|
144
|
+
continue
|
|
145
|
+
db = e.get("shape") in ("db", "files")
|
|
146
|
+
nodes["x:" + e["id"]] = {"id": "x:" + e["id"], "col": "store" if db else "external",
|
|
147
|
+
"type": "store" if db else "host", "title": e["label"], "status": "same",
|
|
148
|
+
"sub": e.get("about", "")}
|
|
149
|
+
for u in users:
|
|
150
|
+
flows.append({"from": u, "to": "x:" + e["id"], "label": e.get("via") or protocol(e["label"], e.get("shape", "")),
|
|
151
|
+
"kind": "data" if db else "network"})
|
|
152
|
+
return list(nodes.values()), flows
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _status(marks: dict, nid: str) -> str:
|
|
156
|
+
return marks["status"].get(nid, "same")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def system(cmp, marks: Optional[dict] = None, agent: Optional[str] = None,
|
|
160
|
+
stack: Optional[Dict[str, Dict[str, int]]] = None) -> Dict[str, Any]:
|
|
161
|
+
"""The system level: {"columns", "nodes", "edges"}, ready for canvas.layered. A container's node lists its
|
|
162
|
+
components as `items`; a flow names the boxes it joins and, where one end is a component in a container, that
|
|
163
|
+
component (`from_item`, `to_item`), so a picked component lights its own flows."""
|
|
164
|
+
bp, before, cfg = cmp.after, cmp.before, cmp.cfg
|
|
165
|
+
marks = marks or graph.change_marks(cmp.diff, before, bp)
|
|
166
|
+
kind = {**kinds(before, cfg), **kinds(bp, cfg)}
|
|
167
|
+
group = {**groups_of(before, cfg, kind), **groups_of(bp, cfg, kind)}
|
|
168
|
+
meta = _meta(cfg)
|
|
169
|
+
comps = {**before["components"], **bp["components"]}
|
|
170
|
+
gone = set(before["components"]) - set(bp["components"])
|
|
171
|
+
|
|
172
|
+
nodes: Dict[str, dict] = {}
|
|
173
|
+
box_of: Dict[str, str] = {}
|
|
174
|
+
for cid in sorted(comps, key=lambda c: graph.part_order(comps[c], c)):
|
|
175
|
+
c = comps[cid]
|
|
176
|
+
if c.get("quiet"):
|
|
177
|
+
continue
|
|
178
|
+
st = "removed" if cid in gone else _status(marks, cid)
|
|
179
|
+
col = kind.get(cid, "logic")
|
|
180
|
+
if cid in group:
|
|
181
|
+
gid = group[cid]
|
|
182
|
+
box = nodes.setdefault(gid, {"id": gid, "col": col, "type": "group", "title": gid[2:], "sub": "",
|
|
183
|
+
"status": "same", "items": []})
|
|
184
|
+
box["items"].append({"id": cid, "title": c["label"], "status": st, "agent": cid == agent,
|
|
185
|
+
"hub": bool(c.get("hub"))})
|
|
186
|
+
box_of[cid] = gid
|
|
187
|
+
else:
|
|
188
|
+
nodes[cid] = {"id": cid, "col": col, "type": "component", "title": c["label"], "status": st,
|
|
189
|
+
"agent": cid == agent, "sub": (_entry_sub(bp, cid, cfg) if cid in bp["components"]
|
|
190
|
+
and col in ("interface", "ui") else
|
|
191
|
+
meta.get(cid, {}).get("about", "")
|
|
192
|
+
or graph.files_word(c["files"]))}
|
|
193
|
+
box_of[cid] = cid
|
|
194
|
+
for box in nodes.values():
|
|
195
|
+
if box["type"] == "group":
|
|
196
|
+
items = box["items"]
|
|
197
|
+
box["sub"] = f"{len(items)} " + ("entry points" if box["col"] in ("interface", "ui") else "components")
|
|
198
|
+
if any(i["status"] != "same" for i in items):
|
|
199
|
+
box["status"] = "changed"
|
|
200
|
+
box["agent"] = any(i["agent"] for i in items)
|
|
201
|
+
|
|
202
|
+
acts, flows = actors(bp, cfg, kind, stack)
|
|
203
|
+
outs, out_flows = outside_nodes(bp, cfg, before)
|
|
204
|
+
for n in acts + outs:
|
|
205
|
+
n["status"] = _status(marks, n["id"])
|
|
206
|
+
nodes[n["id"]] = n
|
|
207
|
+
box_of[n["id"]] = n["id"]
|
|
208
|
+
flows += out_flows
|
|
209
|
+
|
|
210
|
+
# every connection between parts, lifted to the boxes that hold them; within one box it is not drawn
|
|
211
|
+
now = graph.connections(bp)
|
|
212
|
+
for (s, t), (k, _n) in now.items():
|
|
213
|
+
if k == "call" and not t.startswith("x:") and not comps.get(t, {}).get("hub"):
|
|
214
|
+
flows.append({"from": s, "to": t, "label": "HTTP" if f"{s}>{t}" in bp["api"] else "", "kind": "call"})
|
|
215
|
+
for (s, t), (k, st) in marks["edges"].items():
|
|
216
|
+
if st == "removed" and (s, t) not in now:
|
|
217
|
+
flows.append({"from": s, "to": t, "label": "", "kind": k, "status": "removed"})
|
|
218
|
+
|
|
219
|
+
edges: Dict[Tuple[str, str], dict] = {}
|
|
220
|
+
for f in flows:
|
|
221
|
+
a, b = box_of.get(f["from"]), box_of.get(f["to"])
|
|
222
|
+
if not a or not b or a == b:
|
|
223
|
+
continue
|
|
224
|
+
st = f.get("status") or marks["edges"].get((f["from"], f["to"]), (f["kind"], "same"))[1]
|
|
225
|
+
e = edges.setdefault((a, b), {"from": a, "to": b, "kind": f["kind"], "label": f.get("label", ""),
|
|
226
|
+
"status": "same", "pairs": []})
|
|
227
|
+
e["pairs"].append([f["from"], f["to"]])
|
|
228
|
+
if st != "same" and (e["status"] == "same" or st == "removed"):
|
|
229
|
+
e["status"] = st
|
|
230
|
+
if f.get("label") and f["label"] not in e["label"].split(", "):
|
|
231
|
+
e["label"] = f"{e['label']}, {f['label']}" if e["label"] else f["label"]
|
|
232
|
+
for e in edges.values(): # one component at an end: the flow meets it on its row
|
|
233
|
+
if len(e["label"]) > 16:
|
|
234
|
+
e["label"] = e["label"].split(", ")[0] + " +"
|
|
235
|
+
froms = {p[0] for p in e["pairs"]}
|
|
236
|
+
tos = {p[1] for p in e["pairs"]}
|
|
237
|
+
if nodes[e["from"]]["type"] == "group" and len(froms) == 1:
|
|
238
|
+
e["from_item"] = next(iter(froms))
|
|
239
|
+
if nodes[e["to"]]["type"] == "group" and len(tos) == 1:
|
|
240
|
+
e["to_item"] = next(iter(tos))
|
|
241
|
+
heading = dict(cfg["picture"]["columns"])
|
|
242
|
+
for n in nodes.values():
|
|
243
|
+
n["col"] = SYSTEM_COLUMN.get(n["col"], n["col"])
|
|
244
|
+
# the main flows: not one entry point starting another (one program's wiring), and from each box its strongest
|
|
245
|
+
# few; every flow that changed stays. The rest show in a box's own view.
|
|
246
|
+
kept: Dict[str, List[dict]] = defaultdict(list)
|
|
247
|
+
for e in sorted(edges.values(), key=lambda e: (e["status"] == "same", -len(e["pairs"]), e["to"])):
|
|
248
|
+
same_col = nodes[e["from"]]["col"] == nodes[e["to"]]["col"] in ("interface", "ui", "actors")
|
|
249
|
+
if e["status"] != "same" or (not same_col and len(kept[e["from"]]) < MAIN_FLOWS):
|
|
250
|
+
kept[e["from"]].append(e)
|
|
251
|
+
edges = {(e["from"], e["to"]): e for es in kept.values() for e in es}
|
|
252
|
+
columns = COLUMNS_FIRST + [("ui", heading.get("ui", "What people use")),
|
|
253
|
+
("interface", heading.get("interface", "Entry points")),
|
|
254
|
+
("core", "Inside the system"), ("outside", "Data and outside")]
|
|
255
|
+
return {"columns": columns, "nodes": list(nodes.values()), "edges": list(edges.values())}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def container(cmp, box: str, marks: Optional[dict] = None, agent: Optional[str] = None,
|
|
259
|
+
stack: Optional[Dict[str, Dict[str, int]]] = None) -> Optional[Dict[str, Any]]:
|
|
260
|
+
"""One box zoomed in on: a container's components (or a lone part) as boxes, with the connections between them
|
|
261
|
+
and every box outside it that they connect to, each flow as at the system level."""
|
|
262
|
+
whole = system(cmp, marks, agent, stack)
|
|
263
|
+
nodes = {n["id"]: n for n in whole["nodes"]}
|
|
264
|
+
if box not in nodes:
|
|
265
|
+
holder = next((n["id"] for n in whole["nodes"] if any(i["id"] == box for i in n.get("items", []))), None)
|
|
266
|
+
if holder is None:
|
|
267
|
+
return None
|
|
268
|
+
box = holder
|
|
269
|
+
me = nodes[box]
|
|
270
|
+
inside = [i["id"] for i in me.get("items", [])] or [box]
|
|
271
|
+
item = {i["id"]: i for i in me.get("items", [])}
|
|
272
|
+
bp, before, cfg = cmp.after, cmp.before, cmp.cfg
|
|
273
|
+
marks = marks or graph.change_marks(cmp.diff, before, bp)
|
|
274
|
+
kind = {**kinds(before, cfg), **kinds(bp, cfg)}
|
|
275
|
+
comps = {**before["components"], **bp["components"]}
|
|
276
|
+
out_nodes: Dict[str, dict] = {}
|
|
277
|
+
for cid in inside:
|
|
278
|
+
if cid in item:
|
|
279
|
+
i = item[cid]
|
|
280
|
+
out_nodes[cid] = {"id": cid, "col": kind.get(cid, me["col"]), "type": "component", "title": i["title"],
|
|
281
|
+
"status": i["status"], "agent": i["agent"],
|
|
282
|
+
"sub": _meta(cfg).get(cid, {}).get("about", "") or graph.files_word(comps[cid]["files"])}
|
|
283
|
+
else:
|
|
284
|
+
out_nodes[cid] = dict(me)
|
|
285
|
+
edges = []
|
|
286
|
+
# inside: component to component
|
|
287
|
+
now = graph.connections(bp)
|
|
288
|
+
for (s, t), (k, _n) in sorted(now.items()):
|
|
289
|
+
if s in inside and t in inside and s != t and k == "call":
|
|
290
|
+
edges.append({"from": s, "to": t, "kind": "call", "label": "",
|
|
291
|
+
"status": marks["edges"].get((s, t), (k, "same"))[1]})
|
|
292
|
+
# out of and into the box: the system level's flows, met at their components
|
|
293
|
+
for e in whole["edges"]:
|
|
294
|
+
if box not in (e["from"], e["to"]):
|
|
295
|
+
continue
|
|
296
|
+
other = e["to"] if e["from"] == box else e["from"]
|
|
297
|
+
on = nodes[other]
|
|
298
|
+
out_nodes.setdefault(other, {**on, "items": None, "type": "component" if on["type"] == "group" else on["type"]}
|
|
299
|
+
if on["type"] == "group" else dict(on))
|
|
300
|
+
if on["type"] == "group":
|
|
301
|
+
out_nodes[other]["sub"] = f"{len(on['items'])} components"
|
|
302
|
+
for s, t in e["pairs"]:
|
|
303
|
+
a = s if s in inside else other
|
|
304
|
+
b = t if t in inside else other
|
|
305
|
+
if a != b:
|
|
306
|
+
edges.append({"from": a, "to": b, "kind": e["kind"], "label": e["label"],
|
|
307
|
+
"status": marks["edges"].get((s, t), (e["kind"], e["status"]))[1]
|
|
308
|
+
if e["status"] == "same" else e["status"]})
|
|
309
|
+
seen, uniq = set(), []
|
|
310
|
+
for e in edges:
|
|
311
|
+
if (e["from"], e["to"]) not in seen:
|
|
312
|
+
seen.add((e["from"], e["to"]))
|
|
313
|
+
uniq.append(e)
|
|
314
|
+
for n in out_nodes.values():
|
|
315
|
+
if n["id"] in comps and n["id"] in kind:
|
|
316
|
+
n["col"] = kind[n["id"]]
|
|
317
|
+
elif n["id"].startswith("x:"):
|
|
318
|
+
n["col"] = "store" if n.get("type") == "store" else "external"
|
|
319
|
+
elif n["id"].startswith("g:"):
|
|
320
|
+
first = next((i["id"] for i in nodes[n["id"]].get("items", [])), "")
|
|
321
|
+
n["col"] = kind.get(first, "logic")
|
|
322
|
+
columns = COLUMNS_FIRST + [tuple(c) for c in cfg["picture"]["columns"]]
|
|
323
|
+
return {"columns": columns, "nodes": list(out_nodes.values()), "edges": uniq, "box": box,
|
|
324
|
+
"title": me["title"]}
|