code-node-graph 0.4.0__py3-none-any.whl → 0.6.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.4.0"
16
+ __version__ = "0.6.0"
17
17
 
18
18
  __all__ = [
19
19
  "CodeGraph",
@@ -15,7 +15,7 @@ from ..kit.text import _clip
15
15
 
16
16
  from .syntax import _a
17
17
  from .aws_model import _service_name
18
- from .model import AwsCall, CodeFunction, CodeMap, CodeSpot, CodeStep, LibraryCall
18
+ from .model import AwsCall, CodeFunction, CodeMap, CodeSpot, CodeStep, History, LibraryCall
19
19
  from .findings import _listed, _short_name
20
20
  from ..platforms import get_platform
21
21
 
@@ -56,6 +56,7 @@ class GraphNode:
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
58
  name: str = "" # a function's box: the function, as module.name ('orders.repo.save')
59
+ history: History | None = None # a function's box: what git says about its lines
59
60
 
60
61
 
61
62
  @dataclass
@@ -834,6 +835,7 @@ def code_graph(
834
835
  when=_when(f, f.called_from),
835
836
  icon=platform.entry_icon(cmap) if is_entry and not focus else "",
836
837
  name=f.name,
838
+ history=f.history,
837
839
  )
838
840
  )
839
841
  out_column = 1 + max([n.column for n in graph.nodes if n.kind in ("handler", "function", "start")] + [1])
@@ -173,6 +173,62 @@ class EventField:
173
173
  return _event_label(self.path)
174
174
 
175
175
 
176
+ @dataclass
177
+ class Commit:
178
+ """
179
+ A commit, as git blame tells it.
180
+ """
181
+
182
+ sha: str
183
+ author: str
184
+ time: int # when it was made, in seconds since 1970
185
+ subject: str # its message's first line
186
+ url: str = "" # its page on the repository's host, when that's GitHub or GitLab
187
+
188
+ @property
189
+ def short(self) -> str:
190
+ return self.sha[:7]
191
+
192
+
193
+ @dataclass
194
+ class History:
195
+ """
196
+ What git says about a function's lines as they are now: the commit that last changed one of them, how many commits
197
+ and people they come from, and how many have changed since the last commit. Read by history.py; None on a
198
+ function when the folder isn't in git.
199
+ """
200
+
201
+ last: Commit | None # the newest commit among its lines; None when none of them is committed yet
202
+ commits: int # the commits its lines come from
203
+ authors: list[str] # who wrote them, most lines first
204
+ first: int # when the oldest of them was made (about when the function was written), in seconds since 1970
205
+ uncommitted: int # its lines changed since the last commit
206
+ now: int # when this was read, for 'three days ago'
207
+ lines: dict[int, Commit | None] = field(default_factory=dict) # each of its lines -> the commit it comes from,
208
+ # None for one changed since the last commit
209
+
210
+ def ago(self, time: int) -> str:
211
+ """
212
+ 'today', '3 days ago', '2 months ago', '1 year ago'.
213
+ """
214
+ days = max(0, self.now - time) // 86_400
215
+ for size, unit in ((365, "year"), (30, "month"), (7, "week"), (1, "day")):
216
+ if days >= size:
217
+ count = days // size
218
+ return f"{count} {unit}{'' if count == 1 else 's'} ago"
219
+ return "today"
220
+
221
+ def since(self, time: int) -> str:
222
+ """
223
+ The same, short, for beside a line: 'today', '3d', '2w', '5mo', '1y'.
224
+ """
225
+ days = max(0, self.now - time) // 86_400
226
+ for size, unit in ((365, "y"), (30, "mo"), (7, "w"), (1, "d")):
227
+ if days >= size:
228
+ return f"{days // size}{unit}"
229
+ return "today"
230
+
231
+
176
232
  @dataclass
177
233
  class CodeFunction:
178
234
  """
@@ -190,6 +246,7 @@ class CodeFunction:
190
246
  # nearest around the call, else the nearest `if x: return` above it ('args.serve', not every check before it)
191
247
  depth: int = 0 # calls from the entry point (the entry point is 0)
192
248
  is_async: bool = False # async def: a call that blocks in it holds up everything else on its event loop
249
+ history: History | None = None # what git says about its lines, when the folder is in git (history.py)
193
250
 
194
251
 
195
252
  @dataclass
code_node/cli.py CHANGED
@@ -99,6 +99,9 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
99
99
  default="vscode",
100
100
  help="the editor the page's line links open (default vscode; none for no links)",
101
101
  )
102
+ parser.add_argument(
103
+ "--no-git", action="store_true", help="leave out what git says about each function (who changed it, when)"
104
+ )
102
105
  return parser.parse_args(argv)
103
106
 
104
107
 
@@ -286,7 +289,12 @@ def main(argv: list[str] | None = None) -> int:
286
289
  if entry is None:
287
290
  return 1
288
291
  options = PageOptions(
289
- draw_all=args.all, functions=args.functions, depth=args.depth, platform=args.platform, editor=args.editor
292
+ draw_all=args.all,
293
+ functions=args.functions,
294
+ depth=args.depth,
295
+ platform=args.platform,
296
+ editor=args.editor,
297
+ git=not args.no_git,
290
298
  )
291
299
  if args.mermaid:
292
300
  print(make_mermaid(project.root, entry, options))
code_node/history.py ADDED
@@ -0,0 +1,156 @@
1
+ """
2
+ What git says about the functions a map shows: `git blame` on the lines of each one, once per file, for the commit
3
+ that last changed it, how many commits and people its lines come from, the lines changed since the last commit, and
4
+ the commit each line comes from.
5
+
6
+ git is run on the folder, never the code in it, and only to read: blame with no text conversion and no file system
7
+ monitor. A folder whose own git config names a filter command (one git would run on the files) isn't read at all.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import time
16
+ from collections import Counter
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from pathlib import Path
19
+
20
+ from .analysis.model import CodeFunction, Commit, History
21
+
22
+ _TIMEOUT = 20 # seconds a git command may take before its file goes without history
23
+
24
+ _WORKERS = 8 # files blamed at once
25
+
26
+ _UNCOMMITTED = "0" * 40 # the commit blame gives lines changed since the last one
27
+
28
+ _HOSTS = {"github.com": "{}/commit/{}", "gitlab.com": "{}/-/commit/{}"} # a commit's page, by host
29
+
30
+ _SAFE = ["-c", "core.fsmonitor=false", "--no-pager"] # no program of the folder's config runs
31
+
32
+
33
+ def _git(root: Path, *args: str) -> str | None:
34
+ """
35
+ What a git command prints, run in root; None when git isn't there or fails (not a repository, a file it doesn't
36
+ track, a folder it doesn't trust).
37
+ """
38
+ try:
39
+ done = subprocess.run(
40
+ ["git", *_SAFE, "-C", str(root), *args],
41
+ capture_output=True,
42
+ text=True,
43
+ encoding="utf-8",
44
+ errors="replace",
45
+ timeout=_TIMEOUT,
46
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"},
47
+ )
48
+ except (OSError, subprocess.SubprocessError):
49
+ return None
50
+ return done.stdout if done.returncode == 0 else None
51
+
52
+
53
+ def _runs_filters(root: Path) -> bool:
54
+ """
55
+ Whether the folder's own git config (not the user's) names a filter command, which blame would run on its files.
56
+ """
57
+ found = _git(root, "config", "--show-scope", "--get-regexp", r"^filter\..*\.(clean|process)$")
58
+ return any(line.split("\t", 1)[0] in ("local", "worktree") for line in (found or "").splitlines())
59
+
60
+
61
+ def _commit_page(remote: str) -> str:
62
+ """
63
+ The repository's web address with a {} for the sha, from its origin: 'https://github.com/o/r/commit/{}'.
64
+ """
65
+ found = re.fullmatch(r"(?:https?://(?:[^@/]+@)?|ssh://(?:[^@/]+@)?|[^@/]+@)([^/:]+)[:/](.+?)(?:\.git)?/?", remote)
66
+ if not found or found.group(1) not in _HOSTS:
67
+ return ""
68
+ return _HOSTS[found.group(1)].format(f"https://{found.group(1)}/{found.group(2)}", "{}")
69
+
70
+
71
+ def _blame(root: Path, file: str, start: int, end: int) -> dict[int, tuple[str, dict[str, str]]]:
72
+ """
73
+ Each line from start to end of the file as it is now: line -> (the commit it comes from, that commit's headers).
74
+ """
75
+ out = _git(root, "blame", "--porcelain", "--no-textconv", "-L", f"{start},{end}", "--", file)
76
+ lines: dict[int, tuple[str, dict[str, str]]] = {}
77
+ if not out:
78
+ return lines
79
+ headers: dict[str, dict[str, str]] = {}
80
+ sha = ""
81
+ for row in out.splitlines():
82
+ if row.startswith("\t"):
83
+ continue
84
+ parts = row.split(" ")
85
+ if len(parts) >= 3 and len(parts[0]) == 40 and parts[1].isdigit() and parts[2].isdigit():
86
+ sha = parts[0]
87
+ headers.setdefault(sha, {})
88
+ lines[int(parts[2])] = (sha, headers[sha])
89
+ elif sha:
90
+ key, _, value = row.partition(" ")
91
+ headers[sha].setdefault(key, value)
92
+ return lines
93
+
94
+
95
+ def _history(blamed: dict[int, tuple[str, dict[str, str]]], start: int, end: int, page: str, now: int) -> History:
96
+ """
97
+ A function's history from its lines' blame.
98
+ """
99
+ commits: dict[str, Commit] = {}
100
+ by_author: Counter[str] = Counter()
101
+ uncommitted = 0
102
+ lines: dict[int, Commit | None] = {}
103
+ for k in range(start, end + 1):
104
+ if k not in blamed:
105
+ continue
106
+ sha, said = blamed[k]
107
+ if sha == _UNCOMMITTED:
108
+ uncommitted += 1
109
+ lines[k] = None
110
+ continue
111
+ if sha not in commits:
112
+ commits[sha] = Commit(
113
+ sha,
114
+ said.get("author", "?"),
115
+ int(said.get("author-time", "0") or 0),
116
+ said.get("summary", ""),
117
+ page.format(sha) if page else "",
118
+ )
119
+ by_author[commits[sha].author] += 1
120
+ lines[k] = commits[sha]
121
+ last = max(commits.values(), key=lambda c: c.time, default=None)
122
+ return History(
123
+ last=last,
124
+ commits=len(commits),
125
+ authors=[name for name, _ in by_author.most_common()],
126
+ first=min((c.time for c in commits.values()), default=0),
127
+ uncommitted=uncommitted,
128
+ now=now,
129
+ lines=lines,
130
+ )
131
+
132
+
133
+ def read_history(root: Path, functions: list[CodeFunction]) -> int:
134
+ """
135
+ Fills in each function's history from git, blaming each file once over the lines its functions take up. How many
136
+ functions it found history for: 0 when the folder isn't in git, git isn't installed, or the folder's config would
137
+ have git run a program of its own.
138
+ """
139
+ spans: dict[str, tuple[int, int]] = {}
140
+ for f in functions:
141
+ if f.file and f.line and f.lines:
142
+ start, end = spans.get(f.file, (f.line, f.line + f.lines - 1))
143
+ spans[f.file] = (min(start, f.line), max(end, f.line + f.lines - 1))
144
+ if not spans or _git(root, "rev-parse", "--is-inside-work-tree") is None or _runs_filters(root):
145
+ return 0
146
+ page = _commit_page((_git(root, "remote", "get-url", "origin") or "").strip())
147
+ now = int(time.time())
148
+ with ThreadPoolExecutor(max_workers=min(_WORKERS, len(spans))) as pool:
149
+ blamed = dict(zip(spans, pool.map(lambda file: _blame(root, file, *spans[file]), spans)))
150
+ found = 0
151
+ for f in functions:
152
+ lines = blamed.get(f.file)
153
+ if lines:
154
+ f.history = _history(lines, f.line, f.line + f.lines - 1, page, now)
155
+ found += 1
156
+ return found
code_node/pages.py CHANGED
@@ -6,13 +6,14 @@ once, and the server makes it again on every change.
6
6
  from __future__ import annotations
