code-node-graph 0.2.0__py3-none-any.whl → 0.4.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.
code_node/__init__.py CHANGED
@@ -13,7 +13,7 @@ from .libraries import Library, register_library
13
13
  from .platforms import Platform, get_platform, register_platform
14
14
  from .project import EntryPoint, Project, entry_points, read_project
15
15
 
16
- __version__ = "0.2.0"
16
+ __version__ = "0.4.0"
17
17
 
18
18
  __all__ = [
19
19
  "CodeGraph",
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
  import heapq
9
9
  import re
10
10
  from collections import defaultdict
11
- from dataclasses import dataclass, field
11
+ from dataclasses import dataclass, field, replace
12
12
 
13
13
  from ..kit.fmt import _plural
14
14
  from ..kit.text import _clip
@@ -55,6 +55,7 @@ class GraphNode:
55
55
  when: str = "" # a function called only under an if, everywhere it's called: 'check(order)'
56
56
  icon: str = "" # a service's icon, when its plugin names one: 'database', 'queue', 'spark'; or the entry
57
57
  # point's logo, when its platform names one: 'fastapi-icon'
58
+ name: str = "" # a function's box: the function, as module.name ('orders.repo.save')
58
59
 
59
60
 
60
61
  @dataclass
@@ -85,6 +86,7 @@ class CodeGraph:
85
86
  nodes: list[GraphNode] = field(default_factory=list)
86
87
  edges: list[GraphEdge] = field(default_factory=list)
87
88
  hidden: int = 0 # functions left out to keep the graph readable (too deep, or too many)
89
+ too_deep: int = 0 # ...of them, the ones deeper than it draws
88
90
 
89
91
  def node(self, node_id: str) -> GraphNode | None:
90
92
  return next((n for n in self.nodes if n.id == node_id), None)
@@ -256,11 +258,13 @@ def _pick(
256
258
  order: dict[str, int],
257
259
  room: int,
258
260
  max_depth: int,
261
+ first: bool = False,
259
262
  ) -> list[str]:
260
263
  """
261
264
  The functions the graph draws besides the entry point, at most `room` of them and max_depth calls deep: grown out
262
265
  from the entry point (and what runs at import), each time taking the one the drawn boxes call that leads to the
263
- most code, so the boxes are the ones that do the work and each is joined to one above it.
266
+ most code, so the boxes are the ones that do the work and each is joined to one above it. first takes every
267
+ function the entry point calls before the rest.
264
268
  """
265
269
 
266
270
  def depth(name: str) -> int:
@@ -283,6 +287,14 @@ def _pick(
283
287
  if name not in level:
284
288
  offer(name)
285
289
  picked: list[str] = []
290
+ if first:
291
+ for child, _ in children.get(entry, []):
292
+ if child in by_name and child != entry and child not in picked and depth(child) <= max_depth:
293
+ picked.append(child)
294
+ for name in list(picked):
295
+ for child, _ in children.get(name, []):
296
+ if child not in picked:
297
+ offer(child)
286
298
  while queue and len(picked) < room:
287
299
  name = heapq.heappop(queue)[3]
288
300
  if name in picked:
@@ -294,7 +306,46 @@ def _pick(
294
306
  return picked
295
307
 
296
308
 
297
- def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_depth: int = _GRAPH_DEPTH) -> CodeGraph:
309
+ def _focused(cmap: CodeMap, focus: str) -> CodeMap:
310
+ """
311
+ The map cut down to one of its functions and what it calls, however deep, as if the code started there: its
312
+ calls in from elsewhere, what runs at import and the entry point's input left out.
313
+ """
314
+ children: dict[str, list[str]] = defaultdict(list)
315
+ for f in cmap.functions:
316
+ for where in f.called_from:
317
+ children[where.rpartition(" line ")[0]].append(f.name)
318
+ scope, todo = {focus}, [focus]
319
+ while todo:
320
+ for child in children.get(todo.pop(), []):
321
+ if child not in scope:
322
+ scope.add(child)
323
+ todo.append(child)
324
+ root = next(f for f in cmap.functions if f.name == focus)
325
+ functions = []
326
+ for f in cmap.functions:
327
+ if f.name in scope:
328
+ kept = [] if f.name == focus else [w for w in f.called_from if w.rpartition(" line ")[0] in scope]
329
+ functions.append(replace(f, called_from=kept, when={w: t for w, t in f.when.items() if w in kept}))
330
+ return replace(
331
+ cmap,
332
+ entry=focus,
333
+ handler_file=root.file,
334
+ entry_line=root.line,
335
+ startup=[],
336
+ event=[],
337
+ functions=functions,
338
+ aws=[c for c in cmap.aws if c.function in scope],
339
+ library_calls=[c for c in cmap.library_calls if c.function in scope],
340
+ env=[e for e in cmap.env if e.function in scope],
341
+ spots=[s for s in cmap.spots if s.function in scope and not s.startup],
342
+ routes=[r for r in cmap.routes if r[2] in scope],
343
+ )
344
+
345
+
346
+ def code_graph(
347
+ cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_depth: int = _GRAPH_DEPTH, focus: str = ""
348
+ ) -> CodeGraph:
298
349
  """
299
350
  How the code fits together, as boxes and arrows, from a CodeMap: on the left what comes in (the event, with the
300
351
  fields it reads, and the environment variables), then the handler, what runs at start-up, and its own functions
@@ -304,10 +355,16 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
304
355
  they do is drawn from the nearest box above them that is.
305
356
  The functions drawn are grown out from the entry point, each time the one that leads to the most code (and to AWS,
306
357
  the web and the environment), so the boxes are the ones that do the work, not the nearest helpers.
358
+ focus draws the graph from one of its functions instead (module.name): it and what it calls, every call it makes
359
+ drawn even past max_functions, and nothing from what comes in or what runs at import.
307
360
  """
308
361
  graph = CodeGraph()
309
362
  if cmap.note or cmap.entry is None:
310
363
  return graph
364
+ if focus:
365
+ if not any(f.name == focus for f in cmap.functions):
366
+ return graph
367
+ cmap = _focused(cmap, focus)
311
368
  platform = get_platform(cmap.platform)
312
369
  entry = cmap.entry
313
370
  by_name = {f.name: f for f in cmap.functions}
@@ -342,11 +399,15 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
342
399
  todo.append(child)
343
400
  order = {f.name: i for i, f in enumerate(cmap.functions)}
344
401
  weight = _weights(cmap, children)
345
- picked = _pick(cmap, entry, start_key, children, level, weight, order, max_functions - entry_is_function, max_depth)
402
+ room = max_functions - entry_is_function
403
+ if focus: # a function opened on its own shows every call it makes
404
+ room = max(room, len({child for child, _ in children.get(entry, [])}))
405
+ picked = _pick(cmap, entry, start_key, children, level, weight, order, room, max_depth, first=bool(focus))
346
406
  shown = ([by_name[entry]] if entry_is_function else []) + sorted(
347
407
  (by_name[name] for name in picked), key=lambda f: (level.get(f.name, f.depth + 1), order[f.name])
348
408
  )
349
409
  graph.hidden = len(cmap.functions) - len(shown)
410
+ graph.too_deep = sum(1 for f in cmap.functions if level.get(f.name, f.depth + 1) > max_depth)
350
411
  ids = {f.name: f"f{i}" for i, f in enumerate(shown)}
351
412
  drawn_level = {entry: 0, start_key: 0} # calls from the entry point through the boxes drawn: each box's column
352
413
  todo = [entry, start_key]
@@ -408,7 +469,9 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
408
469
  reasons[call.function].append(f"{call.label} isn't allowed: {denied[call.permission]} (line {call.line})")
409
470
  for function, why in platform.marks(cmap).items():
410
471
  reasons[function] += why
411
- handler_reasons = [m.split(":")[0] for level_, m in platform.entry_findings(cmap) if level_ == "warn"]
472
+ handler_reasons = (
473
+ [] if focus else [m.split(":")[0] for level_, m in platform.entry_findings(cmap) if level_ == "warn"]
474
+ )
412
475
  unset = platform.unset_env(cmap)
413
476
 
414
477
  # the lines in each function worth pointing at: where it calls, reads or sends what the graph draws
@@ -455,7 +518,7 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
455
518
  when=_when(f, [where for where in f.called_from if where.rpartition(" line ")[0] == caller]),
456
519
  )
457
520
  )
458
- has_input = bool(cmap.event) or platform.draws_input
521
+ has_input = not focus and (bool(cmap.event) or platform.draws_input)
459
522
  if has_input:
460
523
  edges.insert(0, GraphEdge("event", entry_id, "event", "its input"))
461
524
 
@@ -769,7 +832,8 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
769
832
  ],
770
833
  more=more,
771
834
  when=_when(f, f.called_from),
772
- icon=platform.entry_icon(cmap) if is_entry else "",
835
+ icon=platform.entry_icon(cmap) if is_entry and not focus else "",
836
+ name=f.name,
773
837
  )
