leyline-code 0.8.1__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.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
leyline/export.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""Export a store as one self-contained viewer page (or as the JSON the viewer reads)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import gzip
|
|
7
|
+
import html
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from . import store
|
|
15
|
+
|
|
16
|
+
KEEP_ATTRS = ("framework", "runner", "native_kind", "visibility", "signature", "declared_type", "trigger", "is_static",
|
|
17
|
+
"is_abstract", "marker", "ecosystem", "category", "also_in", "namespace", "version",
|
|
18
|
+
"target_framework", "url", "is_test")
|
|
19
|
+
|
|
20
|
+
# The role that names a pattern instance: a strategy is named after its abstraction, not its context.
|
|
21
|
+
PRIMARY_ROLE = {"strategy": "strategy", "decorator": "decorator", "composite": "composite", "template method": "template",
|
|
22
|
+
"observer": "subject", "factory": "factory", "builder": "builder", "singleton": "singleton",
|
|
23
|
+
"process boundary": "launcher"}
|
|
24
|
+
|
|
25
|
+
PAGE = """<!doctype html>
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="utf-8">
|
|
29
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
30
|
+
<style>html{{color-scheme:light}}body{{margin:0;font:14px system-ui,sans-serif}}[hidden]{{display:none!important}}</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
{fragment}
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def remote_url(url: Optional[str]) -> Optional[str]:
|
|
40
|
+
"""A git remote that is a URL (https://, ssh://, git@host:path), or None for a path on this machine."""
|
|
41
|
+
ok = url and not url.lower().startswith("file:") and re.match(r"^(?:[a-z][a-z0-9+.-]*://|[\w.-]+@[\w.-]+:)", url, re.I)
|
|
42
|
+
return url if ok else None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def graph(con, with_sources: bool = True, memory: Optional[Path] = None) -> dict:
|
|
46
|
+
"""The whole store in the compact shape the viewer loads. `memory` is the file that remembers where boxes
|
|
47
|
+
were drawn last time (see `layout`); without it the layout is worked out afresh, the same way each time."""
|
|
48
|
+
rows = con.execute("SELECT * FROM nodes ORDER BY kind = 'repo' DESC, id").fetchall()
|
|
49
|
+
index = {r["id"]: i for i, r in enumerate(rows)}
|
|
50
|
+
nodes = []
|
|
51
|
+
module_names: dict[str, int] = {}
|
|
52
|
+
for r in rows:
|
|
53
|
+
if r["kind"] == "module":
|
|
54
|
+
module_names[r["name"]] = module_names.get(r["name"], 0) + 1
|
|
55
|
+
for r in rows:
|
|
56
|
+
attrs = json.loads(r["attrs"]) if r["attrs"] else {}
|
|
57
|
+
n = {"i": r["id"], "k": r["kind"], "n": r["name"]}
|
|
58
|
+
if r["kind"] == "module" and module_names[r["name"]] > 1:
|
|
59
|
+
# Two modules with one name (src/Shared, test/Shared): show the path to tell them apart.
|
|
60
|
+
n["n"] = r["path"] if r["path"] not in (None, "", ".") else "(repository root)"
|
|
61
|
+
if r["parent_id"] in index:
|
|
62
|
+
n["p"] = index[r["parent_id"]]
|
|
63
|
+
if r["language"]:
|
|
64
|
+
n["l"] = r["language"]
|
|
65
|
+
if r["path"] is not None and r["kind"] != "repo":
|
|
66
|
+
n["f"] = r["path"]
|
|
67
|
+
if r["span_start"]:
|
|
68
|
+
n["a"], n["b"] = r["span_start"], r["span_end"]
|
|
69
|
+
x = {k: attrs[k] for k in KEEP_ATTRS if attrs.get(k) not in (None, "", [], False)}
|
|
70
|
+
if r["kind"] == "system":
|
|
71
|
+
x.update({k: attrs[k] for k in ("members", "cohesion", "top") if k in attrs})
|
|
72
|
+
x["layer"] = r["layer"]
|
|
73
|
+
if x:
|
|
74
|
+
n["x"] = x
|
|
75
|
+
nodes.append(n)
|
|
76
|
+
rank = {"exact": 2, "observed": 2, "heuristic": 1, "guess": 0}
|
|
77
|
+
edges = []
|
|
78
|
+
access = [] # [function, field, 1 for an assignment, times, line, 1 if only while creating, 1 if guessed]
|
|
79
|
+
for e in con.execute("SELECT kind, src_id, dst_id, precision, attrs FROM edges WHERE kind != 'contains'"):
|
|
80
|
+
if e["src_id"] in index and e["dst_id"] in index:
|
|
81
|
+
attrs = json.loads(e["attrs"]) if e["attrs"] else {}
|
|
82
|
+
if e["kind"] in ("reads", "writes"):
|
|
83
|
+
access.append([index[e["src_id"]], index[e["dst_id"]], int(e["kind"] == "writes"), attrs.get("n", 1),
|
|
84
|
+
attrs.get("line") or 0, int(bool(attrs.get("init"))), int(e["precision"] == "guess")])
|
|
85
|
+
continue
|
|
86
|
+
extra = attrs.get("role") or ""
|
|
87
|
+
if e["kind"] == "communicates":
|
|
88
|
+
extra = {"channel": attrs.get("channel"), "address": attrs.get("address"),
|
|
89
|
+
"handler": attrs.get("handler"), "pipes": attrs.get("pipes")}
|
|
90
|
+
edges.append([e["kind"], index[e["src_id"]], index[e["dst_id"]], rank.get(e["precision"], 1), extra])
|
|
91
|
+
calls = []
|
|
92
|
+
for c in con.execute(
|
|
93
|
+
"SELECT src_id, dst_id, COUNT(*) AS n, MIN(site_start) AS line,"
|
|
94
|
+
" MIN(CASE precision WHEN 'exact' THEN 2 WHEN 'guess' THEN 0 ELSE 1 END) AS rank"
|
|
95
|
+
" FROM calls GROUP BY src_id, dst_id ORDER BY dst_id, src_id"):
|
|
96
|
+
if c["src_id"] in index and c["dst_id"] in index:
|
|
97
|
+
calls.append([index[c["src_id"]], index[c["dst_id"]], c["n"], c["line"], c["rank"]])
|
|
98
|
+
flows = []
|
|
99
|
+
for f in con.execute("SELECT * FROM flows ORDER BY name"):
|
|
100
|
+
a = json.loads(f["attrs"]) if f["attrs"] else {}
|
|
101
|
+
# A step is [node, depth] or [node, depth, how it was reached] when that is not a plain call.
|
|
102
|
+
steps = [[index[s["callable_id"]], s["depth"]] + ([s["via"]] if s["via"] not in (None, "calls") else [])
|
|
103
|
+
for s in con.execute("SELECT callable_id, depth, via FROM flow_steps WHERE flow_id = ? ORDER BY seq", (f["id"],))
|
|
104
|
+
if s["callable_id"] in index]
|
|
105
|
+
if f["entry_id"] in index:
|
|
106
|
+
flows.append({"id": f["id"], "name": f["name"], "kind": a.get("kind"), "detail": a.get("detail"),
|
|
107
|
+
"truncated": a.get("truncated", False), "entry": index[f["entry_id"]], "steps": steps})
|
|
108
|
+
coverage = [{"repo": r["repo_id"], "extractor": r["extractor"], "status": r["status"],
|
|
109
|
+
"stats": json.loads(r["stats"]) if r["stats"] else {}}
|
|
110
|
+
for r in con.execute("SELECT * FROM extractor_coverage ORDER BY extractor")]
|
|
111
|
+
notes: dict[int, dict] = {}
|
|
112
|
+
for a in con.execute("SELECT * FROM annotations ORDER BY layer = 'intent'"):
|
|
113
|
+
if a["node_id"] in index:
|
|
114
|
+
notes.setdefault(index[a["node_id"]], {})[a["key"]] = {
|
|
115
|
+
"v": a["value"], "layer": a["layer"], "c": a["confidence"], "stale": bool(a["stale"])}
|
|
116
|
+
views = []
|
|
117
|
+
try:
|
|
118
|
+
for v in con.execute("SELECT * FROM views ORDER BY created DESC"):
|
|
119
|
+
spec = json.loads(v["spec"])
|
|
120
|
+
spec["marks"] = [{**m, "i": index[m["id"]]} for m in spec.get("marks", []) if m["id"] in index]
|
|
121
|
+
for key in ("tests_to_run", "entry_points_affected", "untested"):
|
|
122
|
+
for item in spec.get(key, []):
|
|
123
|
+
item["i"] = index.get(item.get("id"))
|
|
124
|
+
for c in spec.get("channels", []):
|
|
125
|
+
c["fi"], c["ti"] = index.get(c.get("from")), index.get(c.get("to"))
|
|
126
|
+
for g in spec.get("by_module", []) + spec.get("by_system", []):
|
|
127
|
+
g["i"] = index.get(g.get("id"))
|
|
128
|
+
for nn in spec.get("new_nodes", []):
|
|
129
|
+
nn["pi"] = index.get(nn.get("parent"))
|
|
130
|
+
def attach(x):
|
|
131
|
+
# Give every record in a review that names a node the node's index.
|
|
132
|
+
if isinstance(x, dict):
|
|
133
|
+
for key, out in (("id", "i"), ("from_id", "fi"), ("to_id", "ti")):
|
|
134
|
+
if isinstance(x.get(key), str):
|
|
135
|
+
x[out] = index.get(x[key])
|
|
136
|
+
for value in x.values():
|
|
137
|
+
attach(value)
|
|
138
|
+
elif isinstance(x, list):
|
|
139
|
+
for value in x:
|
|
140
|
+
attach(value)
|
|
141
|
+
attach(spec.get("review"))
|
|
142
|
+
views.append({"id": v["id"], "title": v["title"], "kind": v["kind"], "created": v["created"],
|
|
143
|
+
"change_id": v["change_id"], **spec})
|
|
144
|
+
except Exception: # a store written before views existed
|
|
145
|
+
views = []
|
|
146
|
+
pattern_list = []
|
|
147
|
+
try:
|
|
148
|
+
from . import patterns
|
|
149
|
+
for x in patterns.listing(con, include_tests=True, limit=2000)["patterns"]:
|
|
150
|
+
lead = PRIMARY_ROLE.get(x["pattern"])
|
|
151
|
+
marks = [{"i": index[n["id"]], "role": role}
|
|
152
|
+
for role, ns in sorted(x["roles"].items(), key=lambda kv: (kv[0] != lead, kv[0]))
|
|
153
|
+
for n in ns if n["id"] in index]
|
|
154
|
+
if marks:
|
|
155
|
+
pattern_list.append({"id": x["id"], "pattern": x["pattern"], "about": x["about"], "rationale": x["rationale"],
|
|
156
|
+
"c": x["confidence"], "source": x["source"], "stale": x["stale"], "tests": x["in_tests"],
|
|
157
|
+
"marks": marks})
|
|
158
|
+
except Exception: # a store written before patterns existed
|
|
159
|
+
pattern_list = []
|
|
160
|
+
from . import query
|
|
161
|
+
state = [{"i": index[f["id"]], "from": f["written_from"], "w": f["writers"], "r": f["readers"]}
|
|
162
|
+
for f in query.shared_state(con, limit=60)["fields"] if f["id"] in index]
|
|
163
|
+
measured = {"t": {}, "any": [], "watched": []}
|
|
164
|
+
try:
|
|
165
|
+
from . import coverage as cov_store
|
|
166
|
+
if cov_store.has(con):
|
|
167
|
+
per = {}
|
|
168
|
+
for r in con.execute("SELECT DISTINCT test_id, node_id FROM covered WHERE test_id IS NOT NULL"):
|
|
169
|
+
if r["test_id"] in index and r["node_id"] in index:
|
|
170
|
+
per.setdefault(index[r["test_id"]], []).append(index[r["node_id"]])
|
|
171
|
+
measured["t"] = per
|
|
172
|
+
measured["any"] = sorted(index[i] for i in cov_store.ran(con) if i in index)
|
|
173
|
+
measured["watched"] = sorted(index[r[0]] for r in con.execute(
|
|
174
|
+
"SELECT DISTINCT a.file_id FROM covered c JOIN ancestry a ON a.node_id = c.node_id") if r[0] in index)
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
tour_list = []
|
|
178
|
+
try:
|
|
179
|
+
from . import tours
|
|
180
|
+
for t in tours.listing(con)["tours"]:
|
|
181
|
+
full = tours.get(con, t["id"])
|
|
182
|
+
tour_list.append({"id": t["id"], "title": t["title"], "audience": t["audience"], "source": t["source"],
|
|
183
|
+
"stops": [{"title": st["title"], "kind": st["kind"], "ref": st["ref"],
|
|
184
|
+
"i": index.get(st["ref"]), "text": st["narrative"]} for st in full["stops"] if st["exists"]]})
|
|
185
|
+
except Exception: # a store written before tours existed
|
|
186
|
+
tour_list = []
|
|
187
|
+
sources = {}
|
|
188
|
+
roots = {k: str(v) for k, v in store.roots(con).items()}
|
|
189
|
+
# The folder mapped is where the code is; git's origin is shown apart, and only when it is a URL (a clone of a
|
|
190
|
+
# local folder has that folder as its origin, which is not this one).
|
|
191
|
+
repos = [{"id": r["id"], "commit": r["commit_sha"], **(json.loads(r["attrs"]) if r["attrs"] else {}), "folder": roots.get(r["id"], "")}
|
|
192
|
+
for r in rows if r["kind"] == "repo"]
|
|
193
|
+
for r in repos:
|
|
194
|
+
if not remote_url(r.get("url")):
|
|
195
|
+
r.pop("url", None)
|
|
196
|
+
if with_sources:
|
|
197
|
+
from .indexer import source_lines
|
|
198
|
+
# Keyed by path; in a workspace by repo/path, since two repositories can hold the same path.
|
|
199
|
+
several = len(repos) > 1
|
|
200
|
+
for r in rows:
|
|
201
|
+
if r["kind"] == "file" and r["repo_id"] in roots:
|
|
202
|
+
p = Path(roots[r["repo_id"]]) / r["path"]
|
|
203
|
+
try:
|
|
204
|
+
# numbered as the parsers number lines, so a span points at the same text in the page
|
|
205
|
+
text = "\n".join(source_lines(p)) if p.is_file() and store.inside(p, roots[r["repo_id"]]) else None
|
|
206
|
+
except OSError:
|
|
207
|
+
text = None
|
|
208
|
+
if text is not None:
|
|
209
|
+
sources[f"{r['repo_id']}/{r['path']}" if several else r["path"]] = text
|
|
210
|
+
out = {"version": 3, "repos": repos, "nodes": nodes, "edges": edges, "calls": calls, "flows": flows, "notes": notes, "views": views, "patterns": pattern_list, "tours": tour_list, "access": access, "state": state, "measured": measured,
|
|
211
|
+
"coverage": coverage, "changes": changes(con, index, roots), "sources": sources}
|
|
212
|
+
out["layout"] = layout(out, memory)
|
|
213
|
+
out["parts"] = parts(con, index, nodes)
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def parts(con, index: dict, nodes: list) -> list:
|
|
218
|
+
"""Large modules drawn as their parts, as `leyline outline` splits them (two levels): each part is added to `nodes`
|
|
219
|
+
as a node of kind "part" (after the layout, which does not draw them), and listed with the files it holds."""
|
|
220
|
+
from . import outline
|
|
221
|
+
try:
|
|
222
|
+
found = outline.map_parts(con)
|
|
223
|
+
except Exception: # the page is still worth having without them
|
|
224
|
+
return []
|
|
225
|
+
out = []
|
|
226
|
+
|
|
227
|
+
def add(x, parent):
|
|
228
|
+
k = len(nodes)
|
|
229
|
+
extra = {key: x[key] for key in ("kind", "files", "functions", "lines", "summary", "stale", "named")
|
|
230
|
+
if x.get(key) not in (None, "", False)}
|
|
231
|
+
extra["files"] = len(x["files"])
|
|
232
|
+
if x["label"] != x["name"]:
|
|
233
|
+
extra["label"] = x["label"]
|
|
234
|
+
nodes.append({"i": "part:" + x["id"], "k": "part", "n": x["name"], "p": parent, "x": extra})
|
|
235
|
+
out.append({"i": k, "id": x["id"], "files": [index[f] for f in x["files"] if f in index]})
|
|
236
|
+
for s in x.get("parts", ()):
|
|
237
|
+
add(s, k)
|
|
238
|
+
for mp in found:
|
|
239
|
+
if mp["module"] in index:
|
|
240
|
+
for x in mp["parts"]:
|
|
241
|
+
add(x, index[mp["module"]])
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# -- changes ---------------------------------------------------------------------------------------
|
|
246
|
+
# After `leyline check`, the page names what was done in the words of leyline.md's last section.
|
|
247
|
+
AFTER_ROLE = {"edited as predicted": "done", "new, as declared": "done_new", "edited, not predicted": "drift",
|
|
248
|
+
"predicted, not edited": "missed"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def changes(con, index: dict, roots: dict) -> list[dict]:
|
|
252
|
+
"""Each planned change: its page (leyline.md), its tasks and scenarios tied to nodes, and the views that
|
|
253
|
+
hold what it was expected to touch and, after a check, what it did touch."""
|
|
254
|
+
try:
|
|
255
|
+
props = con.execute("SELECT id, intent, status, attrs FROM change_proposals").fetchall()
|
|
256
|
+
views = {r["id"]: r for r in con.execute("SELECT id, kind, created, spec FROM views WHERE change_id IS NOT NULL")}
|
|
257
|
+
except Exception: # a store written before changes existed
|
|
258
|
+
return []
|
|
259
|
+
tests = set()
|
|
260
|
+
try:
|
|
261
|
+
from . import spec
|
|
262
|
+
tests = set(spec._tests(con).values())
|
|
263
|
+
except Exception:
|
|
264
|
+
pass
|
|
265
|
+
out = []
|
|
266
|
+
for p in props:
|
|
267
|
+
cid = p["id"]
|
|
268
|
+
attrs = json.loads(p["attrs"] or "{}")
|
|
269
|
+
name = cid[len("spec-"):] if cid.startswith("spec-") else cid
|
|
270
|
+
plan, review = views.get("view-" + cid), views.get("view-review-" + cid)
|
|
271
|
+
if plan is None:
|
|
272
|
+
continue
|
|
273
|
+
c = {"id": cid, "name": name, "title": attrs.get("title") or p["intent"] or name, "status": p["status"],
|
|
274
|
+
"planned": plan["created"], "plan_view": plan["id"], "review_view": review["id"] if review else None,
|
|
275
|
+
"checked": review["created"] if review else None, "tasks": [], "scenarios": [], "page": None, "folder": None}
|
|
276
|
+
for root in roots.values(): # the change folder sits in one of the mapped repositories
|
|
277
|
+
folder = Path(root) / "openspec" / "changes" / name
|
|
278
|
+
if (folder / "leyline.md").is_file() and store.inside(folder / "leyline.md", root):
|
|
279
|
+
c["page"] = (folder / "leyline.md").read_text(encoding="utf-8", errors="replace")
|
|
280
|
+
c["folder"] = f"openspec/changes/{name}/"
|
|
281
|
+
break
|
|
282
|
+
try:
|
|
283
|
+
for r in con.execute("SELECT * FROM spec_items WHERE change_id = ? ORDER BY kind, key", (cid,)):
|
|
284
|
+
ids = [index[i] for i in json.loads(r["nodes"] or "[]") if i in index]
|
|
285
|
+
extra = json.loads(r["attrs"] or "{}")
|
|
286
|
+
if r["kind"] == "task":
|
|
287
|
+
c["tasks"].append({"key": r["key"], "text": r["text"], "action": r["action"], "nodes": ids,
|
|
288
|
+
"new": [{"name": n.get("label") or n["name"], "pi": index.get(n.get("parent"))}
|
|
289
|
+
for n in extra.get("new", [])],
|
|
290
|
+
"into": [index[i] for i in extra.get("into", []) if i in index]})
|
|
291
|
+
else:
|
|
292
|
+
c["scenarios"].append({"key": r["key"], "name": r["text"], "test": ids[0] if ids else None})
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
if review:
|
|
296
|
+
# leyline.md does not count a test the change edits (that is how a scenario gets proven), a new
|
|
297
|
+
# function only the planned code calls (a helper), or new code inside a container a task names, as
|
|
298
|
+
# outside the spec. The map says the same.
|
|
299
|
+
ids = {k: nid for nid, k in index.items()}
|
|
300
|
+
marks = [m for m in json.loads(review["spec"]).get("marks", []) if m["id"] in index]
|
|
301
|
+
named = {ids[i] for t in c["tasks"] for i in t["nodes"]} | {
|
|
302
|
+
m["id"] for m in marks if m["role"] in ("edited as predicted", "new, as declared")}
|
|
303
|
+
into = [ids[i] for t in c["tasks"] for i in t["into"]]
|
|
304
|
+
after = []
|
|
305
|
+
for m in marks:
|
|
306
|
+
role = AFTER_ROLE.get(m["role"], m["role"])
|
|
307
|
+
if role == "drift":
|
|
308
|
+
if m["id"] in tests or "/test:" in m["id"]:
|
|
309
|
+
role = "test_changed"
|
|
310
|
+
elif any(m["id"].startswith(x + ".") or m["id"].startswith(x + "/") for x in into):
|
|
311
|
+
role = "done"
|
|
312
|
+
elif str(m.get("note", "")).startswith("new") and _only_called_from(con, m["id"], named):
|
|
313
|
+
role = "helper"
|
|
314
|
+
after.append({"i": index[m["id"]], "role": role, "note": m.get("note") or ""})
|
|
315
|
+
c["after"] = after
|
|
316
|
+
out.append(c)
|
|
317
|
+
out.sort(key=lambda c: c["checked"] or c["planned"] or "", reverse=True)
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _only_called_from(con, node_id: str, named: set) -> bool:
|
|
322
|
+
"""True when something calls the node and every caller is named code (or inside it)."""
|
|
323
|
+
callers = {r[0] for r in con.execute("SELECT DISTINCT src_id FROM calls WHERE dst_id = ?", (node_id,))} - {node_id}
|
|
324
|
+
return bool(callers) and all(c in named or any(c.startswith(n + ".") or c.split("(")[0] == n.split("(")[0] for n in named)
|
|
325
|
+
for c in callers)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# -- a layout that stays put -----------------------------------------------------------------------
|
|
329
|
+
# The page draws a level of the map in rows, what depends on something above it. For the levels a person
|
|
330
|
+
# comes back to (the repositories of a workspace, the modules of a repository, the parts of a module) the
|
|
331
|
+
# rows are worked out here and remembered in a file beside the store, so a re-index keeps the map familiar:
|
|
332
|
+
# a box that was there before keeps its row and its place, a new box goes next to what it links to, and a
|
|
333
|
+
# box that went away leaves its neighbours where they were. Delete the file to lay the map out afresh.
|
|
334
|
+
LINK_KINDS = {"calls": "calls", "uses_type": "types", "instantiates": "types", "extends": "inherit", "implements": "inherit",
|
|
335
|
+
"imports": "imports", "depends_on": "imports", "communicates": "channels"}
|
|
336
|
+
ROW_WIDTH = 1300 # px; wider rows wrap
|
|
337
|
+
GAP_X, GAP_X_THIN = 26, 12
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def memory_path(con) -> Optional[Path]:
|
|
341
|
+
"""Where a store's remembered layout lives: beside it, as <store>.layout.json."""
|
|
342
|
+
try:
|
|
343
|
+
for row in con.execute("PRAGMA database_list"):
|
|
344
|
+
if row[1] == "main" and row[2]:
|
|
345
|
+
return Path(row[2]).with_suffix(".layout.json")
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def layout(g: dict, memory: Optional[Path] = None) -> dict:
|
|
352
|
+
"""{level: {node index: [row, x]}} for the workspace, each repository and each module. x is the centre
|
|
353
|
+
of the box in px; the page keeps that order and moves a box right only as far as needed to fit."""
|
|
354
|
+
N = g["nodes"]
|
|
355
|
+
unit, module, repo, system = _ownership(N, g["edges"])
|
|
356
|
+
links = [(k, s, d, (x or {}).get("channel") if k == "communicates" and isinstance(x, dict) else None)
|
|
357
|
+
for k, s, d, _, x in g["edges"] if k in LINK_KINDS] + [("calls", s, d, None) for s, d, *_ in g["calls"]]
|
|
358
|
+
kids: dict[int, list[int]] = {}
|
|
359
|
+
for i, n in enumerate(N):
|
|
360
|
+
if n.get("p") is not None:
|
|
361
|
+
kids.setdefault(n["p"], []).append(i)
|
|
362
|
+
levels: dict[str, tuple[list[int], set]] = {}
|
|
363
|
+
repos = [i for i, n in enumerate(N) if n["k"] == "repo"]
|
|
364
|
+
if len(repos) > 1:
|
|
365
|
+
pairs = {(repo[s], repo[d]) for _, s, d, ch in links if repo[s] is not None and repo[d] is not None and repo[s] != repo[d]}
|
|
366
|
+
levels["ws"] = (repos, pairs)
|
|
367
|
+
mods = {}
|
|
368
|
+
for i, n in enumerate(N):
|
|
369
|
+
if n["k"] == "module" and repo[i] is not None:
|
|
370
|
+
mods.setdefault(repo[i], []).append(i)
|
|
371
|
+
for r in repos:
|
|
372
|
+
own = set(mods.get(r, []))
|
|
373
|
+
pairs = set()
|
|
374
|
+
for _, s, d, ch in links:
|
|
375
|
+
a, b = module[s], module[d]
|
|
376
|
+
if a in own and b in own and a != b:
|
|
377
|
+
pairs.add((b, a) if ch == "event" else (a, b))
|
|
378
|
+
levels["repo:" + N[r]["i"]] = (sorted(own), pairs)
|
|
379
|
+
inner = {"calls", "inherit", "channels"} # what the page shows inside a module until a person asks for more
|
|
380
|
+
in_sys = {d: s for k, s, d, *_ in g["edges"] if k == "groups"}
|
|
381
|
+
grp = lambda u: in_sys.get(u, u)
|
|
382
|
+
# The links inside each module, found in one pass: going over every link for every module took minutes and
|
|
383
|
+
# most of the memory on a repository of thousands of modules.
|
|
384
|
+
within: dict[int, list] = {}
|
|
385
|
+
for k, s, d, ch in links:
|
|
386
|
+
if LINK_KINDS[k] in inner and module[s] is not None and module[s] == module[d] \
|
|
387
|
+
and unit[s] is not None and unit[d] is not None:
|
|
388
|
+
within.setdefault(module[s], []).append((s, d, ch))
|
|
389
|
+
for m in (i for i, n in enumerate(N) if n["k"] == "module"):
|
|
390
|
+
nodes = [k for k in kids.get(m, []) if N[k]["k"] == "system"]
|
|
391
|
+
stack = [k for k in kids.get(m, []) if N[k]["k"] == "file"]
|
|
392
|
+
while stack:
|
|
393
|
+
f = stack.pop()
|
|
394
|
+
for k in [f] + kids.get(f, []):
|
|
395
|
+
if unit[k] == k and k not in in_sys and (N[k]["k"] == "type" or any(N[c]["k"] == "callable" for c in kids.get(k, []))):
|
|
396
|
+
nodes.append(k)
|
|
397
|
+
if len(nodes) < 2:
|
|
398
|
+
continue
|
|
399
|
+
own = set(nodes)
|
|
400
|
+
pairs = set()
|
|
401
|
+
for s, d, ch in within.get(m, ()):
|
|
402
|
+
a, b = grp(unit[s]), grp(unit[d])
|
|
403
|
+
if a in own and b in own and a != b:
|
|
404
|
+
pairs.add((b, a) if ch == "event" else (a, b))
|
|
405
|
+
levels["module:" + N[m]["i"]] = (sorted(own), pairs)
|
|
406
|
+
|
|
407
|
+
old = {}
|
|
408
|
+
if memory and memory.is_file():
|
|
409
|
+
try:
|
|
410
|
+
old = json.loads(memory.read_text(encoding="utf-8")).get("levels", {})
|
|
411
|
+
except (OSError, ValueError, AttributeError, RecursionError):
|
|
412
|
+
old = {}
|
|
413
|
+
width = lambda i: _box_width(N, i, kids)
|
|
414
|
+
out, keep = {}, {}
|
|
415
|
+
for key, (nodes, pairs) in levels.items():
|
|
416
|
+
fresh = _layered(nodes, sorted(pairs), width)
|
|
417
|
+
placed = _remembered(fresh, {N[i]["i"]: i for i in nodes}, old.get(key), pairs, width)
|
|
418
|
+
out[key] = {str(i): v for i, v in placed.items()}
|
|
419
|
+
keep[key] = {N[i]["i"]: v for i, v in placed.items()}
|
|
420
|
+
if memory:
|
|
421
|
+
try:
|
|
422
|
+
body = json.dumps({"version": 1, "levels": keep}, separators=(",", ":"), sort_keys=True)
|
|
423
|
+
if not memory.is_file() or memory.read_text(encoding="utf-8") != body:
|
|
424
|
+
store.write_file(memory, body)
|
|
425
|
+
except OSError:
|
|
426
|
+
pass
|
|
427
|
+
return out
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _ownership(N: list[dict], edges: list) -> tuple[list, list, list, list]:
|
|
431
|
+
"""For each node: its unit (outermost type, or its file), module, repository and system, as the page has them."""
|
|
432
|
+
unit, module, repo, system = [None] * len(N), [None] * len(N), [None] * len(N), [None] * len(N)
|
|
433
|
+
for i, n in enumerate(N): # parents come before children only by accident, so climb
|
|
434
|
+
c, f, top = i, None, None
|
|
435
|
+
while c is not None:
|
|
436
|
+
k = N[c]["k"]
|
|
437
|
+
if k == "file" and f is None:
|
|
438
|
+
f = c
|
|
439
|
+
if k == "module" and module[i] is None:
|
|
440
|
+
module[i] = c
|
|
441
|
+
if k == "repo":
|
|
442
|
+
repo[i] = c
|
|
443
|
+
c = N[c].get("p")
|
|
444
|
+
if n["k"] in ("module", "repo", "external", "system"):
|
|
445
|
+
continue
|
|
446
|
+
if n["k"] == "file":
|
|
447
|
+
unit[i] = i
|
|
448
|
+
continue
|
|
449
|
+
c = i
|
|
450
|
+
while N[c].get("p") is not None and N[N[c]["p"]]["k"] != "file":
|
|
451
|
+
c = N[c]["p"]
|
|
452
|
+
unit[i] = c if N[c]["k"] == "type" else f
|
|
453
|
+
for k, s, d, *_ in edges:
|
|
454
|
+
if k == "groups":
|
|
455
|
+
system[d] = s
|
|
456
|
+
return unit, module, repo, system
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _box_width(N: list[dict], i: int, kids: dict) -> int:
|
|
460
|
+
"""About as wide as the page will draw the box: the label in a 12.5px monospace face, or the line under it."""
|
|
461
|
+
n = N[i]
|
|
462
|
+
meta = 30 if n["k"] in ("module", "repo", "system") else 0
|
|
463
|
+
return int(max(len(n["n"]) * 7.6 + 34, meta * 6.2 + 32, 70))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
WAYPOINTS = 200_000
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _layered(nodes: list[int], pairs: list[tuple], width) -> dict[int, list]:
|
|
470
|
+
"""The page's layered layout: break cycles, layer by longest path, order by barycentre, wrap wide rows,
|
|
471
|
+
route long edges through waypoints so they pass between boxes. Same input, same answer."""
|
|
472
|
+
out = {v: [] for v in nodes}
|
|
473
|
+
inn = {v: [] for v in nodes}
|
|
474
|
+
for a, b in pairs:
|
|
475
|
+
if b not in out[a]:
|
|
476
|
+
out[a].append(b)
|
|
477
|
+
inn[b].append(a)
|
|
478
|
+
linked = [v for v in nodes if out[v] or inn[v]]
|
|
479
|
+
loose = [v for v in nodes if not out[v] and not inn[v]]
|
|
480
|
+
back, state = set(), {}
|
|
481
|
+
for root in sorted(linked, key=lambda v: (len(inn[v]), -len(out[v]), v)):
|
|
482
|
+
if root in state:
|
|
483
|
+
continue
|
|
484
|
+
state[root] = 1
|
|
485
|
+
stack = [(root, iter(out[root]))]
|
|
486
|
+
while stack:
|
|
487
|
+
v, it = stack[-1]
|
|
488
|
+
w = next(it, None)
|
|
489
|
+
if w is None:
|
|
490
|
+
state[v] = 2
|
|
491
|
+
stack.pop()
|
|
492
|
+
elif state.get(w) == 1:
|
|
493
|
+
back.add((v, w))
|
|
494
|
+
elif w not in state:
|
|
495
|
+
state[w] = 1
|
|
496
|
+
stack.append((w, iter(out[w])))
|
|
497
|
+
layer: dict[int, int] = {}
|
|
498
|
+
pending = {v: sum(1 for u in inn[v] if (u, v) not in back) for v in linked}
|
|
499
|
+
ready = [v for v in linked if not pending[v]]
|
|
500
|
+
while ready:
|
|
501
|
+
v = ready.pop()
|
|
502
|
+
layer.setdefault(v, 0)
|
|
503
|
+
for w in out[v]:
|
|
504
|
+
if (v, w) in back:
|
|
505
|
+
continue
|
|
506
|
+
layer[w] = max(layer.get(w, 0), layer[v] + 1)
|
|
507
|
+
pending[w] -= 1
|
|
508
|
+
if not pending[w]:
|
|
509
|
+
ready.append(w)
|
|
510
|
+
rows: list[list[int]] = []
|
|
511
|
+
for v in linked:
|
|
512
|
+
while len(rows) <= layer.get(v, 0):
|
|
513
|
+
rows.append([])
|
|
514
|
+
rows[layer.get(v, 0)].append(v)
|
|
515
|
+
rows = [r for r in rows if r]
|
|
516
|
+
pos: dict = {}
|
|
517
|
+
|
|
518
|
+
def sweep(rs, up, down, times):
|
|
519
|
+
for r in rs:
|
|
520
|
+
for j, v in enumerate(r):
|
|
521
|
+
pos[v] = j
|
|
522
|
+
for k in range(times):
|
|
523
|
+
for r in (reversed(range(len(rs))) if k % 2 else range(len(rs))):
|
|
524
|
+
nb = down if k % 2 else up
|
|
525
|
+
bary = {}
|
|
526
|
+
for v in rs[r]:
|
|
527
|
+
ns = [u for u in nb.get(v, []) if u in pos]
|
|
528
|
+
bary[v] = sum(pos[u] for u in ns) / len(ns) if ns else pos[v]
|
|
529
|
+
rs[r].sort(key=lambda v: bary[v])
|
|
530
|
+
for j, v in enumerate(rs[r]):
|
|
531
|
+
pos[v] = j
|
|
532
|
+
|
|
533
|
+
at = {v: k for k, r in enumerate(rows) for v in r}
|
|
534
|
+
up = {v: [u for u in inn[v] + out[v] if at[u] < at[v]] for v in linked}
|
|
535
|
+
down = {v: [u for u in inn[v] + out[v] if at[u] > at[v]] for v in linked}
|
|
536
|
+
sweep(rows, up, down, 4)
|
|
537
|
+
ranks: list[list] = []
|
|
538
|
+
for r in rows:
|
|
539
|
+
line, lw = [], 0
|
|
540
|
+
for v in r:
|
|
541
|
+
if line and lw + width(v) > ROW_WIDTH:
|
|
542
|
+
ranks.append(line)
|
|
543
|
+
line, lw = [], 0
|
|
544
|
+
line.append(v)
|
|
545
|
+
lw += width(v) + GAP_X
|
|
546
|
+
if line:
|
|
547
|
+
ranks.append(line)
|
|
548
|
+
rank = {v: k for k, r in enumerate(ranks) for v in r}
|
|
549
|
+
up2: dict = {}
|
|
550
|
+
down2: dict = {}
|
|
551
|
+
|
|
552
|
+
def link(a, b):
|
|
553
|
+
down2.setdefault(a, []).append(b)
|
|
554
|
+
up2.setdefault(b, []).append(a)
|
|
555
|
+
# A waypoint per row a long edge crosses: on a level of thousands of boxes in hundreds of rows that is tens of
|
|
556
|
+
# millions of them (GBs), so past a budget long edges are ordered by their ends alone.
|
|
557
|
+
route = sum(max(0, abs(rank[a] - rank[b]) - 1) for a, b in pairs) <= WAYPOINTS
|
|
558
|
+
for a, b in pairs:
|
|
559
|
+
ra, rb = rank[a], rank[b]
|
|
560
|
+
if ra == rb:
|
|
561
|
+
continue
|
|
562
|
+
top, bottom = (a, b) if ra < rb else (b, a)
|
|
563
|
+
prev = top
|
|
564
|
+
for r in range(min(ra, rb) + 1, max(ra, rb)) if route else ():
|
|
565
|
+
d = ("~", a, b, r)
|
|
566
|
+
ranks[r].append(d)
|
|
567
|
+
rank[d] = r
|
|
568
|
+
link(prev, d)
|
|
569
|
+
prev = d
|
|
570
|
+
link(prev, bottom)
|
|
571
|
+
sweep(ranks, up2, down2, 8)
|
|
572
|
+
w = lambda v: 2 if isinstance(v, tuple) else width(v)
|
|
573
|
+
|
|
574
|
+
def gap(a, b):
|
|
575
|
+
da, db = isinstance(a, tuple), isinstance(b, tuple)
|
|
576
|
+
return 5 if da and db else GAP_X_THIN if da or db else GAP_X
|
|
577
|
+
placed: dict[int, list] = {}
|
|
578
|
+
|
|
579
|
+
def place(line, row):
|
|
580
|
+
total = sum(w(v) for v in line) + sum(gap(line[j], line[j + 1]) for j in range(len(line) - 1))
|
|
581
|
+
x = -total / 2
|
|
582
|
+
for j, v in enumerate(line):
|
|
583
|
+
if not isinstance(v, tuple):
|
|
584
|
+
placed[v] = [row, round(x + w(v) / 2)]
|
|
585
|
+
x += w(v) + (gap(v, line[j + 1]) if j + 1 < len(line) else 0)
|
|
586
|
+
for k, r in enumerate(ranks):
|
|
587
|
+
place(r, k)
|
|
588
|
+
row = len(ranks)
|
|
589
|
+
line, lw = [], 0
|
|
590
|
+
for v in sorted(loose, key=lambda v: v):
|
|
591
|
+
if line and lw + width(v) > ROW_WIDTH:
|
|
592
|
+
place(line, row)
|
|
593
|
+
row += 1
|
|
594
|
+
line, lw = [], 0
|
|
595
|
+
line.append(v)
|
|
596
|
+
lw += width(v) + GAP_X
|
|
597
|
+
if line:
|
|
598
|
+
place(line, row)
|
|
599
|
+
return placed
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _remembered(fresh: dict, ids: dict, old: Optional[dict], pairs: set, width) -> dict:
|
|
603
|
+
"""Keep the boxes the remembered layout knows where they were, and fit new ones in beside their links.
|
|
604
|
+
When most of the boxes are new, the remembered layout is for other code: start again."""
|
|
605
|
+
if not old:
|
|
606
|
+
return fresh
|
|
607
|
+
at = {i: old[nid] for nid, i in ids.items() if nid in old}
|
|
608
|
+
if len(at) * 2 < len(ids):
|
|
609
|
+
return fresh
|
|
610
|
+
rows = [r for r, _ in at.values()]
|
|
611
|
+
first, last = min(rows), max(rows)
|
|
612
|
+
ins: dict = {}
|
|
613
|
+
outs: dict = {}
|
|
614
|
+
for a, b in pairs: # looked up per box below; scanning every pair for every new box was quadratic
|
|
615
|
+
outs.setdefault(a, []).append(b)
|
|
616
|
+
ins.setdefault(b, []).append(a)
|
|
617
|
+
in_row: dict = {}
|
|
618
|
+
for j, (r, _) in at.items():
|
|
619
|
+
in_row.setdefault(r, []).append(j)
|
|
620
|
+
for nid, i in sorted(ids.items()):
|
|
621
|
+
if i in at:
|
|
622
|
+
continue
|
|
623
|
+
above = [at[a][0] for a in ins.get(i, ()) if a in at] # what depends on it
|
|
624
|
+
below = [at[b][0] for b in outs.get(i, ()) if b in at] # what it depends on
|
|
625
|
+
row = max(above) + 1 if above else min(below) - 1 if below else last
|
|
626
|
+
near = [at[j][1] for j in ins.get(i, []) + outs.get(i, []) if j in at]
|
|
627
|
+
here = in_row.get(row, [])
|
|
628
|
+
if near:
|
|
629
|
+
x = sum(near) / len(near)
|
|
630
|
+
else: # at the right-hand end of its row
|
|
631
|
+
x = max([at[j][1] + width(j) / 2 for j in here] or [0]) + GAP_X + width(i) / 2
|
|
632
|
+
at[i] = [row, round(_free(x, width(i), [(at[j][1], width(j)) for j in here]))]
|
|
633
|
+
in_row.setdefault(row, []).append(i)
|
|
634
|
+
return at
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _free(x: float, w: float, row: list[tuple]) -> float:
|
|
638
|
+
"""The nearest centre to x where a box w wide overlaps none in the row, so a new box never takes an old one's
|
|
639
|
+
place (the page would then push the old one aside)."""
|
|
640
|
+
clear = lambda c: all(abs(c - cx) >= (w + cw) / 2 + GAP_X for cx, cw in row)
|
|
641
|
+
if clear(x):
|
|
642
|
+
return x
|
|
643
|
+
spots = [cx + s * ((w + cw) / 2 + GAP_X) for cx, cw in row for s in (-1, 1)]
|
|
644
|
+
return min((c for c in spots if clear(c)), key=lambda c: (abs(c - x), c), default=x)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def fragment(con, with_sources: bool = True, open_change: Optional[str] = None, memory: Optional[Path] = None) -> str:
|
|
648
|
+
"""The viewer with data embedded, without an html/head/body wrapper. `open_change` names a change the page
|
|
649
|
+
opens on; `memory` defaults to the layout file beside the store."""
|
|
650
|
+
template = resources.files("leyline").joinpath("viewer/viewer.html").read_text(encoding="utf-8")
|
|
651
|
+
g = graph(con, with_sources, memory if memory is not None else memory_path(con))
|
|
652
|
+
if open_change:
|
|
653
|
+
g["start"] = {"change": open_change}
|
|
654
|
+
data = json.dumps(g, separators=(",", ":"))
|
|
655
|
+
kind = "application/json"
|
|
656
|
+
if len(data) > COMPRESS_OVER:
|
|
657
|
+
# A big map goes in gzipped: the page unpacks it with the browser's own DecompressionStream.
|
|
658
|
+
data, kind = base64.b64encode(gzip.compress(data.encode(), 6, mtime=0)).decode(), "application/gzip+base64"
|
|
659
|
+
else:
|
|
660
|
+
# No `<` at all inside the script element: `</script>` would end it, and `<!--` then `<script` in some
|
|
661
|
+
# source text would make the browser read past its real end tag and into the viewer's code.
|
|
662
|
+
data = data.replace("<", "\\u003c")
|
|
663
|
+
repos = [r["id"] for r in g["repos"]] or ["repo"]
|
|
664
|
+
name = repos[0] if len(repos) == 1 else " + ".join(repos[:3]) + (f" and {len(repos) - 3} more" if len(repos) > 3 else "")
|
|
665
|
+
return (template.replace("__LEYLINE_REPO__", html.escape(name))
|
|
666
|
+
.replace('type="application/json">__LEYLINE_DATA__', f'type="{kind}">' + data))
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
COMPRESS_OVER = 2_000_000 # characters of JSON; smaller pages stay readable as plain JSON
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def page(con, with_sources: bool = True, open_change: Optional[str] = None, memory: Optional[Path] = None) -> str:
|
|
673
|
+
return PAGE.format(fragment=fragment(con, with_sources, open_change, memory))
|