7
7
 
8
8
  import os
9
- from dataclasses import dataclass
9
+ from dataclasses import dataclass, replace
10
10
  from pathlib import Path
11
11
 
12
12
  from .analysis.graph import code_graph
13
13
  from .analysis.mapping import entry_file, map_code
14
14
  from .analysis.model import CodeMap
15
15
  from .deploys import find_deployment
16
+ from .history import read_history
16
17
  from .project import EntryPoint, Project, read_project
17
18
  from .report.build import page_blocks
18
19
  from .report.editor import editor_link
@@ -38,6 +39,7 @@ class PageOptions:
38
39
  depth: int = GRAPH_DEPTH
39
40
  platform: str = "" # --platform: its checks run whatever the entry point is ('' leaves it to the entry point)
40
41
  editor: str = "vscode" # --editor: the editor its line links open ('vscode', 'cursor'; 'none' for no links)
42
+ git: bool = True # what git says about each function (--no-git leaves it out)
41
43
 
42
44
  @property
43
45
  def limits(self) -> tuple[int, int]:
@@ -63,6 +65,8 @@ def map_entry(root: Path, entry: EntryPoint, options: PageOptions) -> tuple[Code
63
65
  platform=options.platform or entry.platform,
64
66
  deployment=deployment,
65
67
  )
68
+ if options.git:
69
+ read_history(project.root, cmap.functions)
66
70
  return cmap, project
67
71
 
68
72
 
@@ -87,6 +91,6 @@ def make_mermaid(root: Path, entry: EntryPoint, options: PageOptions) -> str:
87
91
  """
88
92
  The graph as Mermaid.
89
93
  """
90
- cmap, _ = map_entry(root, entry, options)
94
+ cmap, _ = map_entry(root, entry, replace(options, git=False)) # Mermaid has nowhere to say it
91
95
  max_functions, max_depth = options.limits
92
96
  return code_graph(cmap, max_functions=max_functions, max_depth=max_depth).mermaid()