774
838
  )
775
839
  out_column = 1 + max([n.column for n in graph.nodes if n.kind in ("handler", "function", "start")] + [1])
@@ -778,3 +842,80 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
778
842
  graph.nodes += reach
779
843
  graph.edges = edges
780
844
  return graph
845
+
846
+
847
+ _GRAPH_VIEWS = 120 # functions opened on their own, at most: past that a box's calls left out stay in the steps
848
+
849
+ _GRAPH_EVERYTHING = 150 # functions, at most, for a graph of all of them besides the one drawn
850
+
851
+
852
+ @dataclass
853
+ class GraphView:
854
+ """
855
+ The graph from one of the functions a graph leaves calls out of: the function and what it calls (`graph`), and the
856
+ calls from the entry point down to it (`path`, module.name each, the entry point first and the function last).
857
+ """
858
+
859
+ name: str
860
+ graph: CodeGraph
861
+ path: list[str] = field(default_factory=list)
862
+
863
+
864
+ def _call_path(cmap: CodeMap, name: str) -> list[str]:
865
+ """
866
+ The fewest calls from the entry point to a function: [entry, ..., name]. [name] when the entry point isn't above it.
867
+ """
868
+ known = {f.name for f in cmap.functions} | {cmap.entry or ""}
869
+ parents: dict[str, list[str]] = defaultdict(list)
870
+ for f in cmap.functions:
871
+ for where in f.called_from:
872
+ caller = where.rpartition(" line ")[0]
873
+ if caller in known:
874
+ parents[f.name].append(caller)
875
+ came: dict[str, str] = {name: ""}
876
+ todo = [name]
877
+ while todo:
878
+ key = todo.pop(0)
879
+ if key == cmap.entry:
880
+ path = [key]
881
+ while came[path[-1]]:
882
+ path.append(came[path[-1]])
883
+ return path
884
+ for parent in parents.get(key, []):
885
+ if parent not in came:
886
+ came[parent] = key
887
+ todo.append(parent)
888
+ return [name]
889
+
890
+
891
+ def graph_views(
892
+ cmap: CodeMap,
893
+ graph: CodeGraph,
894
+ *,
895
+ max_functions: int = _GRAPH_FUNCTIONS,
896
+ max_depth: int = _GRAPH_DEPTH,
897
+ most: int = _GRAPH_VIEWS,
898
+ ) -> list[GraphView]:
899
+ """
900
+ A graph from each function whose calls `graph` leaves out, and from each such function in those, so that from any
901
+ box with calls not drawn, its own graph has them: at most `most`, nearest the entry point first.
902
+ """
903
+ views: dict[str, GraphView] = {}
904
+ todo = [graph]
905
+ while todo and len(views) < most:
906
+ for n in todo.pop(0).nodes:
907
+ if not (n.more and n.name) or n.name in views or len(views) >= most:
908
+ continue
909
+ opened = code_graph(cmap, max_functions=max_functions, max_depth=max_depth, focus=n.name)
910
+ views[n.name] = GraphView(n.name, opened, _call_path(cmap, n.name))
911
+ todo.append(opened)
912
+ return list(views.values())
913
+
914
+
915
+ def everything_graph(cmap: CodeMap, graph: CodeGraph, most: int = _GRAPH_EVERYTHING) -> CodeGraph | None:
916
+ """
917
+ Every function in one graph, when `graph` leaves some out and there are at most `most` of them; else None.
918
+ """
919
+ if not graph.hidden or len(cmap.functions) > most:
920
+ return None
921
+ return code_graph(cmap, max_functions=len(cmap.functions) + 1, max_depth=len(cmap.functions) + 1)
@@ -203,6 +203,22 @@ class _CodeReader:
203
203
  module = module[: -len(".__init__")] if module.endswith(".__init__") else module
204
204
  if module and module != "__init__":
205
205
  self.sources[module] = path
206
+ here = (cmap.handler_file or "").rpartition("/")[0]
207
+ here = here + "/" if here else ""
208
+ if here.startswith(root) and here != root:
209
+ # the entry point's own folder is on sys.path too, as a script's is and a Lambda function's code is:
210
+ # `from helpers import x` beside it is its own code, though the folder is also a package from the root
211
+ beside: set[str] = set()
212
+ for path in texts:
213
+ if not path.startswith(here) or not path.endswith(".py") or "site-packages" in path:
214
+ continue
215
+ module = path[len(here) : -3].replace("/", ".")
216
+ module = module[: -len(".__init__")] if module.endswith(".__init__") else module
217
+ if module and module != "__init__" and "-" not in module and module not in self.sources:
218
+ self.sources[module] = path
219
+ beside.add(module.split(".")[0])
220
+ self.tops |= beside
221
+ self.libraries = libraries - beside
206
222
  self.modules: dict[str, _Module] = {}
207
223
  self.defs: dict[str, tuple[str, Any, str | None]] = {} # key -> (module, node, class key)
208
224
  self.classes: dict[str, dict[str, Any]] = {}
@@ -311,8 +327,9 @@ class _CodeReader:
311
327
  cmap.event = list(self.event_reads.values())
312
328
  cmap.routes = [(method, path, key) for method, path, key, _ in self.routes]
313
329
  loaded = [m for m in self.modules.values() if m.tree is not None]
314
- cmap.other_files = sum(
315
- 1 for name, path in self.sources.items() if name not in self.modules and not _JUNK_RE.search(path)
330
+ read = {path for name, path in self.sources.items() if name in self.modules} # a file has two names, at most
331
+ cmap.other_files = len(
332
+ {path for path in self.sources.values() if path not in read and not _JUNK_RE.search(path)}
316
333
  )
317
334
  for m in loaded:
318
335
  for node in m.tree.body:
@@ -324,7 +341,7 @@ class _CodeReader:
324
341
  if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and prefix + f.name not in self.reached
325
342
  )
326
343
  self._secrets(m)
327
- for name, path in self.sources.items():
344
+ for path, name in {path: name for name, path in reversed(self.sources.items())}.items():
328
345
  for match in _AWS_KEY_RE.finditer(self.texts[path]):
329
346
  line = self.texts[path].count("\n", 0, match.start()) + 1
330
347
  self._spot("secret", path, line, name, "", "an AWS access key", ok=False)
code_node/report/build.py CHANGED
@@ -10,7 +10,7 @@ from ..kit.blocks import _Note, _Text
10
10
  from ..kit.fmt import _plural
11
11
 
12
12
  from ..analysis.findings import _listed, _short_name
13
- from ..analysis.graph import CodeGraph, code_graph
13
+ from ..analysis.graph import CodeGraph, code_graph, everything_graph, graph_views
14
14
  from ..analysis.model import CodeMap
15
15
  from .graph import _Graph
16
16
  from .tabs import _Tabs
@@ -34,8 +34,8 @@ def _closing_note(cmap: CodeMap, graph: CodeGraph) -> str:
34
34
  said.append(f"Calls to {_listed(services, 4)} are read by code-node's library plugins.")
35
35
  if graph.hidden:
36
36
  said.append(
37
- f"The graph leaves out {_plural(graph.hidden, 'function')} to stay readable: run with --all to "
38
- "draw every one."
37
+ f"The graph leaves out {_plural(graph.hidden, 'function')} to stay readable: a box's line of calls not "
38
+ "drawn opens them, and --all draws every one."
39
39
  )
40
40
  if cmap.other_files:
