code-node-graph 0.3.0__py3-none-any.whl → 0.5.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 +1 -1
- code_node/analysis/graph.py +151 -8
- code_node/analysis/model.py +45 -0
- code_node/analysis/reader.py +20 -3
- code_node/cli.py +9 -1
- code_node/history.py +151 -0
- code_node/pages.py +6 -2
- code_node/report/build.py +5 -3
- code_node/report/changes.py +42 -0
- code_node/report/graph.py +336 -53
- code_node/report/style.py +40 -4
- code_node/report/walk.py +20 -5
- code_node/serve.py +2 -2
- {code_node_graph-0.3.0.dist-info → code_node_graph-0.5.0.dist-info}/METADATA +2 -1
- {code_node_graph-0.3.0.dist-info → code_node_graph-0.5.0.dist-info}/RECORD +18 -16
- {code_node_graph-0.3.0.dist-info → code_node_graph-0.5.0.dist-info}/WHEEL +0 -0
- {code_node_graph-0.3.0.dist-info → code_node_graph-0.5.0.dist-info}/entry_points.txt +0 -0
- {code_node_graph-0.3.0.dist-info → code_node_graph-0.5.0.dist-info}/licenses/LICENSE +0 -0
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.
|
|
16
|
+
__version__ = "0.5.0"
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
19
19
|
"CodeGraph",
|
code_node/analysis/graph.py
CHANGED
|
@@ -8,14 +8,14 @@ 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
|
|
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
|
|
|
@@ -55,6 +55,8 @@ 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')
|
|
59
|
+
history: History | None = None # a function's box: what git says about its lines
|
|
58
60
|
|
|
59
61
|
|
|
60
62
|
@dataclass
|
|
@@ -85,6 +87,7 @@ class CodeGraph:
|
|
|
85
87
|
nodes: list[GraphNode] = field(default_factory=list)
|
|
86
88
|
edges: list[GraphEdge] = field(default_factory=list)
|
|
87
89
|
hidden: int = 0 # functions left out to keep the graph readable (too deep, or too many)
|
|
90
|
+
too_deep: int = 0 # ...of them, the ones deeper than it draws
|
|
88
91
|
|
|
89
92
|
def node(self, node_id: str) -> GraphNode | None:
|
|
90
93
|
return next((n for n in self.nodes if n.id == node_id), None)
|
|
@@ -256,11 +259,13 @@ def _pick(
|
|
|
256
259
|
order: dict[str, int],
|
|
257
260
|
room: int,
|
|
258
261
|
max_depth: int,
|
|
262
|
+
first: bool = False,
|
|
259
263
|
) -> list[str]:
|
|
260
264
|
"""
|
|
261
265
|
The functions the graph draws besides the entry point, at most `room` of them and max_depth calls deep: grown out
|
|
262
266
|
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.
|
|
267
|
+
most code, so the boxes are the ones that do the work and each is joined to one above it. first takes every
|
|
268
|
+
function the entry point calls before the rest.
|
|
264
269
|
"""
|
|
265
270
|
|
|
266
271
|
def depth(name: str) -> int:
|
|
@@ -283,6 +288,14 @@ def _pick(
|
|
|
283
288
|
if name not in level:
|
|
284
289
|
offer(name)
|
|
285
290
|
picked: list[str] = []
|
|
291
|
+
if first:
|
|
292
|
+
for child, _ in children.get(entry, []):
|
|
293
|
+
if child in by_name and child != entry and child not in picked and depth(child) <= max_depth:
|
|
294
|
+
picked.append(child)
|
|
295
|
+
for name in list(picked):
|
|
296
|
+
for child, _ in children.get(name, []):
|
|
297
|
+
if child not in picked:
|
|
298
|
+
offer(child)
|
|
286
299
|
while queue and len(picked) < room:
|
|
287
300
|
name = heapq.heappop(queue)[3]
|
|
288
301
|
if name in picked:
|
|
@@ -294,7 +307,46 @@ def _pick(
|
|
|
294
307
|
return picked
|
|
295
308
|
|
|
296
309
|
|
|
297
|
-
def
|
|
310
|
+
def _focused(cmap: CodeMap, focus: str) -> CodeMap:
|
|
311
|
+
"""
|
|
312
|
+
The map cut down to one of its functions and what it calls, however deep, as if the code started there: its
|
|
313
|
+
calls in from elsewhere, what runs at import and the entry point's input left out.
|
|
314
|
+
"""
|
|
315
|
+
children: dict[str, list[str]] = defaultdict(list)
|
|
316
|
+
for f in cmap.functions:
|
|
317
|
+
for where in f.called_from:
|
|
318
|
+
children[where.rpartition(" line ")[0]].append(f.name)
|
|
319
|
+
scope, todo = {focus}, [focus]
|
|
320
|
+
while todo:
|
|
321
|
+
for child in children.get(todo.pop(), []):
|
|
322
|
+
if child not in scope:
|
|
323
|
+
scope.add(child)
|
|
324
|
+
todo.append(child)
|
|
325
|
+
root = next(f for f in cmap.functions if f.name == focus)
|
|
326
|
+
functions = []
|
|
327
|
+
for f in cmap.functions:
|
|
328
|
+
if f.name in scope:
|
|
329
|
+
kept = [] if f.name == focus else [w for w in f.called_from if w.rpartition(" line ")[0] in scope]
|
|
330
|
+
functions.append(replace(f, called_from=kept, when={w: t for w, t in f.when.items() if w in kept}))
|
|
331
|
+
return replace(
|
|
332
|
+
cmap,
|
|
333
|
+
entry=focus,
|
|
334
|
+
handler_file=root.file,
|
|
335
|
+
entry_line=root.line,
|
|
336
|
+
startup=[],
|
|
337
|
+
event=[],
|
|
338
|
+
functions=functions,
|
|
339
|
+
aws=[c for c in cmap.aws if c.function in scope],
|
|
340
|
+
library_calls=[c for c in cmap.library_calls if c.function in scope],
|
|
341
|
+
env=[e for e in cmap.env if e.function in scope],
|
|
342
|
+
spots=[s for s in cmap.spots if s.function in scope and not s.startup],
|
|
343
|
+
routes=[r for r in cmap.routes if r[2] in scope],
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def code_graph(
|
|
348
|
+
cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_depth: int = _GRAPH_DEPTH, focus: str = ""
|
|
349
|
+
) -> CodeGraph:
|
|
298
350
|
"""
|
|
299
351
|
How the code fits together, as boxes and arrows, from a CodeMap: on the left what comes in (the event, with the
|
|
300
352
|
fields it reads, and the environment variables), then the handler, what runs at start-up, and its own functions
|
|
@@ -304,10 +356,16 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
|
|
|
304
356
|
they do is drawn from the nearest box above them that is.
|
|
305
357
|
The functions drawn are grown out from the entry point, each time the one that leads to the most code (and to AWS,
|
|
306
358
|
the web and the environment), so the boxes are the ones that do the work, not the nearest helpers.
|
|
359
|
+
focus draws the graph from one of its functions instead (module.name): it and what it calls, every call it makes
|
|
360
|
+
drawn even past max_functions, and nothing from what comes in or what runs at import.
|
|
307
361
|
"""
|
|
308
362
|
graph = CodeGraph()
|
|
309
363
|
if cmap.note or cmap.entry is None:
|
|
310
364
|
return graph
|
|
365
|
+
if focus:
|
|
366
|
+
if not any(f.name == focus for f in cmap.functions):
|
|
367
|
+
return graph
|
|
368
|
+
cmap = _focused(cmap, focus)
|
|
311
369
|
platform = get_platform(cmap.platform)
|
|
312
370
|
entry = cmap.entry
|
|
313
371
|
by_name = {f.name: f for f in cmap.functions}
|
|
@@ -342,11 +400,15 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
|
|
|
342
400
|
todo.append(child)
|
|
343
401
|
order = {f.name: i for i, f in enumerate(cmap.functions)}
|
|
344
402
|
weight = _weights(cmap, children)
|
|
345
|
-
|
|
403
|
+
room = max_functions - entry_is_function
|
|
404
|
+
if focus: # a function opened on its own shows every call it makes
|
|
405
|
+
room = max(room, len({child for child, _ in children.get(entry, [])}))
|
|
406
|
+
picked = _pick(cmap, entry, start_key, children, level, weight, order, room, max_depth, first=bool(focus))
|
|
346
407
|
shown = ([by_name[entry]] if entry_is_function else []) + sorted(
|
|
347
408
|
(by_name[name] for name in picked), key=lambda f: (level.get(f.name, f.depth + 1), order[f.name])
|
|
348
409
|
)
|
|
349
410
|
graph.hidden = len(cmap.functions) - len(shown)
|
|
411
|
+
graph.too_deep = sum(1 for f in cmap.functions if level.get(f.name, f.depth + 1) > max_depth)
|
|
350
412
|
ids = {f.name: f"f{i}" for i, f in enumerate(shown)}
|
|
351
413
|
drawn_level = {entry: 0, start_key: 0} # calls from the entry point through the boxes drawn: each box's column
|
|
352
414
|
todo = [entry, start_key]
|
|
@@ -408,7 +470,9 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
|
|
|
408
470
|
reasons[call.function].append(f"{call.label} isn't allowed: {denied[call.permission]} (line {call.line})")
|
|
409
471
|
for function, why in platform.marks(cmap).items():
|
|
410
472
|
reasons[function] += why
|
|
411
|
-
handler_reasons =
|
|
473
|
+
handler_reasons = (
|
|
474
|
+
[] if focus else [m.split(":")[0] for level_, m in platform.entry_findings(cmap) if level_ == "warn"]
|
|
475
|
+
)
|
|
412
476
|
unset = platform.unset_env(cmap)
|
|
413
477
|
|
|
414
478
|
# the lines in each function worth pointing at: where it calls, reads or sends what the graph draws
|
|
@@ -455,7 +519,7 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
|
|
|
455
519
|
when=_when(f, [where for where in f.called_from if where.rpartition(" line ")[0] == caller]),
|
|
456
520
|
)
|
|
457
521
|
)
|
|
458
|
-
has_input = bool(cmap.event) or platform.draws_input
|
|
522
|
+
has_input = not focus and (bool(cmap.event) or platform.draws_input)
|
|
459
523
|
if has_input:
|
|
460
524
|
edges.insert(0, GraphEdge("event", entry_id, "event", "its input"))
|
|
461
525
|
|
|
@@ -769,7 +833,9 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
|
|
|
769
833
|
],
|
|
770
834
|
more=more,
|
|
771
835
|
when=_when(f, f.called_from),
|
|
772
|
-
icon=platform.entry_icon(cmap) if is_entry else "",
|
|
836
|
+
icon=platform.entry_icon(cmap) if is_entry and not focus else "",
|
|
837
|
+
name=f.name,
|
|
838
|
+
history=f.history,
|
|
773
839
|
)
|
|
774
840
|
)
|
|
775
841
|
out_column = 1 + max([n.column for n in graph.nodes if n.kind in ("handler", "function", "start")] + [1])
|
|
@@ -778,3 +844,80 @@ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_dept
|
|
|
778
844
|
graph.nodes += reach
|
|
779
845
|
graph.edges = edges
|
|
780
846
|
return graph
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
_GRAPH_VIEWS = 120 # functions opened on their own, at most: past that a box's calls left out stay in the steps
|
|
850
|
+
|
|
851
|
+
_GRAPH_EVERYTHING = 150 # functions, at most, for a graph of all of them besides the one drawn
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
@dataclass
|
|
855
|
+
class GraphView:
|
|
856
|
+
"""
|
|
857
|
+
The graph from one of the functions a graph leaves calls out of: the function and what it calls (`graph`), and the
|
|
858
|
+
calls from the entry point down to it (`path`, module.name each, the entry point first and the function last).
|
|
859
|
+
"""
|
|
860
|
+
|
|
861
|
+
name: str
|
|
862
|
+
graph: CodeGraph
|
|
863
|
+
path: list[str] = field(default_factory=list)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _call_path(cmap: CodeMap, name: str) -> list[str]:
|
|
867
|
+
"""
|
|
868
|
+
The fewest calls from the entry point to a function: [entry, ..., name]. [name] when the entry point isn't above it.
|
|
869
|
+
"""
|
|
870
|
+
known = {f.name for f in cmap.functions} | {cmap.entry or ""}
|
|
871
|
+
parents: dict[str, list[str]] = defaultdict(list)
|
|
872
|
+
for f in cmap.functions:
|
|
873
|
+
for where in f.called_from:
|
|
874
|
+
caller = where.rpartition(" line ")[0]
|
|
875
|
+
if caller in known:
|
|
876
|
+
parents[f.name].append(caller)
|
|
877
|
+
came: dict[str, str] = {name: ""}
|
|
878
|
+
todo = [name]
|
|
879
|
+
while todo:
|
|
880
|
+
key = todo.pop(0)
|
|
881
|
+
if key == cmap.entry:
|
|
882
|
+
path = [key]
|
|
883
|
+
while came[path[-1]]:
|
|
884
|
+
path.append(came[path[-1]])
|
|
885
|
+
return path
|
|
886
|
+
for parent in parents.get(key, []):
|
|
887
|
+
if parent not in came:
|
|
888
|
+
came[parent] = key
|
|
889
|
+
todo.append(parent)
|
|
890
|
+
return [name]
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def graph_views(
|
|
894
|
+
cmap: CodeMap,
|
|
895
|
+
graph: CodeGraph,
|
|
896
|
+
*,
|
|
897
|
+
max_functions: int = _GRAPH_FUNCTIONS,
|
|
898
|
+
max_depth: int = _GRAPH_DEPTH,
|
|
899
|
+
most: int = _GRAPH_VIEWS,
|
|
900
|
+
) -> list[GraphView]:
|
|
901
|
+
"""
|
|
902
|
+
A graph from each function whose calls `graph` leaves out, and from each such function in those, so that from any
|
|
903
|
+
box with calls not drawn, its own graph has them: at most `most`, nearest the entry point first.
|
|
904
|
+
"""
|
|
905
|
+
views: dict[str, GraphView] = {}
|
|
906
|
+
todo = [graph]
|
|
907
|
+
while todo and len(views) < most:
|
|
908
|
+
for n in todo.pop(0).nodes:
|
|
909
|
+
if not (n.more and n.name) or n.name in views or len(views) >= most:
|
|
910
|
+
continue
|
|
911
|
+
opened = code_graph(cmap, max_functions=max_functions, max_depth=max_depth, focus=n.name)
|
|
912
|
+
views[n.name] = GraphView(n.name, opened, _call_path(cmap, n.name))
|
|
913
|
+
todo.append(opened)
|
|
914
|
+
return list(views.values())
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def everything_graph(cmap: CodeMap, graph: CodeGraph, most: int = _GRAPH_EVERYTHING) -> CodeGraph | None:
|
|
918
|
+
"""
|
|
919
|
+
Every function in one graph, when `graph` leaves some out and there are at most `most` of them; else None.
|
|
920
|
+
"""
|
|
921
|
+
if not graph.hidden or len(cmap.functions) > most:
|
|
922
|
+
return None
|
|
923
|
+
return code_graph(cmap, max_functions=len(cmap.functions) + 1, max_depth=len(cmap.functions) + 1)
|
code_node/analysis/model.py
CHANGED
|
@@ -173,6 +173,50 @@ 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
|
+
|
|
208
|
+
def ago(self, time: int) -> str:
|
|
209
|
+
"""
|
|
210
|
+
'today', '3 days ago', '2 months ago', '1 year ago'.
|
|
211
|
+
"""
|
|
212
|
+
days = max(0, self.now - time) // 86_400
|
|
213
|
+
for size, unit in ((365, "year"), (30, "month"), (7, "week"), (1, "day")):
|
|
214
|
+
if days >= size:
|
|
215
|
+
count = days // size
|
|
216
|
+
return f"{count} {unit}{'' if count == 1 else 's'} ago"
|
|
217
|
+
return "today"
|
|
218
|
+
|
|
219
|
+
|
|
176
220
|
@dataclass
|
|
177
221
|
class CodeFunction:
|
|
178
222
|
"""
|
|
@@ -190,6 +234,7 @@ class CodeFunction:
|
|
|
190
234
|
# nearest around the call, else the nearest `if x: return` above it ('args.serve', not every check before it)
|
|
191
235
|
depth: int = 0 # calls from the entry point (the entry point is 0)
|
|
192
236
|
is_async: bool = False # async def: a call that blocks in it holds up everything else on its event loop
|
|
237
|
+
history: History | None = None # what git says about its lines, when the folder is in git (history.py)
|
|
193
238
|
|
|
194
239
|
|
|
195
240
|
@dataclass
|
code_node/analysis/reader.py
CHANGED
|
@@ -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
|
-
|
|
315
|
-
|
|
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/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,
|
|
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,151 @@
|
|
|
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, and the lines changed since the last commit.
|
|
4
|
+
|
|
5
|
+
git is run on the folder, never the code in it, and only to read: blame with no text conversion and no file system
|
|
6
|
+
monitor. A folder whose own git config names a filter command (one git would run on the files) isn't read at all.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .analysis.model import CodeFunction, Commit, History
|
|
20
|
+
|
|
21
|
+
_TIMEOUT = 20 # seconds a git command may take before its file goes without history
|
|
22
|
+
|
|
23
|
+
_WORKERS = 8 # files blamed at once
|
|
24
|
+
|
|
25
|
+
_UNCOMMITTED = "0" * 40 # the commit blame gives lines changed since the last one
|
|
26
|
+
|
|
27
|
+
_HOSTS = {"github.com": "{}/commit/{}", "gitlab.com": "{}/-/commit/{}"} # a commit's page, by host
|
|
28
|
+
|
|
29
|
+
_SAFE = ["-c", "core.fsmonitor=false", "--no-pager"] # no program of the folder's config runs
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
33
|
+
"""
|
|
34
|
+
What a git command prints, run in root; None when git isn't there or fails (not a repository, a file it doesn't
|
|
35
|
+
track, a folder it doesn't trust).
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
done = subprocess.run(
|
|
39
|
+
["git", *_SAFE, "-C", str(root), *args],
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
encoding="utf-8",
|
|
43
|
+
errors="replace",
|
|
44
|
+
timeout=_TIMEOUT,
|
|
45
|
+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"},
|
|
46
|
+
)
|
|
47
|
+
except (OSError, subprocess.SubprocessError):
|
|
48
|
+
return None
|
|
49
|
+
return done.stdout if done.returncode == 0 else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _runs_filters(root: Path) -> bool:
|
|
53
|
+
"""
|
|
54
|
+
Whether the folder's own git config (not the user's) names a filter command, which blame would run on its files.
|
|
55
|
+
"""
|
|
56
|
+
found = _git(root, "config", "--show-scope", "--get-regexp", r"^filter\..*\.(clean|process)$")
|
|
57
|
+
return any(line.split("\t", 1)[0] in ("local", "worktree") for line in (found or "").splitlines())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _commit_page(remote: str) -> str:
|
|
61
|
+
"""
|
|
62
|
+
The repository's web address with a {} for the sha, from its origin: 'https://github.com/o/r/commit/{}'.
|
|
63
|
+
"""
|
|
64
|
+
found = re.fullmatch(r"(?:https?://(?:[^@/]+@)?|ssh://(?:[^@/]+@)?|[^@/]+@)([^/:]+)[:/](.+?)(?:\.git)?/?", remote)
|
|
65
|
+
if not found or found.group(1) not in _HOSTS:
|
|
66
|
+
return ""
|
|
67
|
+
return _HOSTS[found.group(1)].format(f"https://{found.group(1)}/{found.group(2)}", "{}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _blame(root: Path, file: str, start: int, end: int) -> dict[int, tuple[str, dict[str, str]]]:
|
|
71
|
+
"""
|
|
72
|
+
Each line from start to end of the file as it is now: line -> (the commit it comes from, that commit's headers).
|
|
73
|
+
"""
|
|
74
|
+
out = _git(root, "blame", "--porcelain", "--no-textconv", "-L", f"{start},{end}", "--", file)
|
|
75
|
+
lines: dict[int, tuple[str, dict[str, str]]] = {}
|
|
76
|
+
if not out:
|
|
77
|
+
return lines
|
|
78
|
+
headers: dict[str, dict[str, str]] = {}
|
|
79
|
+
sha = ""
|
|
80
|
+
for row in out.splitlines():
|
|
81
|
+
if row.startswith("\t"):
|
|
82
|
+
continue
|
|
83
|
+
parts = row.split(" ")
|
|
84
|
+
if len(parts) >= 3 and len(parts[0]) == 40 and parts[1].isdigit() and parts[2].isdigit():
|
|
85
|
+
sha = parts[0]
|
|
86
|
+
headers.setdefault(sha, {})
|
|
87
|
+
lines[int(parts[2])] = (sha, headers[sha])
|
|
88
|
+
elif sha:
|
|
89
|
+
key, _, value = row.partition(" ")
|
|
90
|
+
headers[sha].setdefault(key, value)
|
|
91
|
+
return lines
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _history(blamed: dict[int, tuple[str, dict[str, str]]], start: int, end: int, page: str, now: int) -> History:
|
|
95
|
+
"""
|
|
96
|
+
A function's history from its lines' blame.
|
|
97
|
+
"""
|
|
98
|
+
commits: dict[str, Commit] = {}
|
|
99
|
+
by_author: Counter[str] = Counter()
|
|
100
|
+
uncommitted = 0
|
|
101
|
+
for k in range(start, end + 1):
|
|
102
|
+
if k not in blamed:
|
|
103
|
+
continue
|
|
104
|
+
sha, said = blamed[k]
|
|
105
|
+
if sha == _UNCOMMITTED:
|
|
106
|
+
uncommitted += 1
|
|
107
|
+
continue
|
|
108
|
+
if sha not in commits:
|
|
109
|
+
commits[sha] = Commit(
|
|
110
|
+
sha,
|
|
111
|
+
said.get("author", "?"),
|
|
112
|
+
int(said.get("author-time", "0") or 0),
|
|
113
|
+
said.get("summary", ""),
|
|
114
|
+
page.format(sha) if page else "",
|
|
115
|
+
)
|
|
116
|
+
by_author[commits[sha].author] += 1
|
|
117
|
+
last = max(commits.values(), key=lambda c: c.time, default=None)
|
|
118
|
+
return History(
|
|
119
|
+
last=last,
|
|
120
|
+
commits=len(commits),
|
|
121
|
+
authors=[name for name, _ in by_author.most_common()],
|
|
122
|
+
first=min((c.time for c in commits.values()), default=0),
|
|
123
|
+
uncommitted=uncommitted,
|
|
124
|
+
now=now,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_history(root: Path, functions: list[CodeFunction]) -> int:
|
|
129
|
+
"""
|
|
130
|
+
Fills in each function's history from git, blaming each file once over the lines its functions take up. How many
|
|
131
|
+
functions it found history for: 0 when the folder isn't in git, git isn't installed, or the folder's config would
|
|
132
|
+
have git run a program of its own.
|
|
133
|
+
"""
|
|
134
|
+
spans: dict[str, tuple[int, int]] = {}
|
|
135
|
+
for f in functions:
|
|
136
|
+
if f.file and f.line and f.lines:
|
|
137
|
+
start, end = spans.get(f.file, (f.line, f.line + f.lines - 1))
|
|
138
|
+
spans[f.file] = (min(start, f.line), max(end, f.line + f.lines - 1))
|
|
139
|
+
if not spans or _git(root, "rev-parse", "--is-inside-work-tree") is None or _runs_filters(root):
|
|
140
|
+
return 0
|
|
141
|
+
page = _commit_page((_git(root, "remote", "get-url", "origin") or "").strip())
|
|
142
|
+
now = int(time.time())
|
|
143
|
+
with ThreadPoolExecutor(max_workers=min(_WORKERS, len(spans))) as pool:
|
|
144
|
+
blamed = dict(zip(spans, pool.map(lambda file: _blame(root, file, *spans[file]), spans)))
|
|
145
|
+
found = 0
|
|
146
|
+
for f in functions:
|
|
147
|
+
lines = blamed.get(f.file)
|
|
148
|
+
if lines:
|
|
149
|
+
f.history = _history(lines, f.line, f.line + f.lines - 1, page, now)
|
|
150
|
+
found += 1
|
|
151
|
+
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()
|
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:
|
|
38
|
-
"
|
|
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(),
|
|
@@ -0,0 +1,42 @@
|
|
|
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 panel, its hover card and the walk's card.
|
|
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 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>"
|