@@ -0,0 +1,116 @@
1
+ """
2
+ What git says about a function, as HTML: who changed it last and when, with a link to the commit when the repository
3
+ is on GitHub or GitLab, how many commits and people its lines come from, and its lines not committed yet. The same
4
+ in the graph's hover card and the walk's card, and in the graph's panel line by line, beside its code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+
11
+ from ..kit.text import _esc
12
+
13
+ from ..analysis.model import Commit, History
14
+
15
+
16
+ def history_html(history: History | None, short: bool = False) -> str:
17
+ """
18
+ The function's history as a few lines, or '' without one. short: the last change alone, for a hover card.
19
+ """
20
+ if history is None or not (history.last or history.uncommitted):
21
+ return ""
22
+ said = []
23
+ last = history.last
24
+ if last:
25
+ day = datetime.fromtimestamp(last.time, timezone.utc).strftime("%Y-%m-%d")
26
+ sha = f"<code>{_esc(last.short)}</code>"
27
+ if last.url and not short:
28
+ sha = f'<a href="{_esc(last.url)}" target="_blank" rel="noopener" title="The commit">{sha} ↗</a>'
29
+ said.append(
30
+ f'Last changed <b title="{day}">{_esc(history.ago(last.time))}</b> by {_esc(last.author)} in {sha}'
31
+ + (f": <q>{_esc(last.subject)}</q>" if last.subject else "")
32
+ )
33
+ if history.commits > 1 and not short:
34
+ people = f"{len(history.authors)} people" if len(history.authors) > 1 else _esc(history.authors[0])
35
+ said.append(
36
+ f"Its lines come from {history.commits} commits by {people}, the oldest "
37
+ f"{_esc(history.ago(history.first))}"
38
+ )
39
+ if history.uncommitted:
40
+ changed = f"{history.uncommitted} line{'' if history.uncommitted == 1 else 's'} changed since the last commit"
41
+ said.append(f'<span class="cnhu">{changed if last else "Not committed yet"}</span>')
42
+ return '<div class="cnh">' + "".join(f"<p>{line}</p>" for line in said) + "</div>"
43
+
44
+
45
+ _AGES = 4 # shades of a line's bar, oldest to newest
46
+
47
+
48
+ def blame_keys(history: History) -> dict[str, int]:
49
+ """
50
+ The commits its lines come from, oldest first, each with its place in that order: what a line's mark names to
51
+ find its commit in the panel's last row.
52
+ """
53
+ commits = {c.sha: c for c in history.lines.values() if c}
54
+ return {sha: k for k, sha in enumerate(sorted(commits, key=lambda sha: (commits[sha].time, sha)))}
55
+
56
+
57
+ def blame_mark(history: History, line: int, keys: dict[str, int], same: bool) -> str:
58
+ """
59
+ What goes beside a line of the panel's code: who changed it last and how long ago (left out when the line above
60
+ says the same, same), over a bar darker the newer the line, amber for one not committed yet. Pointing at it says
61
+ the commit; '' for a line blame didn't cover, so the code stays in line.
62
+ """
63
+ if line not in history.lines:
64
+ return '<span class="sb"></span>'
65
+ commit = history.lines[line]
66
+ if commit is None:
67
+ return (
68
+ f'<span class="sb au{" same" if same else ""}" data-k="u" title="Changed since the last commit">'
69
+ "<span>Uncommitted</span></span>"
70
+ )
71
+ k = keys[commit.sha]
72
+ shade = _AGES - 1 if len(keys) == 1 else round(k * (_AGES - 1) / (len(keys) - 1))
73
+ said = f"{commit.subject} · {commit.author}, {history.ago(commit.time)} · {commit.short}"
74
+ return (
75
+ f'<span class="sb a{shade}{" same" if same else ""}" data-k="{k}" title="{_esc(said)}">'
76
+ f"<span>{_esc(commit.author.split(' ')[0] or commit.author)} · {_esc(history.since(commit.time))}</span></span>"
77
+ )
78
+
79
+
80
+ def blame_row(history: History, keys: dict[str, int]) -> str:
81
+ """
82
+ The panel's last row: when its code was last changed and by whom, then hidden, a row for each commit its lines come
83
+ from, which a script shows in its place while a line from that commit is pointed at.
84
+ """
85
+
86
+ def sha(commit: Commit) -> str:
87
+ short = f"<code>{_esc(commit.short)}</code>"
88
+ if not commit.url:
89
+ return f'<span class="csfh">{short}</span>'
90
+ return (
91
+ f'<a class="csfh" href="{_esc(commit.url)}" target="_blank" rel="noopener" title="The commit">{short}</a>'
92
+ )
93
+
94
+ last = history.last
95
+ if last:
96
+ day = datetime.fromtimestamp(last.time, timezone.utc).strftime("%Y-%m-%d")
97
+ said = f'Changed <b title="{day}">{_esc(history.ago(last.time))}</b> by {_esc(last.author)}'
98
+ if history.commits > 1:
99
+ people = len(history.authors)
100
+ said += f" · {history.commits} commits" + (f" by {people} people" if people > 1 else "")
101
+ else:
102
+ said = "Not committed yet"
103
+ if history.uncommitted and last:
104
+ lines = f"{history.uncommitted} line{'' if history.uncommitted == 1 else 's'}"
105
+ said += f' · <span class="cnhu">{lines} not committed</span>'
106
+ rows = [f'<p class="csfs"><span>{said}</span>{sha(last) if last else ""}</p>']
107
+ commits = {c.sha: c for c in history.lines.values() if c}
108
+ for found, k in keys.items():
109
+ commit = commits[found]
110
+ rows.append(
111
+ f'<p data-k="{k}" hidden><span><b>{_esc(commit.subject or commit.short)}</b> · {_esc(commit.author)}, '
112
+ f"{_esc(history.ago(commit.time))}</span>{sha(commit)}</p>"
113
+ )
114
+ if history.uncommitted:
115
+ rows.append('<p data-k="u" hidden><span class="cnhu">Changed since the last commit</span></p>')
116
+ return f'<div class="csf">{"".join(rows)}</div>'
code_node/report/graph.py CHANGED
@@ -19,6 +19,7 @@ from ..kit.text import _esc
19
19
 
20
20
  from ..analysis.findings import _short_name
21
21
  from ..analysis.graph import CodeGraph, GraphEdge, GraphNode, GraphView
22
+ from .changes import blame_keys, blame_mark, blame_row, history_html
22
23
  from .brands import AWS_LOGOS, SERVICE_LOGOS, fills, logo, small_logo
23
24
  from .editor import editor_name, open_at
24
25
  from .style import _ROOT
@@ -1130,6 +1131,18 @@ _G_PANEL_KINDS = {
1130
1131
  }
1131
1132
 
1132
1133
 
1134
+ _G_GEAR = ( # the panel's settings
1135
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" '
1136
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/>'
1137
+ '<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21'
1138
+ "a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 "
1139
+ "1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1"
1140
+ "a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1"
1141
+ 'a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>'
1142
+ "</svg>"
1143
+ )
1144
+
1145
+
1133
1146
  def _g_panel_id(n: GraphNode) -> str:
1134
1147
  return f"cn-code-{n.id}"
1135
1148
 
@@ -1145,9 +1158,12 @@ def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "
1145
1158
  """
1146
1159
  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
1160
  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.
1161
+ lines numbered, the lines worth pointing at lit. A function's lines each say who changed them last and when, and
1162
+ its last row what git says about it, when the folder is in git. '' when none of its code is in the files read.
1149
1163
  """
1150
- shown, lit_any = [], False
1164
+ history = n.history if n.history and n.history.lines else None
1165
+ keys = blame_keys(history) if history else {}
1166
+ places = [] # (file, first line, last line, lines left out, the editor's link, its rows)
1151
1167
  for span in n.code[:_G_PANEL_SPANS]:
1152
1168
  lines = lines_of(span.file)
1153
1169
  start, end = max(1, span.start), min(len(lines), span.end)
@@ -1162,47 +1178,66 @@ def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "
1162
1178
  continue
1163
1179
  more = end - start + 1 - _G_PANEL_LINES
1164
1180
  end = min(end, start + _G_PANEL_LINES - 1)
1165
- lit_any |= any(start <= k <= end for k in span.lit)
1166
- rows = "".join(
1167
- f'<span class="sl{" lit" if k in span.lit else ""}"><span class="sn">{k}</span>{lines[k - 1]}</span>'
1168
- for k in range(start, end + 1)
1169
- )
1170
- where = f"line {start}" if start == end else f"lines {start}–{end}"
1171
- href = open_at(editor, span.file, min(span.lit, default=start))
1172
- link = (
1173
- f' <a class="ced" href="{_esc(href)}" title="Open it in {_esc(editor_name(editor))}">open ↗</a>'
1174
- if href
1175
- else ""
1176
- )
1181
+ rows, above = [], ""
1182
+ for k in range(start, end + 1):
1183
+ mark = ""
1184
+ if history:
1185
+ commit = history.lines.get(k)
1186
+ said = commit.sha if commit else "u" if k in history.lines else "" # the commit, to say it once a run
1187
+ mark = blame_mark(history, k, keys, bool(said) and said == above)
1188
+ above = said
1189
+ rows.append(
1190
+ f'<span class="sl{" lit" if k in span.lit else ""}">{mark}<span class="sn">{k}</span>'
1191
+ f'<span class="sc">{lines[k - 1]}</span></span>'
1192
+ )
1193
+ places.append((span.file, start, end, more, open_at(editor, span.file, min(span.lit, default=start)), rows))
1194
+ if not places:
1195
+ return ""
1196
+
1197
+ def opens(href: str) -> str:
1198
+ return f'<a class="ced" href="{_esc(href)}">Open in {_esc(editor_name(editor))}</a>' if href else ""
1199
+
1200
+ shown = []
1201
+ for file, start, end, more, href, rows in places:
1202
+ if len(places) > 1: # one place is in the panel's head
1203
+ where = f"line {start}" if start == end else f"lines {start}–{end}"
1204
+ shown.append(f'<div class="csw">{_esc(file)} <span>{where}</span>{opens(href)}</div>')
1177
1205
  shown.append(
1178
- f'<div class="csw">{_esc(span.file)} <span>{where}</span>{link}</div>'
1179
- f'<pre class="csc">{rows}</pre>'
1206
+ f'<pre class="csc{" blame" if history else ""}">{"".join(rows)}</pre>'
1180
1207
  + (f'<div class="csm">+{_plural(more, "more line")}</div>' if more > 0 else "")
1181
1208
  )
1182
- if not shown:
1183
- return ""
1184
1209
  rest = len(n.code) - _G_PANEL_SPANS
1185
1210
  if rest > 0:
1186
1211
  shown.append(f'<div class="csm">+{_plural(rest, "more place")}: the tables below list them all</div>')
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
- )
1192
- if lit_any:
1193
- said += (
1194
- '<p class="csd csl">The lit lines are where it calls, reads or sends what its arrows show.</p>'
1195
- if n.kind in ("handler", "function", "start")
1196
- else '<p class="csd csl">The lit lines are where the code uses it.</p>'
1212
+ mono = n.kind in ("handler", "function")
1213
+ said = []
1214
+ for line in (n.tip or "").split("\n"):
1215
+ if not line or (n.more and line.startswith("It calls ") and "the graph leaves out" in line):
1216
+ continue # not the calls this graph leaves out: a function's panel is the same in every graph it's in
1217
+ if mono and not said: # 'name(args) in file, 3 lines: its doc': its def is the code's first line
1218
+ head = re.fullmatch(r".+? in .+?, \d+ lines?(?:: (.*))?", line)
1219
+ if head:
1220
+ line = head.group(1) or ""
1221
+ if line:
1222
+ said.append(f'<p class="csd">{_prose(line)}</p>')
1223
+ about = [_esc(_G_PANEL_KINDS.get(n.kind, n.kind))] + ([_esc(n.sub)] if n.sub else [])
1224
+ if len(places) == 1 and places[0][4]:
1225
+ about.append(opens(places[0][4]))
1226
+ key = panel or _g_panel_id(n)
1227
+ opens_bar = bar = gear = ""
1228
+ if history: # its settings: a bar under its head that the gear opens, the checkbox before it so CSS can
1229
+ opens_bar = f'<input type="checkbox" class="cso" id="{_esc(key)}-set" aria-label="Settings">'
1230
+ gear = f'<label class="csx csgr" for="{_esc(key)}-set" title="Settings">{_G_GEAR}</label>'
1231
+ bar = (
1232
+ '<div class="csob"><label><input type="checkbox" class="csg" checked> '
1233
+ "Who changed each line, from git</label></div>"
1197
1234
  )
1198
- mono = " mono" if n.kind in ("handler", "function") else ""
1199
1235
  return (
1200
- f'<aside class="cside" id="{_esc(panel or _g_panel_id(n))}" aria-label="{_esc("The code of " + n.label)}">'
1201
- f'<div class="csh"><div class="cst"><span class="csk">{_esc(_G_PANEL_KINDS.get(n.kind, n.kind))}</span>'
1202
- f'<b class="csn{mono}">{_esc(n.label)}</b>'
1203
- + (f'<span class="css">{_esc(n.sub)}</span>' if n.sub else "")
1204
- + '</div><a class="csx" href="#cn-closed" aria-label="Close" title="Close">×</a></div>'
1205
- f"{said}{''.join(shown)}</aside>"
1236
+ f'<aside class="cside" id="{_esc(key)}" aria-label="{_esc("The code of " + n.label)}">{opens_bar}'
1237
+ f'<div class="csh"><div class="cst"><b class="csn{" mono" if mono else ""}">{_esc(n.label)}</b>'
1238
+ f'<span class="css">{" · ".join(about)}</span></div>{gear}'
1239
+ f'<a class="csx" href="#cn-closed" aria-label="Close" title="Close">×</a></div>{bar}'
1240
+ f"{''.join(said)}{''.join(shown)}{blame_row(history, keys) if history else ''}</aside>"
1206
1241
  )
1207
1242
 
1208
1243
 
@@ -1300,6 +1335,10 @@ def _g_tip(n: GraphNode, lines_of: Callable[[str], list[str]], opens: bool) -> t
1300
1335
  else:
1301
1336
  body.append(f'<p class="ctp">{_g_tip_text(line)}</p>')
1302
1337
  tall += 23 * math.ceil(len(line) / 42) + 10
1338
+ changed = history_html(n.history, short=True)
1339
+ if changed:
1340
+ body.append(changed)
1341
+ tall += 23 * math.ceil(len(re.sub(r"<[^>]*>", "", changed)) / 42) + 14
1303
1342
  if warnings:
1304
1343
  body.append(
1305
1344
  '<div class="ctw"><b>Warning</b><ul>'
@@ -1414,7 +1453,7 @@ _G_SCRIPT = """<script>
1414
1453
  const from = view ? document.getElementById(view)?.content : wrap.main;