41
41
  said.append(
@@ -99,6 +99,8 @@ def page_blocks(
99
99
  "How it fits together: what comes in, who calls whom, and what it reaches",
100
100
  sources=sources or {},
101
101
  editor=editor,
102
+ views=graph_views(cmap, graph, max_functions=max_functions, max_depth=max_depth),
103
+ everything=everything_graph(cmap, graph),
102
104
  ),
103
105
  _Text(
104
106
  graph.mermaid(),
code_node/report/graph.py CHANGED
@@ -17,7 +17,8 @@ from ..kit.highlight import _python_html, _python_lines
17
17
  from ..kit.render import _CALL_RE, _prose
18
18
  from ..kit.text import _esc
19
19
 
20
- from ..analysis.graph import CodeGraph, GraphEdge, GraphNode
20
+ from ..analysis.findings import _short_name
21
+ from ..analysis.graph import CodeGraph, GraphEdge, GraphNode, GraphView
21
22
  from .brands import AWS_LOGOS, SERVICE_LOGOS, fills, logo, small_logo
22
23
  from .editor import editor_name, open_at
23
24
  from .style import _ROOT
@@ -36,6 +37,9 @@ class _Graph:
36
37
  empty: str = "(nothing to draw)"
37
38
  sources: dict[str, str] = field(default_factory=dict)
38
39
  editor: str = "" # a link to open a line in an editor, from editor_link(): each place in a panel links to it
40
+ views: list[GraphView] = field(default_factory=list) # with JavaScript, a box's line of calls not drawn opens the
41
+ # graph from that function, from these (graph_views())
42
+ everything: CodeGraph | None = None # ...and a button in the bar draws every function, this graph
39
43
 
40
44
 
41
45
  _EDGE_WORDS = {
@@ -999,12 +1003,32 @@ def _g_calls(count: int) -> str:
999
1003
  return f"{count} call" if count == 1 else f"{count} calls"
1000
1004
 
1001
1005
 
1006
+ def _g_prose_top(card: _GCard) -> float:
1007
+ """
1008
+ Where a box's prose starts: under its header, its route's pill and its when pill.
1009
+ """
1010
+ return card.head + (_GRAPH_ROUTE if card.route else 0) + (_GRAPH_ROUTE if card.node.when else 0)
1011
+
1012
+
1013
+ def _g_more_y(card: _GCard) -> float:
1014
+ """
1015
+ The baseline of a box's line of calls not drawn.
1016
+ """
1017
+ return _g_prose_top(card) + 13 + _GRAPH_PROSE * len(card.prose)
1018
+
1019
+
1002
1020
  def _node_svg(
1003
- card: _GCard, box: tuple[float, float, float, float], neighbours: list[str], access: dict[str, str]
1021
+ card: _GCard,
1022
+ box: tuple[float, float, float, float],
1023
+ neighbours: list[str],
1024
+ access: dict[str, str],
1025
+ panel: str = "",
1026
+ opens: bool = False,
1004
1027
  ) -> str:
1005
1028
  """
1006
1029
  A box: its badge and name, what's under it, a route's pill, its listed lines (an operation's dot in its arrows'
1007
- colour, a line a warning is about with its flag) or its docstring, and a warning badge.
1030
+ colour, a line a warning is about with its flag) or its docstring, and a warning badge. panel is the id of its
1031
+ code's panel, and opens says its line of calls not drawn opens a graph of its own (a link drawn over it).
1008
1032
  """
1009
1033
  n = card.node
1010
1034
  x, y, w, h = box
@@ -1071,15 +1095,18 @@ def _node_svg(
1071
1095
  if _g_sans(f"{said}: {names}", 12.5) <= w - 28:
1072
1096
  said = f"{said}: {names}"
1073
1097
  break
1074
- parts.append(
1075
- f'<text class="pr mo" x="14" y="{top + 13 + _GRAPH_PROSE * len(card.prose):.1f}">{_esc(said)}</text>'
1076
- )
1098
+ parts.append(f'<text class="pr mo md" x="14" y="{_g_more_y(card):.1f}">{_esc(said)}</text>')
1077
1099
  if warn:
1078
1100
  parts.append(
1079
1101
  f'<g class="wb" transform="translate({w - 21:.0f},{badge_y + _G_BADGE / 2:.0f})"><circle r="8.5"/>'
1080
1102
  '<path d="M0,-4.2V.6M0,3.9V4"/></g>'
1081
1103
  )
1082
- classes = " ".join(["cn", f"k-{n.kind}", f"n-{n.id}", *(f"a-{m}" for m in neighbours)]) + (" warn" if warn else "")
1104
+ classes = " ".join(
1105
+ ["cn", f"k-{n.kind}", f"n-{n.id}", *(f"a-{m}" for m in neighbours)]
1106
+ + ([f"p-{panel}"] if panel else [])
1107
+ + (["hd"] if opens else [])
1108
+ + (["warn"] if warn else [])
1109
+ )
1083
1110
  return (
1084
1111
  f'<g class="{_esc(classes)}" transform="translate({x:.1f},{y:.1f})">'
1085
1112
  f'<rect class="bg" width="{w:.0f}" height="{h:.0f}" rx="11"/>'
@@ -1114,11 +1141,11 @@ def _g_filled(line: str) -> bool:
1114
1141
  return bool(re.sub(r"<[^>]*>", "", line).strip())
1115
1142
 
1116
1143
 
1117
- def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "") -> str:
1144
+ def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "", panel: str = "") -> str:
1118
1145
  """
1119
- A box's code, in a panel that slides in from the right when the box is clicked (its id is the link's target, so
1120
- it's CSS's :target that opens it): what the box is, then each place it's about with its lines numbered, the lines
1121
- worth pointing at lit. '' when none of its code is in the files read.
1146
+ A box's code, in a panel that slides in from the right when the box is clicked (its id, panel or the box's own, is
1147
+ the link's target, so it's CSS's :target that opens it): what the box is, then each place it's about with its
1148
+ lines numbered, the lines worth pointing at lit. '' when none of its code is in the files read.
1122
1149
  """
1123
1150
  shown, lit_any = [], False
1124
1151
  for span in n.code[:_G_PANEL_SPANS]:
@@ -1157,7 +1184,11 @@ def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "
1157
1184
  rest = len(n.code) - _G_PANEL_SPANS
1158
1185
  if rest > 0:
1159
1186
  shown.append(f'<div class="csm">+{_plural(rest, "more place")}: the tables below list them all</div>')
1160
- said = "".join(f'<p class="csd">{_prose(line)}</p>' for line in (n.tip or "").split("\n") if line)
1187
+ said = "".join( # not the calls this graph leaves out: a function's panel is the same in every graph it's in
1188
+ f'<p class="csd">{_prose(line)}</p>'
1189
+ for line in (n.tip or "").split("\n")
1190
+ if line and not (n.more and line.startswith("It calls ") and "the graph leaves out" in line)
1191
+ )
1161
1192
  if lit_any:
1162
1193
  said += (
1163
1194
  '<p class="csd csl">The lit lines are where it calls, reads or sends what its arrows show.</p>'
@@ -1166,7 +1197,7 @@ def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "
1166
1197
  )
1167
1198
  mono = " mono" if n.kind in ("handler", "function") else ""
1168
1199
  return (
1169
- f'<aside class="cside" id="{_esc(_g_panel_id(n))}" aria-label="{_esc("The code of " + n.label)}">'
1200
+ f'<aside class="cside" id="{_esc(panel or _g_panel_id(n))}" aria-label="{_esc("The code of " + n.label)}">'
1170
1201
  f'<div class="csh"><div class="cst"><span class="csk">{_esc(_G_PANEL_KINDS.get(n.kind, n.kind))}</span>'
1171
1202
  f'<b class="csn{mono}">{_esc(n.label)}</b>'
1172
1203
  + (f'<span class="css">{_esc(n.sub)}</span>' if n.sub else "")
@@ -1368,12 +1399,87 @@ def _g_swatch(kind: str) -> str:
1368
1399
  )
1369
1400
 
1370
1401
 
1371
- @_draws(_Graph, "html")
1372
- def _graph_html(block: _Graph, report: _Report) -> str:
1373
- title = f"<h4>{_prose(block.title)}</h4>" if block.title else ""
1374
- graph = block.graph
1375
- if not graph.nodes:
1376
- return title + f'<div class="more">{_esc(block.empty)}</div>'
1402
+ _G_SCRIPT = """<script>
1403
+ (() => {
1404
+ // A graph's views: a box's line of calls not drawn, the path over a view and the bar's All button link to
1405
+ // #<group>-v<k> (or -all, or -main), and each is a <template> the graph's box, path and key are swapped from.
1406
+ // Any other address (a box's code open, or closed) leaves the view as it is.
1407
+ const parts = ["cgc", "cg", "cgk"];
1408
+ const show = (wrap) => {
1409
+ const group = wrap.dataset.group, id = decodeURIComponent(location.hash.slice(1));
1410
+ const named = id === group + "-main" || id === group + "-all" || id.startsWith(group + "-v");
1411
+ if (id && !named) return;
1412
+ const view = named && id !== group + "-main" ? id : "";
1413
+ if ((wrap.dataset.view || "") === view) return;
1414
+ const from = view ? document.getElementById(view)?.content : wrap.main;
1415
+ if (!from) return;
1416
+ for (const part of parts) {
1417
+ wrap.querySelector(".cgb > ." + part).innerHTML = from.querySelector(".x-" + part).innerHTML;
1418
+ }
1419
+ wrap.dataset.view = view;
1420
+ wrap.querySelector(".cgb").scrollIntoView({block: "nearest"});
1421
+ };
1422
+ for (const wrap of document.querySelectorAll(".cgw[data-group]")) {
1423
+ const main = document.createElement("template");
1424
+ for (const part of parts) {
1425
+ const kept = document.createElement("div");
1426
+ kept.className = "x-" + part;
1427
+ kept.innerHTML = wrap.querySelector(".cgb > ." + part).innerHTML;
1428
+ main.content.append(kept);
1429
+ }
1430
+ wrap.main = main.content;
1431
+ wrap.classList.add("js");
1432
+ show(wrap);
1433
+ }
1434
+ addEventListener("hashchange", () => document.querySelectorAll(".cgw.js").forEach(show));
1435
+ // A box's code: a click anywhere outside its panel closes it, as its × does, and so does Escape. A link (another
1436
+ // box, a view) and the graph's bar do what they do with the panel left to them.
1437
+ const close = () => { if (document.querySelector(".cside:target")) location.hash = "cn-closed"; };
1438
+ document.addEventListener("click", (e) => {
1439
+ const open = document.querySelector(".cside:target");
1440
+ if (open && !open.contains(e.target) && !e.target.closest("a[href], label, input, .cgt")) close();
1441
+ });
1442
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
1443
+ })();
1444
+ </script>"""
1445
+
1446
+
1447
+ def _g_hidden(graph: CodeGraph, opens: bool) -> str:
1448
+ """
1449
+ The legend's pill for the functions a graph leaves out, and why: without JavaScript where to find them, with it
1450
+ (when opens) that a box's line of them opens them.
1451
+ """
1452
+ if not graph.hidden:
1453
+ return ""
1454
+ why = (
1455
+ "too deep"
1456
+ if graph.too_deep == graph.hidden
1457
+ else "too many to draw at once"
1458
+ if not graph.too_deep
1459
+ else "too deep or too many"
1460
+ )
1461
+ head = f"<b>+{graph.hidden}</b>{'more function' if graph.hidden == 1 else 'more functions'} not drawn ({why})"
1462
+ pill = f'<span class="kp kh{" nj" if opens else ""}">{head}: the Step by step tab has them all</span>'
1463
+ if opens:
1464
+ pill += f'<span class="kp kh oj">{head}: click a box\'s blue line of calls to open them</span>'
1465
+ return pill
1466
+
1467
+
1468
+ def _g_canvas(
1469
+ graph: CodeGraph,
1470
+ block: _Graph,
1471
+ report: _Report,
1472
+ lines_of: Callable[[str], list[str]],
1473
+ panels: dict[str, tuple[str, str]],
1474
+ opens: dict[str, str],
1475
+ prefix: str,
1476
+ ) -> tuple[str, str]:
1477
+ """
1478
+ One graph drawn: its SVG and its legend. Its boxes' panels go into panels ({a function, or a panel without its
1479
+ id: (its id, the panel)}, one for a function or the same code wherever it's drawn, the first graph's; a new one's
1480
+ id starts with prefix), and a box whose function is in opens ({module.name: a
1481
+ link}) has its line of calls not drawn made that link.
1482
+ """
1377
1483
  cards = _g_cards(graph)
1378
1484
  boxes, routes, width, tall, xs, widths, column_of = _graph_layout(graph, cards)
1379
1485
  ends = _g_anchors(graph, cards, boxes)
@@ -1386,20 +1492,24 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1386
1492
  for name in _g_names(e):
1387
1493
  access[e.target].setdefault(name, e.kind)
1388
1494
  root = report.root or _ROOT
1389
- highlighted: dict[str, list[str]] = {}
1390
-
1391
- def lines_of(file: str) -> list[str]: # a file's lines in colour, each file coloured once
1392
- if file not in highlighted:
1393
- highlighted[file] = _python_lines(block.sources[file]) if file in block.sources else []
1394
- return highlighted[file]
1395
-
1396
- panels = {n.id: _g_panel(n, lines_of, block.editor) for n in graph.nodes} if block.sources else {}
1397
- for n in graph.nodes:
1398
- if panels.get(n.id): # the box whose code is open, outlined
1399
- report.rules.add(
1400
- f".{root} .cgw:has(#{_g_panel_id(n)}:target) .cg .n-{n.id} .fg"
1401
- "{stroke:light-dark(#2563eb,#60a5fa);stroke-opacity:1;stroke-width:2}"
1402
- )
1495
+ panel_of: dict[str, str] = {} # a box -> its panel's id
1496
+ if block.sources:
1497
+ for n in graph.nodes:
1498
+ same = "\0" + n.name if n.name else ""
1499
+ if same not in panels:
1500
+ html = _g_panel(n, lines_of, block.editor, "\0")
1501
+ if not html:
1502
+ continue
1503
+ same = same or html
1504
+ if same not in panels:
1505
+ key = prefix + _g_panel_id(n)
1506
+ panels[same] = (key, html.replace('id="\0"', f'id="{key}"', 1))
1507
+ panel_of[n.id] = panels[same][0]
1508
+ for key in set(panel_of.values()): # the box whose code is open, outlined
1509
+ report.rules.add(
1510
+ f".{root} .cgw:has(#{key}:target) .cg .p-{key} .fg"
1511
+ "{stroke:light-dark(#2563eb,#60a5fa);stroke-opacity:1;stroke-width:2}"
1512
+ )
1403
1513
  for n in graph.nodes: # pointing at a box leaves only it, its arrows and the boxes they join in sight
1404
1514
  report.rules.add(f".{root} .cg:has(.n-{n.id}:hover) .ce:not(.s-{n.id}):not(.t-{n.id}){{opacity:.06}}")
1405
1515
  report.rules.add(f".{root} .cg:has(.n-{n.id}:hover) .cn:not(.n-{n.id}):not(.a-{n.id}){{opacity:.28}}")
@@ -1446,16 +1556,33 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1446
1556
  marked.add((e.source, e.target))
1447
1557
  edges = "".join(body for body, _ in drawn)
1448
1558
  ports = "".join(dots for _, dots in drawn)
1559
+ opened = {n.id: opens[n.name] for n in graph.nodes if n.more and n.name in opens}
1449
1560
  nodes = "".join(
1450
1561
  (
1451
- f'<a class="cl" href="#{_esc(_g_panel_id(n))}" aria-label="{_esc("Show the code of " + n.label)}">{box}</a>'
1452
- if panels.get(n.id)
1562
+ f'<a class="cl" href="#{_esc(panel_of[n.id])}" aria-label="{_esc("Show the code of " + n.label)}">{box}</a>'
1563
+ if n.id in panel_of
1453
1564
  else box
1454
1565
  )
1455
1566
  for n in graph.nodes
1456
- for box in [_node_svg(cards[n.id], boxes[n.id], list(dict.fromkeys(near[n.id])), access[n.id])]
1567
+ for box in [
1568
+ _node_svg(
1569
+ cards[n.id],
1570
+ boxes[n.id],
1571
+ list(dict.fromkeys(near[n.id])),
1572
+ access[n.id],
1573
+ panel_of.get(n.id, ""),
1574
+ n.id in opened,
1575
+ )
1576
+ ]
1577
+ )
1578
+ links = "".join( # over each line of calls not drawn that opens a graph of its own: drawn with JavaScript only
1579
+ f'<a class="dr" href="#{_esc(link)}" aria-label="{_esc(f"Open the calls {n.label} makes")}">'
1580
+ f'<rect x="{boxes[n.id][0] + 8:.1f}" y="{boxes[n.id][1] + _g_more_y(cards[n.id]) - 14:.1f}" '
1581
+ f'width="{boxes[n.id][2] - 16:.0f}" height="19" rx="5"/></a>'
1582
+ for n in graph.nodes
1583
+ if (link := opened.get(n.id))
1457
1584
  )
1458
- tips = "".join(_g_tip_svg(n, boxes[n.id], width, tall, lines_of, bool(panels.get(n.id))) for n in graph.nodes)
1585
+ tips = "".join(_g_tip_svg(n, boxes[n.id], width, tall, lines_of, n.id in panel_of) for n in graph.nodes)
1459
1586
  kinds = {e.kind for e in graph.edges}
1460
1587
  pills = [f'<span class="kp">{_g_swatch(k)}{_esc(word)}</span>' for k, word in _G_LEGEND if k in kinds]
1461
1588
  if any(e.guessed for e in graph.edges):
@@ -1474,22 +1601,71 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1474
1601
  f'aria-hidden="true"><circle r="7"/><path transform="scale(.6) translate(-8,-8)" d="{_G_LOOP}"/>'
1475
1602
  "</svg>in a loop: once per item</span>"
1476
1603
  )
1477
- hidden = (
1478
- (
1479
- f'<span class="kp kh"><b>+{graph.hidden}</b>{"more function" if graph.hidden == 1 else "more functions"} '
1480
- "not drawn (too deep or too many): the Step by step tab has them all</span>"
1481
- )
1482
- if graph.hidden
1483
- else ""
1484
- )
1485
1604
  hint = (
1486
1605
  '<span class="kt">Point at a box to see only what it\'s joined to'
1487
- + ("; click it to read its code" if any(panels.values()) else "")
1606
+ + ("; click it to read its code" if panel_of else "")
1488
1607
  + "</span>"
1489
1608
  )
1609
+ svg = (
1610
+ f'<svg viewBox="0 0 {width:.0f} {tall:.0f}" width="{width:.0f}" height="{tall:.0f}" '
1611
+ f'style="--w:{width:.0f}px" role="img" aria-label="{_esc(block.title or "How the code fits together")}">'
1612
+ f"{_g_captions(graph, xs, widths, column_of)}{edges}{nodes}{links}{ports}{tips}</svg>"
1613
+ )
1614
+ return svg, "".join(pills) + _g_hidden(graph, bool(opened)) + hint
1615
+
1616
+
1617
+ def _g_path(path: list[str], links: dict[str, str], main: str, last: str = "") -> str:
1618
+ """
1619
+ The calls from the entry point down to a view's function, over its graph: each one that has a view of its own a
1620
+ link to it, and a link back to the whole graph first. last, when given, is said after them in place of the last.
1621
+ """
1622
+ names = [_esc(_short_name(name)) + "()" for name in path]
1623
+ steps = [
1624
+ f'<a href="#{_esc(links[name])}"><code>{said}</code></a>' if name in links else f"<code>{said}</code>"
1625
+ for name, said in zip(path[:-1], names[:-1])
1626
+ ]
1627
+ steps.append(_esc(last) if last else f"<b><code>{names[-1]}</code></b>" if names else "")
1628
+ return f'<a class="cgcb" href="#{_esc(main)}">← The whole graph</a>' + "".join(
1629
+ f'<span class="cgcs">›</span>{step}' for step in steps
1630
+ )
1631
+
1632
+
1633
+ @_draws(_Graph, "html")
1634
+ def _graph_html(block: _Graph, report: _Report) -> str:
1635
+ title = f"<h4>{_prose(block.title)}</h4>" if block.title else ""
1636
+ graph = block.graph
1637
+ if not graph.nodes:
1638
+ return title + f'<div class="more">{_esc(block.empty)}</div>'
1639
+ highlighted: dict[str, list[str]] = {}
1640
+
1641
+ def lines_of(file: str) -> list[str]: # a file's lines in colour, each file coloured once
1642
+ if file not in highlighted:
1643
+ highlighted[file] = _python_lines(block.sources[file]) if file in block.sources else []
1644
+ return highlighted[file]
1645
+
1490
1646
  made = report.shared("graphs", lambda: [0]) # graphs drawn so far, so each one's buttons have ids of their own
1491
1647
  group = f"cn-graph{made[0]}"
1492
1648
  made[0] += 1
1649
+ links = {view.name: f"{group}-v{k}" for k, view in enumerate(block.views, 1)}
1650
+ panels: dict[str, tuple[str, str]] = {}
1651
+ svg, legend = _g_canvas(graph, block, report, lines_of, panels, links, "")
1652
+ views = []
1653
+ for k, view in enumerate(block.views, 1):
1654
+ drawn, key = _g_canvas(view.graph, block, report, lines_of, panels, links, f"v{k}-")
1655
+ views.append(
1656
+ f'<template id="{group}-v{k}"><div class="x-cgc">{_g_path(view.path, links, group + "-main")}</div>'
1657
+ f'<div class="x-cg">{drawn}</div><div class="x-cgk">{key}</div></template>'
1658
+ )
1659
+ everything = ""
1660
+ if block.everything is not None and block.everything.nodes:
1661
+ count = sum(1 for n in block.everything.nodes if n.kind in ("handler", "function"))
1662
+ drawn, key = _g_canvas(block.everything, block, report, lines_of, panels, {}, "all-")
1663
+ views.append(
1664
+ f'<template id="{group}-all"><div class="x-cgc">'
1665
+ f"{_g_path([], links, group + '-main', f'Every function, all {count} of them')}</div>"
1666
+ f'<div class="x-cg">{drawn}</div><div class="x-cgk">{key}</div></template>'
1667
+ )
1668
+ everything = f'<a class="cgta oj" href="#{group}-all" title="Every function in one graph">All {count}</a>'
1493
1669
  # its zoom and full screen: inputs before the box they change, so CSS reaches it with ~ (no :has(), no script)
1494
1670
  switches = f'<input type="checkbox" class="cgs cgfs" id="{group}-full">' + "".join(
1495
1671
  f'<input type="radio" class="cgs cgz-{key}" name="{group}-zoom" id="{group}-{key}"'
@@ -1497,7 +1673,7 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1497
1673
  for key, _, _ in _GRAPH_ZOOMS
1498
1674
  )
1499
1675
  bar = (
1500
- '<div class="cgt"><span class="cgtz" role="group" aria-label="Zoom">'
1676
+ f'<div class="cgt">{everything}<span class="cgtz" role="group" aria-label="Zoom">'
1501
1677
  + "".join(
1502
1678
  f'<label class="zl-{key}" for="{group}-{key}" title="{_esc(tip)}">{_esc(word)}</label>'
1503
1679
  for key, word, tip in _GRAPH_ZOOMS
@@ -1505,14 +1681,18 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1505
1681
  + f'</span><label class="cgtf" for="{group}-full"><span class="on">Full screen</span>'
1506
1682
  '<span class="off">Close full screen</span></label></div>'
1507
1683
  )
1684
+ script = ""
1685
+ if (views or panels) and not report.shared("graph-script", lambda: [False])[0]:
1686
+ report.shared("graph-script", lambda: [False])[0] = True
1687
+ script = _G_SCRIPT
1508
1688
  # in a box of its own with its panels: the :has() rules that outline a box whose code is open look only in here,
1509
1689
  # so a radio button elsewhere (the walk's steps) doesn't make the browser restyle the whole page
1690
+ data = f' data-group="{group}"' if views else ""
1510
1691
  return (
1511
- f'{title}<div class="cgw">{switches}<div class="cgb">{bar}<div class="cg">'
1512
- f'<svg viewBox="0 0 {width:.0f} {tall:.0f}" width="{width:.0f}" height="{tall:.0f}" '
1513
- f'style="--w:{width:.0f}px" role="img" aria-label="{_esc(block.title or "How the code fits together")}">'
1514
- f"{_g_captions(graph, xs, widths, column_of)}{edges}{nodes}{ports}{tips}</svg></div>"
1515
- f'<div class="cgk">{"".join(pills)}{hidden}{hint}</div></div>{"".join(panels.values())}</div>'
1692
+ f'{title}<div class="cgw"{data}>{switches}<div class="cgb">{bar}'
1693
+ f'<nav class="cgc" aria-label="Where this graph starts"></nav><div class="cg">{svg}</div>'
1694
+ f'<div class="cgk">{legend}</div></div>{"".join(html for _, html in panels.values())}{"".join(views)}</div>'
1695
+ f"{script}"
1516
1696
  )
1517
1697
 
1518
1698
 
code_node/report/style.py CHANGED
@@ -125,6 +125,20 @@ _EXTRA_CSS = """\
125
125
  .cnd .cgfs:checked~.cgz-fit:checked~.cgb .cg svg{width:100%;height:100%;max-width:none}
126
126
  .cnd .cgfs:checked~.cgz-auto:checked~.cgb .cg svg{min-width:0}
127
127
  @media (min-width:1100px){.cnd .cgw:has(.cside:target) .cgfs:checked~.cgb{right:min(660px,94vw)}}
128
+ .cnd .cgw:not(.js) :is(.oj,.dr),.cnd .cgw.js .nj{display:none}
129
+ .cnd .cgta{padding:3px 10px;border:1px solid rgba(127,127,127,.28);border-radius:8px;color:inherit;text-decoration:none;opacity:.7}
130
+ .cnd .cgta:hover{opacity:.95;background:rgba(127,127,127,.08)}
131
+ .cnd .cgw[data-view$="-all"] .cgta{opacity:1;font-weight:650;background:rgba(127,127,127,.14)}
132
+ .cnd .cgc{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 7px;margin:0 0 6px;font-size:13px}
133
+ .cnd .cgc:empty{display:none}
134
+ .cnd .cgc a{color:light-dark(#2563eb,#60a5fa);text-decoration:none}
135
+ .cnd .cgc a:hover{text-decoration:underline}
136
+ .cnd .cgc code{font-family:var(--mono);font-size:12.5px}
137
+ .cnd .cgc .cgcs{opacity:.4}
138
+ .cnd .cg .dr rect{fill:transparent;cursor:pointer}
139
+ .cnd .cg .dr:hover rect{fill:rgba(37,99,235,.09)}
140
+ .cnd .cg .dr:focus-visible{outline:2px solid light-dark(#2563eb,#60a5fa)}
141
+ .cnd .cgw.js .cg .hd .md{fill:light-dark(#2563eb,#60a5fa);fill-opacity:1;font-style:normal}
128
142
  .cnd .cg .cap{font-size:12px;font-weight:650;letter-spacing:1.2px;fill:currentColor;fill-opacity:.6}
129
143
  .cnd .cg .capl{stroke:currentColor;stroke-opacity:.14;stroke-width:1;fill:none}
130
144
  .cnd .cg .cn,.cnd .cg .ce{transition:opacity .15s}
@@ -298,13 +312,27 @@ _EXTRA_CSS = """\
298
312
  .cnd .walk .ws{border-left:1px solid rgba(127,127,127,.2)}
299
313
  .cnd .walk .wch{position:sticky;top:0;z-index:2;padding:12px 16px 9px;font-size:12.5px;font-weight:650;background:var(--bg);border-bottom:1px solid rgba(127,127,127,.14)}
300
314
  .cnd .walk .wr{display:block;width:0;height:0;margin:0;border:0;padding:0;opacity:0;appearance:none;-webkit-appearance:none;pointer-events:none}
301
- .cnd .walk .wil{display:flex;align-items:center;gap:10px;min-height:30px;padding:0 14px 0 4px;cursor:pointer;white-space:nowrap;user-select:none;-webkit-user-select:none;transition:background-color .15s}
315
+ .cnd .walk .wil{display:flex;align-items:center;gap:10px;min-height:30px;padding:0 14px 0 4px;cursor:pointer;white-space:nowrap;user-select:none;-webkit-user-select:none;transition:background-color .2s,box-shadow .2s;animation:cnd-row .22s ease-out}
316
+ @keyframes cnd-row{from{opacity:0;transform:translateY(-3px)}}
317
+ .cnd .walk .wcx{position:absolute;width:0;height:0;margin:0;opacity:0;pointer-events:none}
318
+ .cnd .walk .wtg{position:relative;z-index:1;display:block;height:0}
319
+ .cnd .walk .wtg i{position:absolute;top:0;left:calc(40px + var(--d)*16px);width:16px;height:30px;border-radius:4px;cursor:pointer;opacity:.45;transition:opacity .15s,background-color .15s}
320
+ .cnd .walk .wtg i::before{content:"";position:absolute;left:5px;top:12px;width:5px;height:5px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;transform:rotate(45deg);transition:transform .2s cubic-bezier(.3,.7,.3,1)}
321
+ .cnd .walk .wtg:hover i{opacity:1;background:rgba(127,127,127,.14)}
322
+ .cnd .walk .wcx:checked+.wtg i{opacity:.8}
323
+ .cnd .walk .wcx:checked+.wtg i::before{transform:translate(-1px,1px) rotate(-45deg)}
324
+ .cnd .walk .wcnt{display:none;flex:none;font-size:11px;padding:0 7px;border-radius:9px;line-height:17px;background:rgba(127,127,127,.14);opacity:.75;animation:cnd-in .2s ease}
325
+ .cnd .walk .wcx:checked+.wtg+.wr+.wil .wcnt{display:inline-block}
302
326
  .cnd .walk .wil:hover{background-color:rgba(127,127,127,.08)}
303
327
  .cnd .walk .wr:checked+.wil{background-color:rgba(245,158,11,.16);box-shadow:inset 3px 0 #f59e0b}
304
328
  .cnd .walk .wr:focus-visible+.wil{outline:2px solid var(--blue);outline-offset:-2px}
305
329
  .cnd .walk .wno{flex:0 0 26px;text-align:right;font:11px var(--mono);opacity:.4;font-variant-numeric:tabular-nums}
306
- .cnd .walk .wr:checked+.wil .wno{opacity:.8}
307
- .cnd .walk .wx{display:flex;align-items:center;align-self:stretch;gap:8px;min-width:0;flex:1;padding-left:calc(var(--d)*16px);background:repeating-linear-gradient(90deg,rgba(127,127,127,.18) 0 1px,transparent 1px 16px) 5px 0/calc(var(--d)*16px) 100% no-repeat}
330
+ .cnd .walk .wr:checked+.wil .wno{opacity:.9;color:#d97706;font-weight:600}
331
+ .cnd .walk .wtx{transition:transform .2s ease}
332
+ .cnd .walk .wr:checked+.wil .wtx{transform:translateX(2px)}
333
+ .cnd .walk .wr:checked+.wil .wdot{animation:cnd-ping 1s ease-out 2}
334
+ @keyframes cnd-ping{from{box-shadow:0 0 0 0 var(--c,rgba(127,127,127,.6))}to{box-shadow:0 0 0 6px transparent}}
335
+ .cnd .walk .wx{display:flex;align-items:center;align-self:stretch;gap:8px;min-width:0;flex:1;padding-left:calc(var(--d)*16px + 18px);background:repeating-linear-gradient(90deg,rgba(127,127,127,.18) 0 1px,transparent 1px 16px) 8px 0/calc(var(--d)*16px) 100% no-repeat}
308
336
  .cnd .walk .wtx{min-width:0;overflow:hidden;text-overflow:ellipsis}
309
337
  .cnd .walk .wtx code{font:12px var(--mono);background:none;padding:0}
310
338
  .cnd .walk :is(.k-def,.k-module) .wtx b{font:650 12.5px var(--mono)}
@@ -312,7 +340,10 @@ _EXTRA_CSS = """\
312
340
  .cnd .walk .wdot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--c,rgba(127,127,127,.6))}
313
341
  .cnd .walk :is(.d-aws-reads,.d-lib-reads){--c:#3b82f6}.cnd .walk :is(.d-aws-writes,.d-lib-writes){--c:#ea580c}
314
342
  .cnd .walk :is(.d-aws-sends,.d-lib-sends,.d-http){--c:#db2777}.cnd .walk :is(.d-aws-runs,.d-lib-runs){--c:#9333ea}.cnd .walk .d-aws-signs{--c:#64748b}
315
- .cnd .walk .wc{display:none;position:absolute;left:var(--lw);right:0;top:0;height:var(--ch);box-sizing:border-box;overflow:auto;padding:14px 22px 12px;white-space:normal;cursor:auto;border-left:1px solid rgba(127,127,127,.2);border-bottom:1px solid rgba(127,127,127,.18);background:var(--bg);animation:cnd-in .18s ease}
343
+ .cnd .walk .wc{display:none;position:absolute;left:var(--lw);right:0;top:0;height:var(--ch);box-sizing:border-box;overflow:auto;padding:14px 22px 12px;white-space:normal;cursor:auto;border-left:1px solid rgba(127,127,127,.2);border-bottom:1px solid rgba(127,127,127,.18);background:var(--bg)}
344
+ .cnd .walk .wc>*{animation:cnd-in .24s ease both}
345
+ .cnd .walk .wc>.whead{animation-delay:.03s}.cnd .walk .wc>.wstk{animation-delay:.06s}.cnd .walk .wc>.wsay{animation-delay:.09s}.cnd .walk .wc>.wthen{animation-delay:.12s}
346
+ .cnd .walk .wfacts li{animation:cnd-in .24s ease both;animation-delay:.12s}.cnd .walk .wfacts li:nth-child(2){animation-delay:.15s}.cnd .walk .wfacts li:nth-child(n+3){animation-delay:.18s}
316
347
  .cnd .walk .wr:checked+.wil+.wc{display:block}
317
348
  .cnd .walk .wnav{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
318
349
  .cnd .walk .wbt{display:inline-flex;align-items:center;gap:5px;padding:4px 14px;border:1px solid rgba(127,127,127,.32);border-radius:8px;font-size:13px;font-weight:600;line-height:20px;cursor:pointer;user-select:none;-webkit-user-select:none;transition:background-color .12s,transform .06s}
@@ -348,7 +379,9 @@ _EXTRA_CSS = """\
348
379
  .cnd .walk :is(.wfm,.wfv){opacity:.6}
349
380
  .cnd .walk .wthen{margin-top:10px;font-size:12px;opacity:.6}
350
381
  .cnd .walk .wf{display:none;position:absolute;left:var(--lw);right:0;top:var(--ch);bottom:0;flex-direction:column;border-left:1px solid rgba(127,127,127,.2);background:var(--bg)}
351
- .cnd .walk .wfn>.wr:checked~.wf{display:flex}
382
+ .cnd .walk .wfn>.wr:checked~.wf{display:flex;animation:cnd-fade .25s ease}
383
+ .cnd .walk .wfh{animation:cnd-in .25s ease}
384
+ @keyframes cnd-fade{from{opacity:0}}
352
385
  .cnd .walk .wfh{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;padding:8px 22px;font-size:12px;border-bottom:1px solid rgba(127,127,127,.12)}
353
386
  .cnd .walk .wfh b{font:650 12.5px var(--mono)}
354
387
  .cnd .walk .wfh span{opacity:.62}
@@ -356,15 +389,16 @@ _EXTRA_CSS = """\
356
389
  .cnd .walk .wfw{position:relative;flex:1;min-height:0;overflow-x:auto;overflow-y:hidden;container-type:size;background:rgba(127,127,127,.03)}
357
390
  .cnd .walk .wfp{position:relative;box-sizing:border-box;width:max-content;min-width:100%;padding:var(--pad) 0;font:12.5px/var(--lh) var(--mono);
358
391
  translate:0 min(0px,max(calc(100cqh - var(--n)*var(--lh) - 2*var(--pad)),calc(36cqh - var(--k,0)*var(--lh))));transition:translate .32s cubic-bezier(.25,.8,.25,1)}
359
- .cnd .walk .wbar{position:absolute;left:0;right:0;top:calc(var(--pad) + var(--k,-9)*var(--lh));height:var(--lh);background:rgba(245,158,11,.2);box-shadow:inset 3px 0 #f59e0b;transition:top .32s cubic-bezier(.25,.8,.25,1)}
392
+ .cnd .walk .wbar{position:absolute;left:0;right:0;top:calc(var(--pad) + var(--k,-9)*var(--lh));height:var(--lh);background:rgba(245,158,11,.2);box-shadow:inset 3px 0 #f59e0b;transition:top .32s cubic-bezier(.25,.8,.25,1);animation:cnd-glow 1.2s ease-in-out 2}
393
+ @keyframes cnd-glow{50%{background:rgba(245,158,11,.3)}}
360
394
  .cnd .walk .sl{position:relative;display:block;padding-right:16px;white-space:pre}
361
395
  .cnd .walk span.sl{opacity:.6}
362
- .cnd .walk label.sl{cursor:pointer}
396
+ .cnd .walk label.sl{cursor:pointer;transition:background-color .15s}
363
397
  .cnd .walk label.sl:hover{background:rgba(127,127,127,.1)}
364
398
  .cnd .walk .sn{display:inline-block;width:3.2em;padding-right:14px;text-align:right;opacity:.42;user-select:none;-webkit-user-select:none}
365
399
  .cnd .walk label.sl .sn{opacity:.8}
366
400
  @media (max-width:860px){.cnd .walk{--lt:40vh;grid-template-columns:minmax(0,1fr);grid-template-rows:var(--lt) 1fr;height:calc(var(--lt) + var(--ch) + 380px)}.cnd .walk .wl{max-height:none}.cnd .walk :is(.wc,.wf){left:0;border-left:0}.cnd .walk .wc{top:var(--lt);border-top:1px solid rgba(127,127,127,.2);padding:12px 16px}.cnd .walk .wf{top:calc(var(--lt) + var(--ch))}.cnd .walk .ws{border-left:0}}
367
- @media (prefers-reduced-motion:reduce){.cnd .walk .wfp,.cnd .walk .wbar,.cnd .walk .wc,.cnd .tabs>.tp{transition:none;animation:none}}"""
401
+ @media (prefers-reduced-motion:reduce){.cnd .walk :is(.wfp,.wbar,.wc,.wc>*,.wfacts li,.wil,.wtx,.wdot,.wcnt,.wf,.wfh,.wtg i,.wtg i::before),.cnd .tabs>.tp{transition:none;animation:none}}"""
368
402
 
369
403
  _CSS = _base_css(_ROOT, _EXTRA_CSS)
370
404
 
code_node/report/walk.py CHANGED
@@ -7,6 +7,11 @@ No JavaScript: each step is a radio button (one group, so once one has focus the
7
7
  Each function's steps sit in a box of their own, with the function's code drawn once at the end of it; the CSS shows
8
8
  the card after the checked button and the code of its box with sibling selectors, so the light slides from line to
9
9
  line as you step, and a step costs the same however many there are.
10
+
11
+ The list folds like a tree: a step with steps under it has a checkbox before it, and its arrow folds them away. Each
12
+ step under it carries the class `wu<step>`, so one rule per fold hides them, whether they sit beside it or inside the
13
+ box of a function it calls (`#cx5:checked ~ .wu5, #cx5:checked ~ * .wu5`). Only the steps' rows are hidden: the
14
+ current one's card and code still show when it's folded away.
10
15
  """
11
16
 
12
17
  from __future__ import annotations
@@ -523,11 +528,29 @@ def _frame_html(index: int, frame: _Frame, rows: list[tuple[int, str]]) -> str:
523
528
  )
524
529
 
525
530
 
526
- def _item_html(stops: list[_Stop], i: int, card: str) -> str:
531
+ def _folds(stops: list[_Stop]) -> tuple[list[int], list[list[int]]]:
532
+ """
533
+ How many steps sit under each stop in the list, and the stops each one sits under, outermost first.
534
+ """
535
+ under = [0] * len(stops)
536
+ above: list[list[int]] = []
537
+ opened: list[int] = []
538
+ for i, stop in enumerate(stops):
539
+ while opened and (stops[opened[-1]].depth >= stop.depth or stops[opened[-1]].chapter != stop.chapter):
540
+ opened.pop()
541
+ for j in opened:
542
+ under[j] += 1
543
+ above.append(list(opened))
544
+ opened.append(i)
545
+ return under, above
546
+
547
+
548
+ def _item_html(stops: list[_Stop], i: int, card: str, under: int = 0, above: tuple[int, ...] = ()) -> str:
527
549
  """
528
550
  A step in the list: its radio button, then its number and what it does in a few words (or, for a function it goes
529
551
  into, the function's name and file, as a heading over its steps) as the button's label, then its card, which shows
530
- (beside the list) while the button is checked.
552
+ (beside the list) while the button is checked. under: how many steps sit under it, folded away by the checkbox and
553
+ arrow before it; above: the steps it sits under, whose folds hide it.
531
554
  """
532
555
  stop = stops[i]
533
556
  step = stop.step
@@ -543,10 +566,18 @@ def _item_html(stops: list[_Stop], i: int, card: str) -> str:
543
566
  dot = f'<span class="wdot d-{said}" title="{_esc(_NOTE_TIPS.get(said, ""))}"></span>' if said in _DOTS else ""
544
567
  text = f'<span class="wtx">{headline}</span>{dot}'
545
568
  checked = " checked" if i == 0 else ""
569
+ hidden_by = "".join(f" wu{j}" for j in above)
570
+ fold = ""
571
+ if under:
572
+ fold = (
573
+ f'<input type="checkbox" class="wcx" id="cx{i}" tabindex="-1"><label class="wtg{hidden_by}" for="cx{i}" '
574
+ f'style="--d:{stop.depth}" title="Fold or unfold the {_plural(under, "step")} under it"><i></i></label>'
575
+ )
576
+ text += f'<span class="wcnt">{_plural(under, "step")}</span>'
546
577
  return (
547
- f'<input type="radio" class="wr" name="{_GROUP}" id="cw{i}"{checked}><label class="wil k-{_esc(step.kind)}" '
548
- f'for="cw{i}" style="--d:{stop.depth}"><span class="wno">{i + 1}</span><span class="wx">{text}</span>'
549
- f"</label>{card}"
578
+ f'{fold}<input type="radio" class="wr" name="{_GROUP}" id="cw{i}"{checked}><label class="wil '
579
+ f'k-{_esc(step.kind)}{hidden_by}" for="cw{i}" style="--d:{stop.depth}"><span class="wno">{i + 1}</span>'
580
+ f'<span class="wx">{text}</span></label>{card}'
550
581
  )
551
582
 
552
583
 
@@ -576,6 +607,7 @@ def _walk_html(block: _Walk, report: _Report) -> str:
576
607
  rows = _frame_rows(frame, highlighted.get(frame.file, []), stops)
577
608
  rows_of.append({number: n for n, (number, _) in enumerate(rows) if number})
578
609
  drawn.append(_frame_html(k, frame, rows))
610
+ under, above = _folds(stops)
579
611
  out: list[str] = []
580
612
  opened: list[int] = [] # the frames whose boxes are open, innermost last
581
613
  chapter = -1
@@ -593,7 +625,9 @@ def _walk_html(block: _Walk, report: _Report) -> str:
593
625
  if not opened or opened[-1] != stop.frame: # into a call
594
626
  out.append('<div class="wfn">')
595
627
  opened.append(stop.frame)
596
- out.append(_item_html(stops, i, _card_html(stops, i, first, docs, block.editor)))
628
+ out.append(_item_html(stops, i, _card_html(stops, i, first, docs, block.editor), under[i], tuple(above[i])))
629
+ if under[i]: # its fold hides the steps under it, beside it or in the boxes of what it calls
630
+ report.rules.add(f".{root} #cx{i}:checked~.wu{i},.{root} #cx{i}:checked~* .wu{i}{{display:none}}")
597
631
  at = rows_of[stop.frame].get(stop.lit)
598
632
  if at is not None: # the line its code lights
599
633
  report.rules.add(f".{root} #cw{i}:checked~.wf{stop.frame}{{--k:{at}}}")
@@ -601,7 +635,8 @@ def _walk_html(block: _Walk, report: _Report) -> str:
601
635
  out.append(drawn[opened.pop()] + "</div>")
602
636
  sub = (
603
637
  f'<div class="wsub">{_plural(len(stops), "step")}, in the order the code runs. Press Next or click a step to '
604
- "light its line in the code; after a click, <kbd>↑</kbd> <kbd>↓</kbd> move through them.</div>"
638
+ "light its line in the code; after a click, <kbd>↑</kbd> <kbd>↓</kbd> move through them. The arrow beside a "
639
+ "step folds the steps under it away.</div>"
605
640
  )
606
641
  return (
607
642
  f'{title}{sub}<div class="walk"><div class="wl" role="radiogroup" aria-label="The steps">{"".join(out)}'
code_node/serve.py CHANGED
@@ -3,8 +3,8 @@ code-node --serve: the page on a local server, made again whenever a .py file (o
3
3
  under the folder changes, and the browser told to reload (keeping its scroll position, and the tab and step that were
4
4
  open). Standard library only: a polling watcher thread, a threaded HTTP server, and Server-Sent Events for the reload.
5
5
 
6
- The saved pages have no JavaScript; the served one gets a few lines (_LIVE_SCRIPT) to reload and to say when the
7
- server has stopped.
6
+ The served page gets a few lines more than a saved one (_LIVE_SCRIPT), to reload and to say when the server has
7
+ stopped.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: code-node-graph
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: See how Python code works without running it: a graph of who calls whom from an entry point, and what each path reaches.
5
5
  Project-URL: Homepage, https://github.com/utkarsh5026/code-node
6
6
  Project-URL: Documentation, https://utkarsh5026.github.io/code-node/
@@ -1,16 +1,16 @@
1
- code_node/__init__.py,sha256=sfJ6RjFtxsJMr0-zronmOKVpmXZECgEUsWBV3BKfHrA,1106
1
+ code_node/__init__.py,sha256=hzITZuWbNbw6NyeHwo4MC9MApmpFcoJwdY43MsPrPQw,1106
2
2
  code_node/__main__.py,sha256=0W2bmerF1S96rjlTNJZKVzO4FvVhN6ENzAl451A08sQ,101
3
3
  code_node/cli.py,sha256=GI0vZY1t4Pf7qtZI2coTpWZi-ulQEWNREDJPkTtmxG0,12302
4
4
  code_node/pages.py,sha256=2UOrHUz3G_vAjPO89phJxhF4652FysVLgrLtJ-Iw6p4,3234
5
5
  code_node/project.py,sha256=1s6pBwQ-hiEf9v0YzE8b6TETT9E0XBnzQPWGjYDJsnw,16361
6
- code_node/serve.py,sha256=T09QSIuO1uR40xe9wD7btCroVFwEScZadfS71KZB_yQ,11468
6
+ code_node/serve.py,sha256=4yvzDaaL1XpsVinLmvlm1W2bM6WJRGe5GI8wG3XuHic,11456
7
7
  code_node/analysis/__init__.py,sha256=-KM77rYv9pPutUG29DsU8ZkAUtr2AU9RsqrDPFAjLoQ,103
8
8
  code_node/analysis/aws_model.py,sha256=R2sHppdu9PmlsOGDXf6_6JHumbA5PqbiJ0JgnyB9Xcs,6648
9
9
  code_node/analysis/findings.py,sha256=S1-VQ1iNi1PkC-2eigNzbJrULa_2Y46avmtwAlV9iqU,19010
10
- code_node/analysis/graph.py,sha256=obWKdYHXGEe_yL6YZAAWq0BJ1bTG0oPv9TVYAABRkrU,33624
10
+ code_node/analysis/graph.py,sha256=4FCzBo3G0XOkXV-zkshFxaoCalqWqbJfGDGDe-pg8S8,39373
11
11
  code_node/analysis/mapping.py,sha256=6wYkUqaQdM-Vio8XGf_Lw8N9dP_90YtbUxauH4IQ8Tg,5102
12
12
  code_node/analysis/model.py,sha256=ORGT6BEQFonQjdRGPEbuGVGoyyK54nTEA4Th-7rEGvU,13614
13
- code_node/analysis/reader.py,sha256=jVm4DSHlM9X7C2Rm1H7GpJ29_cXOSUNpr71Y1xZ35ws,88119
13
+ code_node/analysis/reader.py,sha256=QIqVN2RV477ON69_5YVLXhfnfL3seS8kvP7krpmZPNI,89304
14
14
  code_node/analysis/syntax.py,sha256=wyeUr68IDjGp3i9HYYYzcjw8-CvL6a6mmZdWz5LY8j8,7952
15
15
  code_node/analysis/terms.py,sha256=ec0f51DkfDRixJSRX1hnyq9Hr5aLRhXuBRHlNJBlZD8,13782
16
16
  code_node/deploys/__init__.py,sha256=QEH2oQNPACPekg6QSBpfSbqkuhrdNDpelhn_VaY0RNs,11572
@@ -32,13 +32,13 @@ code_node/platforms/aws_lambda.py,sha256=IoYxJ2IPNkuxXMWSRK5mxaaDtmKQsyq7Q1EkXCp
32
32
  code_node/platforms/web.py,sha256=lNjwkz3D0zJkFcJhqD_ShcfTj4uF8miLNqKMtXu-t1U,11762
33
33
  code_node/report/__init__.py,sha256=l3dfZFbLEo-7Fl7O_Rk2lvyAElQnyugSZPI_PzHrG_Q,76
34
34
  code_node/report/brands.py,sha256=n32BfFz2d02ZVUHvy8L4wPHBtiD_1FCTKzdR4NyvU88,8372
35
- code_node/report/build.py,sha256=F0wdC0ibu3WSAenUsovUWuKwLC0vVxnNYKJVXtU5jRk,4644
35
+ code_node/report/build.py,sha256=xnPK_6FMTSV2nyS-N6KcApdq3ZNBA_0Hy8AUZDVIea0,4884
36
36
  code_node/report/editor.py,sha256=TRUX6BTB-oe-m5Vf3xM9PppnC99sCLL4PObXkYAGKoU,1466
37
- code_node/report/graph.py,sha256=YbrVWbymYUL8-KOdsDSRhD2RxCMWbGVEhHD0-MSjX1I,66832
37
+ code_node/report/graph.py,sha256=1SEMabLWWL9E0kwhKLYZhRFj0pRzLk4eDpHfLsxuuiQ,75162
38
38
  code_node/report/outline.py,sha256=3RRxU4FMUZKBVjFDsscKZkvDlj_W4ZeW4DwUztg4wd0,8539
39
- code_node/report/style.py,sha256=zTOZ552sasyRASnm8-P8XNzashtxHe38RkvOZTMDm24,33743
39
+ code_node/report/style.py,sha256=YTQXlWjsnVfAZBhTr_Ot0PjdyLRSW83WDR3F02YBm4E,36833
40
40
  code_node/report/tabs.py,sha256=K2XR16owfgEL1kfux61PI9nBcRsgjQurTx1ptMM5Mac,3016
41
- code_node/report/walk.py,sha256=jumSV-x1X0D614V7K0zECCSWk69OkHNR7IuDt8K4ggg,27516
41
+ code_node/report/walk.py,sha256=LWJDGJ3efF0fPWlMO4G4HBQqnNVn2RHXgLeuLrC-WnA,29466
42
42
  code_node/report/logos/README.md,sha256=8Evw8eApl_56QJrwY7LAtGgJMZE0mp0-HIpRz9bu1ME,613
43
43
  code_node/report/logos/anthropic-icon.svg,sha256=-_25zx86lfDTm-ksVXkLE4uQ_ldzLbTd3U0z-wjT-JA,302
44
44
  code_node/report/logos/aws-amplify.svg,sha256=62WXM0XCn4WrQYWcVZFz4ln62xidiqF8eO4ELlgO8hg,531
@@ -171,8 +171,8 @@ code_node/report/logos/small/aws-systems-manager.svg,sha256=9VzzFdTjaRZO9ulR8Ulp
171
171
  code_node/report/logos/small/aws-timestream.svg,sha256=yKdKIj7pbooFqDKClEQxW9h7dYV5mnDn0RCHnLUDf8Q,639
172
172
  code_node/report/logos/small/aws-waf.svg,sha256=l9ZzBZAl2g8bK1-m6A50ga37l5g1t-9Mj70bxWDykWE,591
173
173
  code_node/report/logos/small/aws-xray.svg,sha256=VKg_QtKGZKDCbZvKBRDn7eReuNVbJpduBUVV8MQrZZU,591
174
- code_node_graph-0.2.0.dist-info/METADATA,sha256=BkbfZl6ViABwgsSH4QAJoGIkbbTNyfAp6FuPyo9JJuA,7619
175
- code_node_graph-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
176
- code_node_graph-0.2.0.dist-info/entry_points.txt,sha256=eEArp-uk7ZzODIWr6l62rI0-mIA0-mLDaqJEyKikPJw,49
177
- code_node_graph-0.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
178
- code_node_graph-0.2.0.dist-info/RECORD,,
174
+ code_node_graph-0.4.0.dist-info/METADATA,sha256=7tOOzTXKx0lvUJUZRXK4KfuWwvi_lz3RDPhnpnTn3aE,7619
175
+ code_node_graph-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
176
+ code_node_graph-0.4.0.dist-info/entry_points.txt,sha256=eEArp-uk7ZzODIWr6l62rI0-mIA0-mLDaqJEyKikPJw,49
177
+ code_node_graph-0.4.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
178
+ code_node_graph-0.4.0.dist-info/RECORD,,