1415
1454
  if (!from) return;
1416
1455
  for (const part of parts) {
1417
- wrap.querySelector(".cgb > ." + part).innerHTML = from.querySelector(".x-" + part).innerHTML;
1456
+ wrap.querySelector(".cgb ." + part).innerHTML = from.querySelector(".x-" + part).innerHTML;
1418
1457
  }
1419
1458
  wrap.dataset.view = view;
1420
1459
  wrap.querySelector(".cgb").scrollIntoView({block: "nearest"});
@@ -1424,7 +1463,7 @@ _G_SCRIPT = """<script>
1424
1463
  for (const part of parts) {
1425
1464
  const kept = document.createElement("div");
1426
1465
  kept.className = "x-" + part;
1427
- kept.innerHTML = wrap.querySelector(".cgb > ." + part).innerHTML;
1466
+ kept.innerHTML = wrap.querySelector(".cgb ." + part).innerHTML;
1428
1467
  main.content.append(kept);
1429
1468
  }
1430
1469
  wrap.main = main.content;
@@ -1440,6 +1479,118 @@ _G_SCRIPT = """<script>
1440
1479
  if (open && !open.contains(e.target) && !e.target.closest("a[href], label, input, .cgt")) close();
1441
1480
  });
1442
1481
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
1482
+ // A function's code: pointing at a line puts the commit it comes from in its panel's last row, in place of what git
1483
+ // says about all of it.
1484
+ // Its settings, one for every panel: turning git off in one turns it off in all, and the page remembers it.
1485
+ const blame = (on) => document.querySelectorAll(".csg").forEach((box) => { box.checked = on; });
1486
+ try { if (localStorage.getItem("code-node.blame") === "off") blame(false); } catch (e) {}
1487
+ document.addEventListener("change", (e) => {
1488
+ if (!e.target.matches(".csg")) return;
1489
+ blame(e.target.checked);
1490
+ try { localStorage.setItem("code-node.blame", e.target.checked ? "on" : "off"); } catch (e) {}
1491
+ });
1492
+ let blamed = "";
1493
+ document.addEventListener("mouseover", (e) => {
1494
+ const mark = e.target.closest(".cside .sl")?.querySelector(".sb[data-k]");
1495
+ const panel = mark && mark.closest(".cside"), key = mark ? mark.dataset.k : "";
1496
+ const row = (panel || document).querySelector(".csf");
1497
+ if (key + (panel && panel.id) === blamed) return;
1498
+ document.querySelectorAll(".csf p").forEach((p) => {
1499
+ p.hidden = p.closest(".csf") === row && key ? p.dataset.k !== key : p.hasAttribute("data-k");
1500
+ });
1501
+ blamed = key + (panel && panel.id);
1502
+ });
1503
+ // Moving about a graph as on a canvas: drag it to pan, Ctrl or ⌘ with the wheel (or a trackpad's pinch) to zoom
1504
+ // around the pointer, and + - 0 when it has focus. A zoom of its own unchecks the bar's buttons, so the plain rule
1505
+ // (the SVG's width times --z) draws it; picking a button again takes it back. Its box keeps one size, and Fit
1506
+ // (--fit) puts all of it in that box, both ways.
1507
+ for (const wrap of document.querySelectorAll(".cgw")) {
1508
+ const box = wrap.querySelector(".cgb .cg"), radios = wrap.querySelectorAll(":scope > .cgs[type=radio]");
1509
+ const said = wrap.querySelector(".cgtp"), svg = () => box.querySelector("svg");
1510
+ const scale = () => {
1511
+ const s = svg();
1512
+ return s && s.viewBox.baseVal.width ? s.getBoundingClientRect().width / s.viewBox.baseVal.width : 1;
1513
+ };
1514
+ const fit = () => {
1515
+ const s = svg(), w = s && s.viewBox.baseVal.width, h = s && s.viewBox.baseVal.height;
1516
+ if (w && h && box.clientWidth) {
1517
+ box.style.setProperty("--fit", Math.min((box.clientWidth - 24) / w, (box.clientHeight - 24) / h, 1.5));
1518
+ }
1519
+ };
1520
+ const tell = () => { fit(); if (said) said.textContent = Math.round(scale() * 100) + "%"; };
1521
+ const pan = (dx, dy) => { // the graph's box first, then the page for what it can't scroll
1522
+ const top = box.scrollTop;
1523
+ box.scrollLeft += dx;
1524
+ box.scrollTop += dy;
1525
+ const left = dy - (box.scrollTop - top);
1526
+ if (Math.abs(left) > 0.5) scrollBy(0, left);
1527
+ };
1528
+ const zoom = (by, x, y) => { // keeps the point at (x, y) where it is
1529
+ const s = svg();
1530
+ if (!s) return;
1531
+ const from = scale(), to = Math.min(4, Math.max(0.1, from * by)), was = s.getBoundingClientRect();
1532
+ const px = (x - was.left) / from, py = (y - was.top) / from;
1533
+ radios.forEach((r) => { r.checked = false; });
1534
+ box.style.setProperty("--z", to);
1535
+ const now = s.getBoundingClientRect();
1536
+ pan(now.left + px * to - x, now.top + py * to - y);
1537
+ tell();
1538
+ };
1539
+ const auto = () => { // back to the Auto button
1540
+ const radio = wrap.querySelector(":scope > .cgz-auto");
1541
+ radio.checked = true;
1542
+ radio.dispatchEvent(new Event("change", {bubbles: true}));
1543
+ };
1544
+ const step = (by) => { // around the middle of what's in sight
1545
+ const r = box.getBoundingClientRect();
1546
+ zoom(by, r.left + r.width / 2, (Math.max(r.top, 0) + Math.min(r.bottom, innerHeight)) / 2);
1547
+ };
1548
+ wrap.querySelector(".cgb .zi")?.addEventListener("click", () => step(1.25));
1549
+ wrap.querySelector(".cgb .zo")?.addEventListener("click", () => step(0.8));
1550
+ let drag = null, moved = false;
1551
+ box.addEventListener("pointerdown", (e) => {
1552
+ moved = false;
1553
+ if (e.button === 0 && e.pointerType !== "touch") drag = {x: e.clientX, y: e.clientY, id: e.pointerId};
1554
+ });
1555
+ box.addEventListener("pointermove", (e) => {
1556
+ if (!drag || e.pointerId !== drag.id) return;
1557
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
1558
+ if (!moved && Math.hypot(dx, dy) < 4) return; // a click on a box stays a click
1559
+ if (!moved) { moved = true; box.setPointerCapture(e.pointerId); box.classList.add("pan"); }
1560
+ pan(-dx, -dy);
1561
+ drag.x = e.clientX;
1562
+ drag.y = e.clientY;
1563
+ });
1564
+ const end = () => { drag = null; box.classList.remove("pan"); };
1565
+ box.addEventListener("pointerup", end);
1566
+ box.addEventListener("pointercancel", end);
1567
+ box.addEventListener("click", (e) => { if (moved) { e.preventDefault(); e.stopPropagation(); } }, true);
1568
+ box.addEventListener("dragstart", (e) => e.preventDefault());
1569
+ box.addEventListener("wheel", (e) => {
1570
+ if (!e.ctrlKey && !e.metaKey) return;
1571
+ e.preventDefault();
1572
+ const d = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
1573
+ zoom(Math.exp(-Math.max(-100, Math.min(100, d)) * 0.0015), e.clientX, e.clientY);
1574
+ }, {passive: false});
1575
+ box.tabIndex = 0;
1576
+ box.addEventListener("keydown", (e) => {
1577
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1578
+ if (e.key === "+" || e.key === "=") step(1.25);
1579
+ else if (e.key === "-" || e.key === "_") step(0.8);
1580
+ else if (e.key === "0") auto();
1581
+ else return;
1582
+ e.preventDefault();
1583
+ });
1584
+ wrap.addEventListener("change", (e) => {
1585
+ if (!e.target.matches(".cgs")) return; // a zoom button, or full screen: its box's size changes
1586
+ if (e.target.type === "radio") box.style.removeProperty("--z");
1587
+ requestAnimationFrame(tell);
1588
+ });
1589
+ addEventListener("hashchange", () => requestAnimationFrame(tell));
1590
+ addEventListener("resize", tell);
1591
+ wrap.classList.add("pz");
1592
+ tell();
1593
+ }
1443
1594
  })();
1444
1595
  </script>"""
1445
1596
 
@@ -1604,7 +1755,7 @@ def _g_canvas(
1604
1755
  hint = (
1605
1756
  '<span class="kt">Point at a box to see only what it\'s joined to'
1606
1757
  + ("; click it to read its code" if panel_of else "")
1607
- + "</span>"
1758
+ + '</span><span class="kt pzo">Drag to move about; Ctrl or ⌘ and scroll, or pinch, to zoom</span>'
1608
1759
  )
1609
1760
  svg = (
1610
1761
  f'<svg viewBox="0 0 {width:.0f} {tall:.0f}" width="{width:.0f}" height="{tall:.0f}" '
@@ -1681,8 +1832,14 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1681
1832
  + f'</span><label class="cgtf" for="{group}-full"><span class="on">Full screen</span>'
1682
1833
  '<span class="off">Close full screen</span></label></div>'
1683
1834
  )
1835
+ zoom = ( # over the graph's corner, as a canvas has it: with JavaScript only
1836
+ '<span class="cgtm pzo" role="group" aria-label="Zoom out or in">'
1837
+ '<button type="button" class="zo" title="Zoom out (-, or Ctrl and scroll)">−</button>'
1838
+ '<span class="cgtp" title="How big the graph is drawn"></span>'
1839
+ '<button type="button" class="zi" title="Zoom in (+, or Ctrl and scroll)">+</button></span>'
1840
+ )
1684
1841
  script = ""
1685
- if (views or panels) and not report.shared("graph-script", lambda: [False])[0]:
1842
+ if not report.shared("graph-script", lambda: [False])[0]: # once, for every graph: moving about and zooming
1686
1843
  report.shared("graph-script", lambda: [False])[0] = True
1687
1844
  script = _G_SCRIPT
1688
1845
  # in a box of its own with its panels: the :has() rules that outline a box whose code is open look only in here,
@@ -1690,7 +1847,7 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1690
1847
  data = f' data-group="{group}"' if views else ""
1691
1848
  return (
1692
1849
  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>'
1850
+ f'<nav class="cgc" aria-label="Where this graph starts"></nav><div class="cgv"><div class="cg">{svg}</div>{zoom}</div>'
1694
1851
  f'<div class="cgk">{legend}</div></div>{"".join(html for _, html in panels.values())}{"".join(views)}</div>'
1695
1852
  f"{script}"
1696
1853
  )
code_node/report/style.py CHANGED
@@ -31,7 +31,7 @@ FAVICON_LINK = f'<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,
31
31
  _ROOT = "cnd" # the class around each page's HTML: its stylesheet's rules are scoped to it
32
32
 
33
33
  _EXTRA_CSS = """\
34
- .cnd{--mono:"Cascadia Mono","Cascadia Code",ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}
34
+ .cnd{--mono:"Cascadia Code","Cascadia Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}
35
35
  .cnd code,.cnd table.t td.tree{font-family:var(--mono)}
36
36
  .cnd .wire{display:grid;grid-template-columns:minmax(0,1fr) 26px minmax(0,.9fr) 26px minmax(0,1fr);align-items:center;margin:6px 0 10px;max-width:980px}
37
37
  .cnd .wire .wc{display:flex;flex-direction:column;gap:6px;min-width:0}
@@ -102,7 +102,8 @@ _EXTRA_CSS = """\
102
102
  .cnd :is(.flow,.cside,.walk,.ctip) .pa{font-style:italic}
103
103
  .cnd :is(.flow,.cside,.walk,.ctip) .pc{opacity:.6;font-style:italic}
104
104
  .cnd .cgb{width:fit-content;max-width:min(100%,1180px)}
105
- .cnd .cg{border:1px solid rgba(127,127,127,.2);border-radius:14px;margin:6px 0 8px;overflow:auto;background-color:rgba(127,127,127,.035);background-attachment:local;background-size:18px 18px;
105
+ .cnd .cgv{position:relative;margin:6px 0 8px}
106
+ .cnd .cg{border:1px solid rgba(127,127,127,.2);border-radius:14px;overflow:auto;background-color:rgba(127,127,127,.035);background-attachment:local;background-size:18px 18px;
106
107
  background-image:radial-gradient(circle at 1px 1px,rgba(127,127,127,.3) 1px,transparent 0);background-position:9px 9px}
107
108
  .cnd .cg svg{display:block;width:calc(var(--w)*var(--z,1));max-width:none;height:auto;overflow:visible;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
108
109
  .cnd .cgw{position:relative}
@@ -121,11 +122,25 @@ _EXTRA_CSS = """\
121
122
  .cnd .cgz-auto:checked~.cgb .cg svg{width:var(--w);max-width:100%;min-width:calc(var(--w)*.8)}
122
123
  .cnd .cgz-fit:checked~.cgb .cg svg{width:var(--w);max-width:100%}
123
124
  .cnd .cgfs:checked~.cgb{position:fixed;inset:0;z-index:25;width:auto;max-width:none;display:flex;flex-direction:column;box-sizing:border-box;padding:12px clamp(12px,2vw,28px) 4px;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas))}
124
- .cnd .cgfs:checked~.cgb .cg{flex:1;min-height:0;margin:0 0 8px}
125
+ .cnd .cgfs:checked~.cgb .cgv{flex:1;min-height:0;display:flex;flex-direction:column;margin:0 0 8px}
126
+ .cnd .cgfs:checked~.cgb .cg{flex:1;min-height:0}
125
127
  .cnd .cgfs:checked~.cgz-fit:checked~.cgb .cg svg{width:100%;height:100%;max-width:none}
126
128
  .cnd .cgfs:checked~.cgz-auto:checked~.cgb .cg svg{min-width:0}
127
- @media (min-width:1100px){.cnd .cgw:has(.cside:target) .cgfs:checked~.cgb{right:min(660px,94vw)}}
128
129
  .cnd .cgw:not(.js) :is(.oj,.dr),.cnd .cgw.js .nj{display:none}
130
+ .cnd .cgw:not(.pz) .pzo{display:none}
131
+ .cnd .cgw.pz .cg{cursor:grab;user-select:none;-webkit-user-select:none}
132
+ .cnd .cgw.pz .cg.pan,.cnd .cgw.pz .cg.pan *{cursor:grabbing}
133
+ .cnd .cgw.pz .cg:focus-visible{outline:2px solid light-dark(#2563eb,#60a5fa);outline-offset:-2px}
134
+ .cnd .cgw.pz>.cgfs:not(:checked)~.cgb{width:100%;max-width:none}
135
+ .cnd .cgw.pz>.cgfs:not(:checked)~.cgb .cg{height:max(360px,72vh)}
136
+ .cnd .cgw.pz .cg{display:flex}
137
+ .cnd .cgw.pz .cg svg{flex:none;margin:auto}
138
+ .cnd .cgw.pz>.cgz-fit:checked~.cgb .cg svg{width:calc(var(--w)*var(--fit,1));height:auto;max-width:none}
139
+ .cnd .cgtm{position:absolute;right:14px;bottom:14px;z-index:2;display:inline-flex;align-items:center;font-size:12px;user-select:none;-webkit-user-select:none;border:1px solid rgba(127,127,127,.3);border-radius:8px;overflow:hidden;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas));box-shadow:0 1px 2px rgba(0,0,0,.06),0 4px 12px rgba(15,23,42,.08)}
140
+ .cnd .cgtm button{font:inherit;font-size:16px;line-height:1;color:inherit;background:none;border:0;padding:6px 11px;cursor:pointer;opacity:.75}
141
+ .cnd .cgtm button:hover{opacity:1;background:rgba(127,127,127,.08)}
142
+ .cnd .cgtm button:focus-visible{outline:2px solid light-dark(#2563eb,#60a5fa);outline-offset:-2px}
143
+ .cnd .cgtp{min-width:3.4em;padding:3px 2px;text-align:center;opacity:.7;font-variant-numeric:tabular-nums;border-inline:1px solid rgba(127,127,127,.2)}
129
144
  .cnd .cgta{padding:3px 10px;border:1px solid rgba(127,127,127,.28);border-radius:8px;color:inherit;text-decoration:none;opacity:.7}
130
145
  .cnd .cgta:hover{opacity:.95;background:rgba(127,127,127,.08)}
131
146
  .cnd .cgw[data-view$="-all"] .cgta{opacity:1;font-weight:650;background:rgba(127,127,127,.14)}
@@ -265,26 +280,64 @@ _EXTRA_CSS = """\
265
280
  .cnd .cg .ctt .ctw li{margin:4px 0 0;overflow-wrap:anywhere}
266
281
  .cnd .cside{position:fixed;top:0;right:0;bottom:0;z-index:30;box-sizing:border-box;width:min(660px,94vw);overflow:auto;padding:0 20px 28px;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas));color:CanvasText;border-left:1px solid rgba(127,127,127,.3);box-shadow:-14px 0 36px rgba(15,23,42,.14);transform:translateX(104%);visibility:hidden;transition:transform .22s ease,visibility 0s linear .22s}
267
282
  .cnd .cside:target{transform:none;visibility:visible;transition:transform .22s ease}
268
- .cnd .cside .csh{display:flex;align-items:flex-start;gap:12px;position:sticky;top:0;z-index:1;margin:0 -20px 12px;padding:16px 20px 12px;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas));border-bottom:1px solid rgba(127,127,127,.2)}
283
+ .cnd .cside .csh{display:flex;align-items:flex-start;gap:12px;position:sticky;top:0;z-index:1;margin:0 -20px 12px;padding:18px 18px 16px 20px;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas));border-bottom:1px solid rgba(127,127,127,.2)}
269
284
  .cnd .cside .cst{flex:1;min-width:0}
270
- .cnd .cside .csk{display:block;font-size:11px;font-weight:650;letter-spacing:.08em;text-transform:uppercase;opacity:.55}
271
- .cnd .cside .csn{display:block;font-size:17px;font-weight:650;overflow-wrap:anywhere;margin:2px 0 1px}
272
- .cnd .cside .csn.mono{font:650 16px var(--mono)}
273
- .cnd .cside .css{font-size:12.5px;opacity:.65;overflow-wrap:anywhere}
274
- .cnd .cside a.csx{flex:none;display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:8px;font-size:22px;line-height:1;color:inherit;text-decoration:none;opacity:.6}
275
- .cnd .cside a.csx:hover,.cnd .cside a.csx:focus-visible{opacity:1;background:rgba(127,127,127,.14)}
285
+ .cnd .cside .csn{display:block;font-size:17px;font-weight:650;overflow-wrap:anywhere;margin:0 0 3px}
286
+ .cnd .cside .csn.mono{font:600 17px var(--mono)}
287
+ .cnd .cside .css{display:block;font-size:12.5px;overflow-wrap:anywhere;color:color-mix(in srgb,currentColor 65%,transparent)}
288
+ .cnd .cside .csx{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin:-4px -4px 0 0;border-radius:7px;font-size:20px;line-height:1;color:inherit;text-decoration:none;opacity:.5}
289
+ .cnd .cside .csx:hover,.cnd .cside .csx:focus-visible{opacity:1;background:rgba(127,127,127,.14)}
290
+ .cnd .cside .cso{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
291
+ .cnd .cside label.csgr{cursor:pointer;margin-right:-8px}
292
+ .cnd .cside .cso:focus-visible+.csh label.csgr{opacity:1;outline:2px solid light-dark(#2563eb,#6ea8fe)}
293
+ .cnd .cside .cso:checked+.csh label.csgr{opacity:1;background:rgba(127,127,127,.14)}
294
+ .cnd .cside .csob{display:none;align-items:center;gap:18px;flex-wrap:wrap;margin:-12px -20px 12px;padding:9px 20px;border-bottom:1px solid rgba(127,127,127,.2);background:rgba(127,127,127,.05);font-size:12.5px}
295
+ .cnd .cside .cso:checked~.csob{display:flex}
296
+ .cnd .cside .csob label{display:inline-flex;align-items:center;gap:7px;cursor:pointer}
297
+ .cnd .cside .csob input{margin:0;accent-color:light-dark(#2563eb,#6ea8fe)}
298
+ .cnd .cside:has(.csg:not(:checked)) :is(.sb,.csf){display:none}
299
+ .cnd .cside:has(.csg:not(:checked)) .blame .sn{padding-left:20px}
300
+ .cnd .cside:has(.csg:not(:checked)) .blame .sl:hover>*{background:none}
301
+ .cnd .cside:has(.csg:not(:checked)) .blame .sl.lit>:is(.sn,.sc){background:rgba(245,158,11,.14)}
276
302
  .cnd .cside .csd{font-size:13px;line-height:1.5;margin:0 0 6px;opacity:.82;overflow-wrap:anywhere}
277
- .cnd .cside .csl{font-size:12px;opacity:.6}
278
303
  .cnd .cside .csw{font:12px var(--mono);margin:16px 0 5px;overflow-wrap:anywhere}
279
304
  .cnd .cside .csw span{opacity:.55}
280
- .cnd .cside .ced{margin-left:6px;font-family:inherit;color:light-dark(#2563eb,#60a5fa);text-decoration:none}
305
+ .cnd .cside .csw .ced{margin-left:8px;font-family:inherit}
306
+ .cnd .cside .ced{color:light-dark(#2563eb,#6ea8fe);text-decoration:none}
281
307
  .cnd .cside .ced:hover{text-decoration:underline}
282
- .cnd .cside pre.csc{margin:0;padding:8px 0;border:1px solid rgba(127,127,127,.22);border-radius:8px;overflow:auto;background:rgba(127,127,127,.04);font:12.5px/1.6 var(--mono)}
283
- .cnd .cside .sl{display:block;width:max-content;min-width:100%;box-sizing:border-box;padding-right:14px;white-space:pre}
284
- .cnd .cside .sl.lit{background:rgba(245,158,11,.16);box-shadow:inset 3px 0 #f59e0b}
285
- .cnd .cside .sn{display:inline-block;width:3.2em;padding-right:14px;text-align:right;opacity:.42;user-select:none;-webkit-user-select:none}
308
+ .cnd .cside pre.csc{margin:12px -20px 0;padding:6px 0 10px;border:0;border-block:1px solid rgba(127,127,127,.2);border-radius:0;max-height:none;overflow:auto;font:12.5px/1.7 var(--mono)}
309
+ .cnd .cside :is(.csh,.csob)+pre.csc{margin-top:-12px;border-top:0}
310
+ .cnd .cside .sl{display:flex;width:max-content;min-width:100%}
311
+ .cnd .cside .sn{flex:none;width:2.4em;padding:0 16px 0 20px;text-align:right;opacity:.42;user-select:none;-webkit-user-select:none}
312
+ .cnd .cside .blame .sn{width:2.4em;padding-left:0}
313
+ .cnd .cside .sc{flex:1;padding-right:20px;white-space:pre}
314
+ .cnd .cside .sl.lit>:is(.sn,.sc){background:rgba(245,158,11,.14)}
315
+ .cnd .cside .sb{flex:none;display:flex;align-items:center;gap:10px;width:112px;box-sizing:border-box;padding-left:20px;font:11.5px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;white-space:nowrap;user-select:none;-webkit-user-select:none}
316
+ .cnd .cside .sb span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;opacity:.5}
317
+ .cnd .cside .sb::after{content:"";flex:none;width:2px;align-self:stretch;margin-right:2px;background:var(--age)}
318
+ .cnd .cside .sb:not(.same)::after{margin-top:3px}
319
+ .cnd .cside .sl:first-child .sb::after{margin-top:0}
320
+ .cnd .cside .sb.same span{visibility:hidden}
321
+ .cnd .cside .sb.a0{--age:color-mix(in srgb,light-dark(#3b6fe0,#6ea8fe) 18%,transparent)}
322
+ .cnd .cside .sb.a1{--age:color-mix(in srgb,light-dark(#3b6fe0,#6ea8fe) 45%,transparent)}
323
+ .cnd .cside .sb.a2{--age:color-mix(in srgb,light-dark(#3b6fe0,#6ea8fe) 72%,transparent)}
324
+ .cnd .cside .sb.a3{--age:light-dark(#3b6fe0,#6ea8fe)}
325
+ .cnd .cside .sb.au{--age:light-dark(#c2680c,#f2b441)}
326
+ .cnd .cside .sb.au span{color:light-dark(#c2680c,#f2b441);opacity:1}
327
+ .cnd .cside .blame .sl:hover>*{background:rgba(127,127,127,.09)}
328
+ .cnd .cside .blame .sl.lit:hover>:is(.sn,.sc){background:rgba(245,158,11,.22)}
329
+ .cnd .cside .blame .sl:hover .sb span{visibility:visible;opacity:.9}
286
330
  .cnd .cside .csm{font-size:12px;font-style:italic;opacity:.6;margin:6px 2px 0}
287
- @media (min-width:1100px){.cnd .cgw:has(.cside:target){margin-right:min(660px,94vw)}}
331
+ .cnd .cside .csf{position:sticky;bottom:-28px;z-index:1;margin:0 -20px -28px;padding:11px 20px;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas));font-size:12.5px}
332
+ .cnd .cside .csf p{margin:0}
333
+ .cnd .cside .csf p:not([hidden]){display:flex;align-items:baseline;gap:12px}
334
+ .cnd .cside .csf p>span:first-child{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:color-mix(in srgb,currentColor 70%,transparent)}
335
+ .cnd .cside .csf b{font-weight:600}
336
+ .cnd .cside .csf .csfh{flex:none;color:light-dark(#2563eb,#6ea8fe);text-decoration:none}
337
+ .cnd .cside .csf a.csfh:hover{text-decoration:underline}
338
+ .cnd .cside .csf span.csfh{color:inherit;opacity:.6}
339
+ .cnd .cside .csf code{font:12px var(--mono);background:none;padding:0}
340
+ .cnd .cside .cnhu{color:light-dark(#b45309,#fbbf24)}
288
341
  @media (prefers-reduced-motion:reduce){.cnd .cside,.cnd .cside:target{transition:none}}
289
342
  .cnd .tabs{position:relative;margin:0 0 8px}
290
343
  .cnd .tbar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;align-items:flex-end;gap:2px;padding-top:6px;margin:0 0 18px;border-bottom:1px solid rgba(127,127,127,.22);background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas))}
@@ -372,6 +425,14 @@ _EXTRA_CSS = """\
372
425
  .cnd .walk .wsay p{margin:4px 0 0;opacity:.85}
373
426
  .cnd .walk .wsay code{font-size:12.5px}
374
427
  .cnd .walk .wdoc{font-style:italic;opacity:.7}
428
+ .cnd .cnh{margin:10px 0 0;padding:8px 0 0 10px;border-left:2px solid rgba(127,127,127,.3);font-size:12.5px;line-height:1.5}
429
+ .cnd .cnh p{margin:0 0 3px;opacity:.75;overflow-wrap:anywhere}
430
+ .cnd .cnh p:last-child{margin-bottom:0}
431
+ .cnd .cnh b{font-weight:600}
432
+ .cnd .cnh code{font:12px var(--mono)}
433
+ .cnd .cnh a{color:light-dark(#2563eb,#60a5fa);text-decoration:none}
434
+ .cnd .cnh a:hover{text-decoration:underline}
435
+ .cnd .cnh .cnhu{color:light-dark(#b45309,#fbbf24)}
375
436
  .cnd .walk .wjump{user-select:none;-webkit-user-select:none;color:var(--blue);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
376
437
  .cnd .walk .wfacts{list-style:none;margin:10px 0 0;padding:10px 0 0;border-top:1px solid rgba(127,127,127,.14);display:flex;flex-direction:column;gap:5px}
377
438
  .cnd .walk .wfacts li{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 8px;font-size:12.5px;overflow-wrap:anywhere}
code_node/report/walk.py CHANGED
@@ -27,6 +27,7 @@ from ..kit.text import _esc
27
27
  from ..analysis.findings import _short_name
28
28
  from ..analysis.model import CodeFunction, CodeStep
29
29
  from ..analysis.syntax import _a
30
+ from .changes import history_html
30
31
  from .editor import editor_name, open_at
31
32
  from .outline import _NOTE_TIPS, _Outline, _chips_html, _outline_lines
32
33
  from .style import _ROOT
@@ -320,9 +321,12 @@ def _headline(stop: _Stop) -> tuple[str, str]:
320
321
  return f"Runs {_code(step.code)}", ""
321
322
 
322
323
 
323
- def _says(stops: list[_Stop], i: int, first: dict[str, int], docs: dict[str, str], over: bool) -> str:
324
+ def _says(
325
+ stops: list[_Stop], i: int, first: dict[str, int], docs: dict[str, str], over: bool, changes: dict[str, str]
326
+ ) -> str:
324
327
  """
325
- What the stop's line does, in a sentence or two (HTML), under its headline. over: it has a Skip call button.
328
+ What the stop's line does, in a sentence or two (HTML), under its headline. over: it has a Skip call button;
329
+ changes: what git says about each function, as HTML.
326
330
  """
327
331
  stop = stops[i]
328
332
  step = stop.step
@@ -335,6 +339,7 @@ def _says(stops: list[_Stop], i: int, first: dict[str, int], docs: dict[str, str
335
339
  said.append(f"Called from {_code(stop.stack[-2][0])}, line {stops[stop.caller].step.line}{when}.")
336
340
  doc = docs.get(step.name, "")
337
341
  out = "".join(f"<p>{line}</p>" for line in said) + (f'<p class="wdoc">{_esc(doc)}</p>' if doc else "")
342
+ out += changes.get(step.name, "")
338
343
  if step.seen and step.name in first:
339
344
  out += (
340
345
  f'<p>Its steps are shown where it\'s first called: <label class="wjump" for="cw{first[step.name]}">'
@@ -414,7 +419,14 @@ def _button(target: int | None, text: str, css: str = "", tip: str = "") -> str:
414
419
  return f'<label class="{css}" for="cw{target}"{title}>{text}</label>'
415
420
 
416
421
 
417
- def _card_html(stops: list[_Stop], i: int, first: dict[str, int], docs: dict[str, str], editor: str = "") -> str:
422
+ def _card_html(
423
+ stops: list[_Stop],
424
+ i: int,
425
+ first: dict[str, int],
426
+ docs: dict[str, str],
427
+ editor: str = "",
428
+ changes: dict[str, str] | None = None,
429
+ ) -> str:
418
430
  """
419
431
  The current stop's card: Back and Next (and Skip call and Finish, past what a line calls), what the line does in
420
432
  plain words, the calls it's inside (each a link back to its start), and what it touches.
@@ -471,7 +483,7 @@ def _card_html(stops: list[_Stop], i: int, first: dict[str, int], docs: dict[str
471
483
  f'<div class="wc"><div class="wnav">{"".join(nav)}</div>'
472
484
  f'<div class="whead">{headline}</div>'
473
485
  f'<div class="wstk"><span class="win">In</span>{where}{line}</div>'
474
- f'<div class="wsay">{_says(stops, i, first, docs, over is not None)}{_facts_html(stop.step, said)}</div>'
486
+ f'<div class="wsay">{_says(stops, i, first, docs, over is not None, changes or {})}{_facts_html(stop.step, said)}</div>'
475
487
  + (f'<div class="wthen">{_esc(then)}</div>' if then else "")
476
488
  + "</div>"
477
489
  )
@@ -600,6 +612,7 @@ def _walk_html(block: _Walk, report: _Report) -> str:
600
612
  if stop.step.kind == "def" and not stop.step.seen:
601
613
  first.setdefault(stop.step.name, i)
602
614
  docs = {f.name: f.doc for f in block.functions if f.doc}
615
+ changes = {f.name: history_html(f.history) for f in block.functions if f.history}
603
616
  drawn, rows_of = [], []
604
617
  for k, frame in enumerate(frames):
605
618
  if frame.file in block.sources and frame.file not in highlighted:
@@ -625,7 +638,9 @@ def _walk_html(block: _Walk, report: _Report) -> str:
625
638
  if not opened or opened[-1] != stop.frame: # into a call
626
639
  out.append('<div class="wfn">')
627
640
  opened.append(stop.frame)
628
- out.append(_item_html(stops, i, _card_html(stops, i, first, docs, block.editor), under[i], tuple(above[i])))
641
+ out.append(
642
+ _item_html(stops, i, _card_html(stops, i, first, docs, block.editor, changes), under[i], tuple(above[i]))
643
+ )
629
644
  if under[i]: # its fold hides the steps under it, beside it or in the boxes of what it calls
630
645
  report.rules.add(f".{root} #cx{i}:checked~.wu{i},.{root} #cx{i}:checked~* .wu{i}{{display:none}}")
631
646
  at = rows_of[stop.frame].get(stop.lit)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: code-node-graph
3
- Version: 0.4.0
3
+ Version: 0.6.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/
@@ -117,6 +117,7 @@ string is flagged.
117
117
  | `-o`, `--out`, `--no-open` | Where to save (default `~/.cache/code-node/`), and whether to open it |
118
118
  | `--platform NAME`, `--lambda` | Run a platform's checks on any entry point |
119
119
  | `--editor vscode\|cursor\|none` | Editor the line links open (default `vscode`, WSL-aware) |
120
+ | `--no-git` | Leave out what git says about each function (who changed it last, when, in which commit) |
120
121
 
121
122
  </details>
122
123
 
@@ -1,15 +1,16 @@
1
- code_node/__init__.py,sha256=hzITZuWbNbw6NyeHwo4MC9MApmpFcoJwdY43MsPrPQw,1106
1
+ code_node/__init__.py,sha256=XIYIBqbSGZWBP2j54pxOb7oY5thbAHRJwOliWlBU8fs,1106
2
2
  code_node/__main__.py,sha256=0W2bmerF1S96rjlTNJZKVzO4FvVhN6ENzAl451A08sQ,101
3
- code_node/cli.py,sha256=GI0vZY1t4Pf7qtZI2coTpWZi-ulQEWNREDJPkTtmxG0,12302
4
- code_node/pages.py,sha256=2UOrHUz3G_vAjPO89phJxhF4652FysVLgrLtJ-Iw6p4,3234
3
+ code_node/cli.py,sha256=QaieNNhj6_mQTK653jeAxnraZ4hn6CiKa5ju2gVRGfY,12510
4
+ code_node/history.py,sha256=YaaEQywgLZ2w2XvtQrQOWg5yBKDQuA5aT030BxQWfUA,6103
5
+ code_node/pages.py,sha256=BweJffESNV-cIKET5pGVp_9edFkGyqhePKIObz0zgi4,3484
5
6
  code_node/project.py,sha256=1s6pBwQ-hiEf9v0YzE8b6TETT9E0XBnzQPWGjYDJsnw,16361
6
7
  code_node/serve.py,sha256=4yvzDaaL1XpsVinLmvlm1W2bM6WJRGe5GI8wG3XuHic,11456
7
8
  code_node/analysis/__init__.py,sha256=-KM77rYv9pPutUG29DsU8ZkAUtr2AU9RsqrDPFAjLoQ,103
8
9
  code_node/analysis/aws_model.py,sha256=R2sHppdu9PmlsOGDXf6_6JHumbA5PqbiJ0JgnyB9Xcs,6648
9
10
  code_node/analysis/findings.py,sha256=S1-VQ1iNi1PkC-2eigNzbJrULa_2Y46avmtwAlV9iqU,19010
10
- code_node/analysis/graph.py,sha256=4FCzBo3G0XOkXV-zkshFxaoCalqWqbJfGDGDe-pg8S8,39373
11
+ code_node/analysis/graph.py,sha256=loAGXJI0bTMr8s5q6yCm613XOQd1MvE8gHOsIg-NVuo,39503
11
12
  code_node/analysis/mapping.py,sha256=6wYkUqaQdM-Vio8XGf_Lw8N9dP_90YtbUxauH4IQ8Tg,5102
12
- code_node/analysis/model.py,sha256=ORGT6BEQFonQjdRGPEbuGVGoyyK54nTEA4Th-7rEGvU,13614
13
+ code_node/analysis/model.py,sha256=_HxrCZHl-deFOX3LihfacLpgeNgRqtSYv2qA_TrKyVA,15796
13
14
  code_node/analysis/reader.py,sha256=QIqVN2RV477ON69_5YVLXhfnfL3seS8kvP7krpmZPNI,89304
14
15
  code_node/analysis/syntax.py,sha256=wyeUr68IDjGp3i9HYYYzcjw8-CvL6a6mmZdWz5LY8j8,7952
15
16
  code_node/analysis/terms.py,sha256=ec0f51DkfDRixJSRX1hnyq9Hr5aLRhXuBRHlNJBlZD8,13782
@@ -33,12 +34,13 @@ code_node/platforms/web.py,sha256=lNjwkz3D0zJkFcJhqD_ShcfTj4uF8miLNqKMtXu-t1U,11
33
34
  code_node/report/__init__.py,sha256=l3dfZFbLEo-7Fl7O_Rk2lvyAElQnyugSZPI_PzHrG_Q,76
34
35
  code_node/report/brands.py,sha256=n32BfFz2d02ZVUHvy8L4wPHBtiD_1FCTKzdR4NyvU88,8372
35
36
  code_node/report/build.py,sha256=xnPK_6FMTSV2nyS-N6KcApdq3ZNBA_0Hy8AUZDVIea0,4884
37
+ code_node/report/changes.py,sha256=ciD322LsDvdJlhyNOwXKC5AuKddnpfobL7I9mrpF3XE,5481
36
38
  code_node/report/editor.py,sha256=TRUX6BTB-oe-m5Vf3xM9PppnC99sCLL4PObXkYAGKoU,1466
37
- code_node/report/graph.py,sha256=1SEMabLWWL9E0kwhKLYZhRFj0pRzLk4eDpHfLsxuuiQ,75162
39
+ code_node/report/graph.py,sha256=tvyhTm2iUlzJlwx3NIyVdVFsX-m66eRxuhvcVvZlwx4,84223
38
40
  code_node/report/outline.py,sha256=3RRxU4FMUZKBVjFDsscKZkvDlj_W4ZeW4DwUztg4wd0,8539
39
- code_node/report/style.py,sha256=YTQXlWjsnVfAZBhTr_Ot0PjdyLRSW83WDR3F02YBm4E,36833
41
+ code_node/report/style.py,sha256=eZud8ieCCBNZu_pELY9zJgavZUaOzypOR4GCrMP6m9w,41825
40
42
  code_node/report/tabs.py,sha256=K2XR16owfgEL1kfux61PI9nBcRsgjQurTx1ptMM5Mac,3016
41
- code_node/report/walk.py,sha256=LWJDGJ3efF0fPWlMO4G4HBQqnNVn2RHXgLeuLrC-WnA,29466
43
+ code_node/report/walk.py,sha256=xPQCWSfE8XnV5yMzSw5MzXH88-xRgHIalbiO2OHz9Ao,29828
42
44
  code_node/report/logos/README.md,sha256=8Evw8eApl_56QJrwY7LAtGgJMZE0mp0-HIpRz9bu1ME,613
43
45
  code_node/report/logos/anthropic-icon.svg,sha256=-_25zx86lfDTm-ksVXkLE4uQ_ldzLbTd3U0z-wjT-JA,302
44
46
  code_node/report/logos/aws-amplify.svg,sha256=62WXM0XCn4WrQYWcVZFz4ln62xidiqF8eO4ELlgO8hg,531
@@ -171,8 +173,8 @@ code_node/report/logos/small/aws-systems-manager.svg,sha256=9VzzFdTjaRZO9ulR8Ulp
171
173
  code_node/report/logos/small/aws-timestream.svg,sha256=yKdKIj7pbooFqDKClEQxW9h7dYV5mnDn0RCHnLUDf8Q,639
172
174
  code_node/report/logos/small/aws-waf.svg,sha256=l9ZzBZAl2g8bK1-m6A50ga37l5g1t-9Mj70bxWDykWE,591
173
175
  code_node/report/logos/small/aws-xray.svg,sha256=VKg_QtKGZKDCbZvKBRDn7eReuNVbJpduBUVV8MQrZZU,591
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,,
176
+ code_node_graph-0.6.0.dist-info/METADATA,sha256=EEXXZjyeR0kuMOiCO8S01_uDapt0en_DRO0Br1sDlsQ,7725
177
+ code_node_graph-0.6.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
178
+ code_node_graph-0.6.0.dist-info/entry_points.txt,sha256=eEArp-uk7ZzODIWr6l62rI0-mIA0-mLDaqJEyKikPJw,49
179
+ code_node_graph-0.6.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
180
+ code_node_graph-0.6.0.dist-info/RECORD,,