code-node-graph 0.1.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.
Files changed (178) hide show
  1. code_node/__init__.py +40 -0
  2. code_node/__main__.py +7 -0
  3. code_node/analysis/__init__.py +3 -0
  4. code_node/analysis/aws_model.py +197 -0
  5. code_node/analysis/findings.py +462 -0
  6. code_node/analysis/graph.py +780 -0
  7. code_node/analysis/mapping.py +114 -0
  8. code_node/analysis/model.py +289 -0
  9. code_node/analysis/reader.py +1926 -0
  10. code_node/analysis/syntax.py +220 -0
  11. code_node/analysis/terms.py +505 -0
  12. code_node/cli.py +316 -0
  13. code_node/deploys/__init__.py +321 -0
  14. code_node/deploys/sam.py +371 -0
  15. code_node/deploys/serverless.py +117 -0
  16. code_node/kit/__init__.py +4 -0
  17. code_node/kit/blocks.py +184 -0
  18. code_node/kit/fmt.py +389 -0
  19. code_node/kit/highlight.py +131 -0
  20. code_node/kit/render.py +1066 -0
  21. code_node/kit/text.py +81 -0
  22. code_node/libraries/__init__.py +333 -0
  23. code_node/libraries/llm.py +124 -0
  24. code_node/libraries/mongo.py +169 -0
  25. code_node/libraries/redis.py +253 -0
  26. code_node/libraries/sql.py +232 -0
  27. code_node/pages.py +92 -0
  28. code_node/platforms/__init__.py +203 -0
  29. code_node/platforms/aws_lambda.py +290 -0
  30. code_node/platforms/web.py +301 -0
  31. code_node/project.py +402 -0
  32. code_node/report/__init__.py +3 -0
  33. code_node/report/brands.py +244 -0
  34. code_node/report/build.py +119 -0
  35. code_node/report/editor.py +40 -0
  36. code_node/report/graph.py +1411 -0
  37. code_node/report/logos/README.md +8 -0
  38. code_node/report/logos/anthropic-icon.svg +1 -0
  39. code_node/report/logos/aws-amplify.svg +1 -0
  40. code_node/report/logos/aws-api-gateway.svg +1 -0
  41. code_node/report/logos/aws-appflow.svg +1 -0
  42. code_node/report/logos/aws-appsync.svg +1 -0
  43. code_node/report/logos/aws-athena.svg +1 -0
  44. code_node/report/logos/aws-backup.svg +1 -0
  45. code_node/report/logos/aws-batch.svg +1 -0
  46. code_node/report/logos/aws-certificate-manager.svg +1 -0
  47. code_node/report/logos/aws-cloudformation.svg +1 -0
  48. code_node/report/logos/aws-cloudfront.svg +1 -0
  49. code_node/report/logos/aws-cloudtrail.svg +1 -0
  50. code_node/report/logos/aws-cloudwatch.svg +1 -0
  51. code_node/report/logos/aws-codebuild.svg +1 -0
  52. code_node/report/logos/aws-codecommit.svg +1 -0
  53. code_node/report/logos/aws-codedeploy.svg +1 -0
  54. code_node/report/logos/aws-codepipeline.svg +1 -0
  55. code_node/report/logos/aws-cognito.svg +1 -0
  56. code_node/report/logos/aws-config.svg +1 -0
  57. code_node/report/logos/aws-documentdb.svg +1 -0
  58. code_node/report/logos/aws-dynamodb.svg +1 -0
  59. code_node/report/logos/aws-ec2.svg +1 -0
  60. code_node/report/logos/aws-ecs.svg +1 -0
  61. code_node/report/logos/aws-eks.svg +1 -0
  62. code_node/report/logos/aws-elasticache.svg +1 -0
  63. code_node/report/logos/aws-elb.svg +1 -0
  64. code_node/report/logos/aws-eventbridge.svg +1 -0
  65. code_node/report/logos/aws-glacier.svg +1 -0
  66. code_node/report/logos/aws-glue.svg +1 -0
  67. code_node/report/logos/aws-iam.svg +1 -0
  68. code_node/report/logos/aws-keyspaces.svg +1 -0
  69. code_node/report/logos/aws-kinesis.svg +1 -0
  70. code_node/report/logos/aws-kms.svg +1 -0
  71. code_node/report/logos/aws-lake-formation.svg +1 -0
  72. code_node/report/logos/aws-lambda.svg +1 -0
  73. code_node/report/logos/aws-lightsail.svg +1 -0
  74. code_node/report/logos/aws-mq.svg +1 -0
  75. code_node/report/logos/aws-msk.svg +1 -0
  76. code_node/report/logos/aws-neptune.svg +1 -0
  77. code_node/report/logos/aws-open-search.svg +1 -0
  78. code_node/report/logos/aws-quicksight.svg +1 -0
  79. code_node/report/logos/aws-rds.svg +1 -0
  80. code_node/report/logos/aws-redshift.svg +1 -0
  81. code_node/report/logos/aws-route53.svg +1 -0
  82. code_node/report/logos/aws-s3.svg +1 -0
  83. code_node/report/logos/aws-secrets-manager.svg +1 -0
  84. code_node/report/logos/aws-ses.svg +1 -0
  85. code_node/report/logos/aws-shield.svg +1 -0
  86. code_node/report/logos/aws-sns.svg +1 -0
  87. code_node/report/logos/aws-sqs.svg +1 -0
  88. code_node/report/logos/aws-step-functions.svg +1 -0
  89. code_node/report/logos/aws-systems-manager.svg +1 -0
  90. code_node/report/logos/aws-timestream.svg +1 -0
  91. code_node/report/logos/aws-waf.svg +1 -0
  92. code_node/report/logos/aws-xray.svg +1 -0
  93. code_node/report/logos/aws.svg +1 -0
  94. code_node/report/logos/azure-icon.svg +1 -0
  95. code_node/report/logos/django-icon.svg +1 -0
  96. code_node/report/logos/elasticsearch.svg +1 -0
  97. code_node/report/logos/fastapi-icon.svg +1 -0
  98. code_node/report/logos/firebase-icon.svg +1 -0
  99. code_node/report/logos/flask.svg +1 -0
  100. code_node/report/logos/google-cloud.svg +1 -0
  101. code_node/report/logos/kafka-icon.svg +1 -0
  102. code_node/report/logos/mariadb-icon.svg +1 -0
  103. code_node/report/logos/mongodb-icon.svg +1 -0
  104. code_node/report/logos/mysql-icon.svg +1 -0
  105. code_node/report/logos/openai-icon.svg +1 -0
  106. code_node/report/logos/postgresql.svg +1 -0
  107. code_node/report/logos/python.svg +1 -0
  108. code_node/report/logos/rabbitmq-icon.svg +1 -0
  109. code_node/report/logos/redis.svg +1 -0
  110. code_node/report/logos/serverless.svg +1 -0
  111. code_node/report/logos/slack-icon.svg +1 -0
  112. code_node/report/logos/small/aws-api-gateway.svg +1 -0
  113. code_node/report/logos/small/aws-appflow.svg +1 -0
  114. code_node/report/logos/small/aws-appsync.svg +1 -0
  115. code_node/report/logos/small/aws-athena.svg +1 -0
  116. code_node/report/logos/small/aws-backup.svg +1 -0
  117. code_node/report/logos/small/aws-batch.svg +1 -0
  118. code_node/report/logos/small/aws-certificate-manager.svg +1 -0
  119. code_node/report/logos/small/aws-cloudformation.svg +1 -0
  120. code_node/report/logos/small/aws-cloudfront.svg +1 -0
  121. code_node/report/logos/small/aws-cloudtrail.svg +1 -0
  122. code_node/report/logos/small/aws-cloudwatch.svg +1 -0
  123. code_node/report/logos/small/aws-codebuild.svg +1 -0
  124. code_node/report/logos/small/aws-codecommit.svg +1 -0
  125. code_node/report/logos/small/aws-codedeploy.svg +1 -0
  126. code_node/report/logos/small/aws-codepipeline.svg +1 -0
  127. code_node/report/logos/small/aws-cognito.svg +1 -0
  128. code_node/report/logos/small/aws-config.svg +1 -0
  129. code_node/report/logos/small/aws-documentdb.svg +1 -0
  130. code_node/report/logos/small/aws-dynamodb.svg +1 -0
  131. code_node/report/logos/small/aws-ec2.svg +1 -0
  132. code_node/report/logos/small/aws-ecs.svg +1 -0
  133. code_node/report/logos/small/aws-eks.svg +1 -0
  134. code_node/report/logos/small/aws-elasticache.svg +1 -0
  135. code_node/report/logos/small/aws-elb.svg +1 -0
  136. code_node/report/logos/small/aws-eventbridge.svg +1 -0
  137. code_node/report/logos/small/aws-glacier.svg +1 -0
  138. code_node/report/logos/small/aws-glue.svg +1 -0
  139. code_node/report/logos/small/aws-iam.svg +1 -0
  140. code_node/report/logos/small/aws-keyspaces.svg +1 -0
  141. code_node/report/logos/small/aws-kinesis.svg +1 -0
  142. code_node/report/logos/small/aws-kms.svg +1 -0
  143. code_node/report/logos/small/aws-lake-formation.svg +1 -0
  144. code_node/report/logos/small/aws-lambda.svg +1 -0
  145. code_node/report/logos/small/aws-mq.svg +1 -0
  146. code_node/report/logos/small/aws-msk.svg +1 -0
  147. code_node/report/logos/small/aws-neptune.svg +1 -0
  148. code_node/report/logos/small/aws-open-search.svg +1 -0
  149. code_node/report/logos/small/aws-quicksight.svg +1 -0
  150. code_node/report/logos/small/aws-rds.svg +1 -0
  151. code_node/report/logos/small/aws-redshift.svg +1 -0
  152. code_node/report/logos/small/aws-route53.svg +1 -0
  153. code_node/report/logos/small/aws-s3.svg +1 -0
  154. code_node/report/logos/small/aws-secrets-manager.svg +1 -0
  155. code_node/report/logos/small/aws-ses.svg +1 -0
  156. code_node/report/logos/small/aws-shield.svg +1 -0
  157. code_node/report/logos/small/aws-sns.svg +1 -0
  158. code_node/report/logos/small/aws-sqs.svg +1 -0
  159. code_node/report/logos/small/aws-step-functions.svg +1 -0
  160. code_node/report/logos/small/aws-systems-manager.svg +1 -0
  161. code_node/report/logos/small/aws-timestream.svg +1 -0
  162. code_node/report/logos/small/aws-waf.svg +1 -0
  163. code_node/report/logos/small/aws-xray.svg +1 -0
  164. code_node/report/logos/snowflake-icon.svg +1 -0
  165. code_node/report/logos/sqlite.svg +1 -0
  166. code_node/report/logos/stripe.svg +1 -0
  167. code_node/report/logos/supabase-icon.svg +1 -0
  168. code_node/report/logos/twilio-icon.svg +1 -0
  169. code_node/report/outline.py +219 -0
  170. code_node/report/style.py +358 -0
  171. code_node/report/tabs.py +73 -0
  172. code_node/report/walk.py +624 -0
  173. code_node/serve.py +277 -0
  174. code_node_graph-0.1.0.dist-info/METADATA +165 -0
  175. code_node_graph-0.1.0.dist-info/RECORD +178 -0
  176. code_node_graph-0.1.0.dist-info/WHEEL +4 -0
  177. code_node_graph-0.1.0.dist-info/entry_points.txt +2 -0
  178. code_node_graph-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,780 @@
1
+ """
2
+ The code map as a CodeGraph: its inputs, the entry point and its functions, and what it
3
+ reaches, with .mermaid().
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import heapq
9
+ import re
10
+ from collections import defaultdict
11
+ from dataclasses import dataclass, field
12
+
13
+ from ..kit.fmt import _plural
14
+ from ..kit.text import _clip
15
+
16
+ from .syntax import _a
17
+ from .aws_model import _service_name
18
+ from .model import AwsCall, CodeFunction, CodeMap, CodeSpot, CodeStep, LibraryCall
19
+ from .findings import _listed, _short_name
20
+ from ..platforms import get_platform
21
+
22
+
23
+ @dataclass
24
+ class CodeSpan:
25
+ """
26
+ Lines of the code a box in how_it_works()'s graph is about, for a front end to show beside it: a function whole,
27
+ or a few lines around each place it's called with, read or reached from.
28
+ """
29
+
30
+ file: str
31
+ start: int
32
+ end: int
33
+ lit: list[int] = field(default_factory=list) # the lines in it worth pointing at: where it calls, reads or sends
34
+
35
+
36
+ @dataclass
37
+ class GraphNode:
38
+ """
39
+ A box in how_it_works()'s graph: what calls the code (the event), what it's configured with (its environment),
40
+ one of its own functions, or what it reaches (an AWS service, the web).
41
+ """
42
+
43
+ id: str # 'event', 'env', 'start', 'f0' (a function), 'aws-s3', 'http', 'lib-redis-redis'
44
+ kind: str # 'event', 'env', 'start', 'handler', 'function', 'aws', 'http', 'service' (one a library reaches)
45
+ label: str # 'handler()', 'S3', 'an SQS batch'
46
+ sub: str = "" # under the label: 'etl.py line 13', 'reads · writes'
47
+ lines: list[str] = field(default_factory=list) # what the box lists: its operations, fields or variables
48
+ tone: str = "" # 'warn' when a warning in the report is about it
49
+ tip: str = "" # said on hover
50
+ column: int = 0 # left to right: 0 what comes in, 1 the handler, then its functions by depth, last what it reaches
51
+ flags: dict[str, str] = field(default_factory=dict) # a listed line a warning is about -> a word or two:
52
+ # {'TEAM_TOPIC': 'not set'}, {'Query': 'first page only'}, {'GET https://...': 'no timeout'}
53
+ code: list[CodeSpan] = field(default_factory=list) # the lines of code it's about
54
+ more: list[str] = field(default_factory=list) # its own functions it calls that the graph leaves out: 'save()'
55
+ when: str = "" # a function called only under an if, everywhere it's called: 'check(order)'
56
+ icon: str = "" # a service's icon, when its plugin names one: 'database', 'queue', 'spark'; or the entry
57
+ # point's logo, when its platform names one: 'fastapi-icon'
58
+
59
+
60
+ @dataclass
61
+ class GraphEdge:
62
+ """
63
+ An arrow in how_it_works()'s graph: a call, an AWS call, an HTTP request, or the event and variables coming in.
64
+ """
65
+
66
+ source: str # a GraphNode id
67
+ target: str
68
+ kind: str # 'call', 'event', 'env', 'http', 'client' (a client made at start-up), or an AWS call's access:
69
+ # 'reads', 'writes', 'sends', 'runs', 'signs'
70
+ label: str = "" # what goes along it: 'GetObject, PutItem', 'DB_HOST', 'line 19'
71
+ tone: str = "" # 'warn' when a warning in the report is about it
72
+ loop: bool = False # made inside a loop: once per item
73
+ guessed: bool = False # AWS calls known only by their method's name: their client couldn't be followed
74
+ indirect: bool = False # made in functions the graph leaves out, below the box it starts from
75
+ when: str = "" # a call made only under an if: its test, 'args.command == "init"'
76
+
77
+
78
+ @dataclass
79
+ class CodeGraph:
80
+ """
81
+ How the code fits together, as boxes and arrows: what comes in, its own functions and who calls whom, and what
82
+ they reach outside. code_graph() makes it from a CodeMap; mermaid() writes it for a Markdown file.
83
+ """
84
+
85
+ nodes: list[GraphNode] = field(default_factory=list)
86
+ edges: list[GraphEdge] = field(default_factory=list)
87
+ hidden: int = 0 # functions left out to keep the graph readable (too deep, or too many)
88
+
89
+ def node(self, node_id: str) -> GraphNode | None:
90
+ return next((n for n in self.nodes if n.id == node_id), None)
91
+
92
+ def mermaid(self) -> str:
93
+ """
94
+ The graph in Mermaid's language, for a Markdown file, a wiki or a pull request that renders it.
95
+ """
96
+ shapes = {
97
+ "event": ("([", "])"),
98
+ "env": ("[/", "/]"),
99
+ "aws": ("[(", ")]"),
100
+ "service": ("[(", ")]"),
101
+ "http": ("{{", "}}"),
102
+ "start": ("[[", "]]"),
103
+ }
104
+ out = ["flowchart LR"]
105
+ for n in self.nodes:
106
+ left, right = shapes.get(n.kind, ("[", "]"))
107
+ text = n.label + (f"<br/><small>{n.sub}</small>" if n.sub else "")
108
+ out.append(f' {_mermaid_id(n.id)}{left}"{_mermaid_text(text)}"{right}')
109
+ arrows = {"call": "-->", "event": "==>", "env": "-.->"}
110
+ for e in self.edges:
111
+ arrow = arrows.get(e.kind, "-->")
112
+ label = e.label if e.kind != "call" or not e.label.startswith("line ") else ""
113
+ label = " · ".join(filter(None, [label, f"when {e.when}" if e.when else ""]))
114
+ text = f'|"{_mermaid_text(_clip(label, 60))}"|' if label else ""
115
+ out.append(f" {_mermaid_id(e.source)} {arrow}{text} {_mermaid_id(e.target)}")
116
+ warned = [n.id for n in self.nodes if n.tone == "warn"]
117
+ if warned:
118
+ out.append(" classDef warn stroke:#f59e0b,stroke-width:2px")
119
+ out.append(f" class {','.join(_mermaid_id(i) for i in warned)} warn")
120
+ return "\n".join(out)
121
+
122
+
123
+ def _mermaid_id(node_id: str) -> str:
124
+ return re.sub(r"\W", "_", node_id)
125
+
126
+
127
+ def _mermaid_text(text: str) -> str:
128
+ """
129
+ Text for a Mermaid label: its quotes and brackets as entities, so a label can hold any code.
130
+ """
131
+ return (
132
+ text.replace("#", "#35;")
133
+ .replace("&", "#amp;")
134
+ .replace('"', "#quot;")
135
+ .replace("<br/>", "\0")
136
+ .replace("<small>", "\1")
137
+ .replace("</small>", "\2")
138
+ .replace("<", "#lt;")
139
+ .replace(">", "#gt;")
140
+ .replace("\0", "<br/>")
141
+ .replace("\1", "<small>")
142
+ .replace("\2", "</small>")
143
+ )
144
+
145
+
146
+ def _service_node(library: str, service: str) -> str:
147
+ """
148
+ The box id of a service a library reaches: 'lib-sql-postgresql'. Letters, digits and hyphens only, as for AWS.
149
+ """
150
+ return "lib-" + (re.sub(r"[^a-z0-9]+", "-", f"{library} {service}".lower()).strip("-") or "service")
151
+
152
+
153
+ def _aws_node(service: str) -> str:
154
+ """
155
+ An AWS service's box id: 'aws-s3'. The service is a literal from the code, so only letters, digits and hyphens
156
+ stay: the id goes into class names and the CSS rules that light up what a box is joined to.
157
+ """
158
+ return "aws-" + (re.sub(r"[^a-z0-9]+", "-", service.lower()).strip("-") or "service")
159
+
160
+
161
+ _GRAPH_FUNCTIONS = 24 # functions code_graph() draws, at most (the ones that lead to the most code first)
162
+
163
+ _REACH_WEIGHT = 30 # lines of code an AWS call, HTTP request or environment variable counts as, picking boxes
164
+
165
+ _GRAPH_DEPTH = 5 # ...and how deep below the handler
166
+
167
+ _GRAPH_LINES = 5 # lines a box lists before "+3 more"
168
+
169
+
170
+ _SPAN_BEFORE, _SPAN_AFTER = 2, 3 # lines shown around each place a box is about
171
+
172
+
173
+ def _around(places: list[tuple[str, int, str]], functions: dict[str, CodeFunction]) -> list[CodeSpan]:
174
+ """
175
+ A few lines around each place (file, line, the function it's in), kept within that function when it's one of
176
+ `functions`, the places lit, in the order their files first come; places close together share their lines.
177
+ """
178
+ files = list(dict.fromkeys(file for file, _, _ in places))
179
+ spans: list[CodeSpan] = []
180
+ for file, line, function in sorted(set(places), key=lambda place: (files.index(place[0]), place[1])):
181
+ if line < 1:
182
+ continue
183
+ start, end = max(1, line - _SPAN_BEFORE), line + _SPAN_AFTER
184
+ f = functions.get(function)
185
+ if f is not None and f.file == file and f.line <= line < f.line + f.lines:
186
+ start, end = max(start, f.line), min(end, f.line + f.lines - 1)
187
+ if spans and spans[-1].file == file and start <= spans[-1].end + 1:
188
+ spans[-1].end = max(spans[-1].end, end)
189
+ spans[-1].lit.append(line)
190
+ else:
191
+ spans.append(CodeSpan(file, start, end, [line]))
192
+ return spans
193
+
194
+
195
+ def _start_places(steps: list[CodeStep]) -> list[tuple[str, int, str]]:
196
+ """
197
+ The lines that run when the code is imported, its modules' own (a function they call has its own box).
198
+ """
199
+ places = []
200
+ for step in steps:
201
+ if step.kind == "module":
202
+ places += _start_places(step.steps)
203
+ elif step.kind != "def":
204
+ places.append((step.file, step.line, ""))
205
+ places += _start_places(step.steps)
206
+ return places
207
+
208
+
209
+ def _listed_lines(items: list[str], most: int = _GRAPH_LINES) -> list[str]:
210
+ return items[:most] if len(items) <= most else items[: most - 1] + [f"+{len(items) - most + 1} more"]
211
+
212
+
213
+ def _when(f: CodeFunction, places: list[str]) -> str:
214
+ """
215
+ The condition a function is called under from these places ('check(order)'), when every one of them has one:
216
+ each place's nearest test, joined with 'or'. '' when one of them always calls it.
217
+ """
218
+ if not places or any(not f.when.get(where) for where in places):
219
+ return ""
220
+ return " or ".join(dict.fromkeys(f.when[where] for where in places))
221
+
222
+
223
+ def _weights(cmap: CodeMap, children: dict[str, list[tuple[str, str]]]) -> dict[str, int]:
224
+ """
225
+ How much each function leads to: the lines of code in it and in every function it calls, however deep (each
226
+ counted once), and _REACH_WEIGHT more for each AWS call, HTTP request and environment variable among them.
227
+ """
228
+ lines = {f.name: f.lines for f in cmap.functions}
229
+ for function in (
230
+ [c.function for c in cmap.aws]
231
+ + [c.function for c in cmap.library_calls]
232
+ + [e.function for e in cmap.env]
233
+ + [s.function for s in cmap.spots if s.kind == "http"]
234
+ ):
235
+ if function in lines:
236
+ lines[function] += _REACH_WEIGHT
237
+ weight: dict[str, int] = {}
238
+ for name in lines:
239
+ seen, todo = {name}, [name]
240
+ while todo:
241
+ for child, _ in children.get(todo.pop(), []):
242
+ if child not in seen:
243
+ seen.add(child)
244
+ todo.append(child)
245
+ weight[name] = sum(lines.get(k, 0) for k in seen)
246
+ return weight
247
+
248
+
249
+ def _pick(
250
+ cmap: CodeMap,
251
+ entry: str,
252
+ start_key: str,
253
+ children: dict[str, list[tuple[str, str]]],
254
+ level: dict[str, int],
255
+ weight: dict[str, int],
256
+ order: dict[str, int],
257
+ room: int,
258
+ max_depth: int,
259
+ ) -> list[str]:
260
+ """
261
+ The functions the graph draws besides the entry point, at most `room` of them and max_depth calls deep: grown out
262
+ 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.
264
+ """
265
+
266
+ def depth(name: str) -> int:
267
+ return level.get(name, by_name[name].depth + 1)
268
+
269
+ by_name = {f.name: f for f in cmap.functions}
270
+ allowed = [f.name for f in cmap.functions if f.name != entry and depth(f.name) <= max_depth]
271
+ if len(allowed) <= room:
272
+ return allowed
273
+ queue: list[tuple[int, int, int, str]] = []
274
+
275
+ def offer(name: str) -> None:
276
+ if name in by_name and name != entry and depth(name) <= max_depth:
277
+ heapq.heappush(queue, (-weight.get(name, 0), depth(name), order[name], name))
278
+
279
+ for root in (entry, start_key):
280
+ for child, _ in children.get(root, []):
281
+ offer(child)
282
+ for name in allowed: # nothing the drawn code is known to call it: offered from the start, as before
283
+ if name not in level:
284
+ offer(name)
285
+ picked: list[str] = []
286
+ while queue and len(picked) < room:
287
+ name = heapq.heappop(queue)[3]
288
+ if name in picked:
289
+ continue
290
+ picked.append(name)
291
+ for child, _ in children.get(name, []):
292
+ if child not in picked:
293
+ offer(child)
294
+ return picked
295
+
296
+
297
+ def code_graph(cmap: CodeMap, *, max_functions: int = _GRAPH_FUNCTIONS, max_depth: int = _GRAPH_DEPTH) -> CodeGraph:
298
+ """
299
+ How the code fits together, as boxes and arrows, from a CodeMap: on the left what comes in (the event, with the
300
+ fields it reads, and the environment variables), then the handler, what runs at start-up, and its own functions
301
+ by how many calls they are from those (an arrow for each call), and on the right the AWS services and the web it
302
+ reaches, each arrow by what it does (reads, writes, sends, runs). A box or an arrow a warning is about is marked,
303
+ and says which on hover. At most max_functions functions, max_depth calls deep; `hidden` counts the rest, and what
304
+ they do is drawn from the nearest box above them that is.
305
+ The functions drawn are grown out from the entry point, each time the one that leads to the most code (and to AWS,
306
+ the web and the environment), so the boxes are the ones that do the work, not the nearest helpers.
307
+ """
308
+ graph = CodeGraph()
309
+ if cmap.note or cmap.entry is None:
310
+ return graph
311
+ platform = get_platform(cmap.platform)
312
+ entry = cmap.entry
313
+ by_name = {f.name: f for f in cmap.functions}
314
+ entry_is_function = entry in by_name
315
+ start_key = "<start>" # a module's top level: what runs when Python imports the code
316
+
317
+ # who calls whom; a caller that isn't one of its functions is a module's top level, which runs at start-up
318
+ parents: dict[str, list[tuple[str, str]]] = defaultdict(list)
319
+ children: dict[str, list[tuple[str, str]]] = defaultdict(list)
320
+ for f in cmap.functions:
321
+ for where in f.called_from:
322
+ key, _, line = where.rpartition(" line ")
323
+ caller = key if key in by_name or key == entry else start_key
324
+ if (caller, line) not in parents[f.name]:
325
+ parents[f.name].append((caller, line))
326
+ children[caller].append((f.name, line))
327
+ routes: dict[str, list[str]] = defaultdict(list) # a function -> the routes it answers ('GET /items')
328
+ for method, path, key in cmap.routes:
329
+ routes[key].append(f"{method} {path}")
330
+ if not entry_is_function: # a handler that's an object (a web app's adapter) calls each route
331
+ for key in routes:
332
+ if key in by_name and (entry, "") not in parents[key]:
333
+ parents[key].append((entry, ""))
334
+ children[entry].append((key, ""))
335
+ level: dict[str, int] = {entry: 0, start_key: 0}
336
+ todo = [entry, start_key]
337
+ while todo:
338
+ key = todo.pop(0)
339
+ for child, _ in children.get(key, []):
340
+ if child not in level:
341
+ level[child] = level[key] + 1
342
+ todo.append(child)
343
+ order = {f.name: i for i, f in enumerate(cmap.functions)}
344
+ weight = _weights(cmap, children)
345
+ picked = _pick(cmap, entry, start_key, children, level, weight, order, max_functions - entry_is_function, max_depth)
346
+ shown = ([by_name[entry]] if entry_is_function else []) + sorted(
347
+ (by_name[name] for name in picked), key=lambda f: (level.get(f.name, f.depth + 1), order[f.name])
348
+ )
349
+ graph.hidden = len(cmap.functions) - len(shown)
350
+ ids = {f.name: f"f{i}" for i, f in enumerate(shown)}
351
+ drawn_level = {entry: 0, start_key: 0} # calls from the entry point through the boxes drawn: each box's column
352
+ todo = [entry, start_key]
353
+ while todo:
354
+ key = todo.pop(0)
355
+ for child, _ in children.get(key, []):
356
+ if child in ids and child not in drawn_level:
357
+ drawn_level[child] = drawn_level[key] + 1
358
+ todo.append(child)
359
+ if not entry_is_function:
360
+ ids[entry] = "f-entry"
361
+
362
+ def owner(key: str) -> tuple[str | None, bool]:
363
+ """
364
+ The box what a function (or a module's top level) does is drawn from, and whether that's a box above it
365
+ because the graph leaves the function out.
366
+ """
367
+ if key in ids:
368
+ return ids[key], False
369
+ if key not in by_name:
370
+ return "start", False
371
+ seen, queue = {key}, [key]
372
+ while queue:
373
+ for parent, _ in parents.get(queue.pop(0), []):
374
+ if parent in ids:
375
+ return ids[parent], True
376
+ if parent == start_key:
377
+ return "start", True
378
+ if parent not in seen:
379
+ seen.add(parent)
380
+ queue.append(parent)
381
+ return None, False
382
+
383
+ # why a box or arrow is amber: the warnings about it, said on hover
384
+ reasons: dict[str, list[str]] = defaultdict(list)
385
+ for spot in cmap.spots:
386
+ line = f" (line {spot.line})"
387
+ why = (
388
+ "an HTTP request without a timeout"
389
+ if spot.kind == "http" and not spot.ok
390
+ else "drops every error"
391
+ if spot.kind == "swallow"
392
+ else ""
393
+ )
394
+ if why:
395
+ reasons[spot.function].append(why + line)
396
+ for call in cmap.aws:
397
+ if call.pages == "one" and call.operation.startswith(("List", "Scan", "Query")) and not call.guessed:
398
+ reasons[call.function].append(f"{call.label} reads only its first page (line {call.line})")
399
+ for spot in cmap.spots:
400
+ if spot.kind == "command" and not spot.ok:
401
+ reasons[spot.function].append(f"runs a shell command built from {spot.detail} (line {spot.line})")
402
+ for lib_call in cmap.library_calls:
403
+ if lib_call.flag:
404
+ reasons[lib_call.function].append(f"{lib_call.label}: {lib_call.flag} (line {lib_call.line})")
405
+ denied = platform.denied(cmap)
406
+ for call in cmap.aws:
407
+ if call.permission in denied:
408
+ reasons[call.function].append(f"{call.label} isn't allowed: {denied[call.permission]} (line {call.line})")
409
+ for function, why in platform.marks(cmap).items():
410
+ reasons[function] += why
411
+ handler_reasons = [m.split(":")[0] for level_, m in platform.entry_findings(cmap) if level_ == "warn"]
412
+ unset = platform.unset_env(cmap)
413
+
414
+ # the lines in each function worth pointing at: where it calls, reads or sends what the graph draws
415
+ lit: dict[str, set[int]] = defaultdict(set)
416
+ for caller, called in children.items():
417
+ lit[caller] |= {int(line) for _, line in called if line.isdigit()}
418
+ for function, line in (
419
+ [(c.function, c.line) for c in cmap.aws]
420
+ + [(c.function, c.line) for c in cmap.library_calls]
421
+ + [(e.function, e.line) for e in cmap.env]
422
+ + [(s.function, s.line) for s in cmap.spots if s.kind == "http"]
423
+ ):
424
+ lit[function].add(line)
425
+
426
+ def warned_tip(tip: str, why: list[str]) -> str:
427
+ return tip + ("\nA warning is about it: " + "; ".join(why) if why else "")
428
+
429
+ edges: list[GraphEdge] = []
430
+ uses_start = bool(children.get(start_key))
431
+
432
+ # calls between its functions, and from start-up
433
+ entry_id = ids[entry]
434
+ seen_calls: set[tuple[str, str]] = set()
435
+ for f in shown:
436
+ for caller, line in parents.get(f.name, []):
437
+ source = "start" if caller == start_key else ids.get(caller)
438
+ if source is None or (source, ids[f.name]) in seen_calls:
439
+ continue
440
+ seen_calls.add((source, ids[f.name]))
441
+ lines = [ln for c, ln in parents[f.name] if c == caller and ln]
442
+ label = (
443
+ "; ".join(routes[f.name])
444
+ if caller == entry and f.name in routes
445
+ else "line " + ", ".join(lines[:3]) + (" …" if len(lines) > 3 else "")
446
+ if lines
447
+ else ""
448
+ )
449
+ edges.append(
450
+ GraphEdge(
451
+ source,
452
+ ids[f.name],
453
+ "call",
454
+ label,
455
+ when=_when(f, [where for where in f.called_from if where.rpartition(" line ")[0] == caller]),
456
+ )
457
+ )
458
+ has_input = bool(cmap.event) or platform.draws_input
459
+ if has_input:
460
+ edges.insert(0, GraphEdge("event", entry_id, "event", "its input"))
461
+
462
+ # the environment
463
+ names = list(dict.fromkeys(e.name for e in cmap.env))
464
+ reads: dict[str, list[str]] = {}
465
+ for e in cmap.env:
466
+ source, _ = owner(e.function)
467
+ if source is not None and e.name not in reads.setdefault(source, []):
468
+ reads[source].append(e.name)
469
+ for target, read in reads.items():
470
+ uses_start |= target == "start"
471
+ edges.append(
472
+ GraphEdge("env", target, "env", ", ".join(read), "warn" if any(name in unset for name in read) else "")
473
+ )
474
+
475
+ # what it reaches: AWS
476
+ services: dict[str, list[AwsCall]] = {}
477
+ for call in cmap.aws:
478
+ services.setdefault(call.service, []).append(call)
479
+ reach: list[GraphNode] = []
480
+ for service, calls in services.items():
481
+ node_id = _aws_node(service)
482
+ why = [
483
+ f"{c.operation} reads only its first page ({c.file} line {c.line})"
484
+ for c in calls
485
+ if c.pages == "one" and c.operation.startswith(("List", "Scan", "Query")) and not c.guessed
486
+ ]
487
+ why += list(
488
+ dict.fromkeys(
489
+ f"{c.operation} isn't allowed: {denied[c.permission]}" for c in calls if c.permission in denied
490
+ )
491
+ )
492
+ ops = list(dict.fromkeys(c.operation for c in calls))
493
+ does = list(dict.fromkeys(c.access for c in calls))
494
+ tip = f"{_service_name(service)}: " + ", ".join(
495
+ f"{c.operation} ({c.permission or 'no permission needed'})"
496
+ for c in {c.operation: c for c in calls}.values()
497
+ )
498
+ tip += "".join(
499
+ f"\n{c.operation} line {c.line}: " + ", ".join(f"{arg} ← {source}" for arg, source in c.args)
500
+ for c in calls
501
+ if c.args
502
+ )
503
+ if all(c.guessed for c in calls):
504
+ tip += "\nKnown only by the methods' names: their client couldn't be followed"
505
+ reach.append(
506
+ GraphNode(
507
+ node_id,
508
+ "aws",
509
+ _service_name(service),
510
+ " · ".join(does),
511
+ _listed_lines(ops),
512
+ "warn" if why else "",
513
+ warned_tip(tip, why),
514
+ flags={
515
+ **{
516
+ c.operation: "first page only"
517
+ for c in calls
518
+ if c.pages == "one" and not c.guessed and c.operation.startswith(("List", "Scan", "Query"))
519
+ },
520
+ **{c.operation: "not allowed" for c in calls if c.permission in denied},
521
+ },
522
+ code=_around([(c.file, c.line, c.function) for c in calls], by_name),
523
+ )
524
+ )
525
+ grouped: dict[tuple[str, str, bool], list[AwsCall]] = {}
526
+ for call in calls:
527
+ source, indirect = owner(call.function)
528
+ if source is not None:
529
+ grouped.setdefault((source, call.access, indirect), []).append(call)
530
+ for (source, access, indirect), group in grouped.items():
531
+ uses_start |= source == "start"
532
+ edges.append(
533
+ GraphEdge(
534
+ source,
535
+ node_id,
536
+ access,
537
+ ", ".join(dict.fromkeys(c.operation for c in group)),
538
+ "warn"
539
+ if any(
540
+ (c.pages == "one" and c.operation.startswith(("List", "Scan", "Query")) and not c.guessed)
541
+ or c.permission in denied
542
+ for c in group
543
+ )
544
+ else "",
545
+ any(c.loop for c in group),
546
+ all(c.guessed for c in group),
547
+ indirect,
548
+ )
549
+ )
550
+
551
+ # ...the services the libraries plugins know reach (Redis, a SQL database, OpenAI)
552
+ by_service: dict[tuple[str, str], list[LibraryCall]] = {}
553
+ for lib_call in cmap.library_calls:
554
+ by_service.setdefault((lib_call.library, lib_call.service), []).append(lib_call)
555
+ slow_clients = {s.detail: s for s in cmap.spots if s.kind == "connect" and s.waits}
556
+ for (library, service), lib_calls in by_service.items():
557
+ node_id = _service_node(library, service)
558
+ rows = list(dict.fromkeys(c.row for c in lib_calls))
559
+ flags = {c.row: c.flag for c in lib_calls if c.flag}
560
+ why = list(dict.fromkeys(f"{c.row}: {c.flag} ({c.file} line {c.line})" for c in lib_calls if c.flag))
561
+ if service in slow_clients:
562
+ spot = slow_clients[service]
563
+ why.append(f"its client has no timeout ({spot.file} line {spot.line}): a call waits {spot.waits}")
564
+ tip = f"{service}: " + ", ".join(rows[:8]) + (", …" if len(rows) > 8 else "")
565
+ reach.append(
566
+ GraphNode(
567
+ node_id,
568
+ "service",
569
+ service,
570
+ " · ".join(dict.fromkeys(c.access for c in lib_calls)),
571
+ _listed_lines(rows),
572
+ "warn" if why else "",
573
+ warned_tip(tip, why),
574
+ flags=flags,
575
+ code=_around([(c.file, c.line, c.function) for c in lib_calls], by_name),
576
+ icon=lib_calls[0].icon,
577
+ )
578
+ )
579
+ lib_grouped: dict[tuple[str, str, bool], list[LibraryCall]] = {}
580
+ for lib_call in lib_calls:
581
+ source, indirect = owner(lib_call.function)
582
+ if source is not None:
583
+ lib_grouped.setdefault((source, lib_call.access, indirect), []).append(lib_call)
584
+ for (source, access, indirect), group in lib_grouped.items():
585
+ uses_start |= source == "start"
586
+ edges.append(
587
+ GraphEdge(
588
+ source,
589
+ node_id,
590
+ access,
591
+ ", ".join(dict.fromkeys(c.row for c in group)),
592
+ "warn" if any(c.flag for c in group) else "",
593
+ any(c.loop for c in group),
594
+ indirect=indirect,
595
+ )
596
+ )
597
+
598
+ # ...the clients start-up (or a function it runs) makes, for the services they're for
599
+ clients = [s for s in cmap.spots if s.kind == "client" and s.startup]
600
+ made = []
601
+ for spot in clients:
602
+ source, _ = owner(spot.function)
603
+ target = _aws_node(spot.detail)
604
+ if source is None or not any(n.id == target for n in reach):
605
+ continue
606
+ if source == "start" and spot.detail != "?":
607
+ made.append(_service_name(spot.detail))
608
+ if not any(e.source == source and e.target == target and e.kind == "client" for e in edges):
609
+ uses_start |= source == "start"
610
+ edges.append(GraphEdge(source, target, "client", f"makes its {_service_name(spot.detail)} client"))
611
+ for spot in [s for s in cmap.spots if s.kind == "connect"]: # a library's, wherever it's made: connect() opens one
612
+ source, _ = owner(spot.function)
613
+ target = next((n.id for n in reach if n.kind == "service" and n.label == spot.detail), None)
614
+ if source is None or target is None:
615
+ continue
616
+ if source == "start":
617
+ made.append(spot.detail)
618
+ if not any(e.source == source and e.target == target and e.kind == "client" for e in edges):
619
+ uses_start |= source == "start"
620
+ edges.append(GraphEdge(source, target, "client", f"makes its {spot.detail} client", loop=bool(spot.loop)))
621
+
622
+ # ...and the web
623
+ http = [s for s in cmap.spots if s.kind == "http"]
624
+ if http:
625
+ slow = [s for s in http if not s.ok]
626
+ reach.append(
627
+ GraphNode(
628
+ "http",
629
+ "http",
630
+ "The web",
631
+ "HTTP requests",
632
+ _listed_lines(list(dict.fromkeys(s.detail for s in http))),
633
+ "warn" if slow else "",
634
+ warned_tip(
635
+ "HTTP requests it sends", [f"{s.detail} has no timeout ({s.file} line {s.line})" for s in slow]
636
+ ),
637
+ flags={s.detail: "no timeout" for s in slow},
638
+ code=_around([(s.file, s.line, s.function) for s in http], by_name),
639
+ )
640
+ )
641
+ by_source: dict[tuple[str, bool], list[CodeSpot]] = {}
642
+ for spot in http:
643
+ source, indirect = owner(spot.function)
644
+ if source is not None:
645
+ by_source.setdefault((source, indirect), []).append(spot)
646
+ for (source, indirect), group in by_source.items():
647
+ uses_start |= source == "start"
648
+ edges.append(
649
+ GraphEdge(
650
+ source,
651
+ "http",
652
+ "http",
653
+ ", ".join(dict.fromkeys(s.detail for s in group)),
654
+ "warn" if any(not s.ok for s in group) else "",
655
+ any(s.loop for s in group),
656
+ indirect=indirect,
657
+ )
658
+ )
659
+
660
+ # the boxes, left to right
661
+ labels = [f.label for f in cmap.event]
662
+ leaves = [
663
+ label
664
+ for label in labels
665
+ if not any(other.startswith((label + "[", label + ".", label + " (")) for other in labels)
666
+ ]
667
+ kind = platform.input_kind(cmap.event) or (platform.input_name if platform.name else "its input")
668
+ if has_input:
669
+ graph.nodes.append(
670
+ GraphNode(
671
+ "event",
672
+ "event",
673
+ kind[0].upper() + kind[1:],
674
+ "what it's called with",
675
+ _listed_lines(leaves) or ["(no fields read)"],
676
+ tip=f"What the entry point is called with: {platform.input_name}"
677
+ + (f": the code reads {', '.join(leaves)}" if leaves else ""),
678
+ code=_around([(f.file, f.line, "") for f in cmap.event], by_name),
679
+ )
680
+ )
681
+ if names:
682
+ graph.nodes.append(
683
+ GraphNode(
684
+ "env",
685
+ "env",
686
+ "Environment",
687
+ "variables it reads",
688
+ _listed_lines(names),
689
+ "warn" if unset else "",
690
+ warned_tip(
691
+ "Environment variables it reads (names only)",
692
+ [f"{name} isn't set where it runs" for name in names if name in unset],
693
+ ),
694
+ flags={name: "not set" for name in names if name in unset},
695
+ code=_around([(e.file, e.line, e.function) for e in cmap.env], by_name),
696
+ )
697
+ )
698
+ if uses_start:
699
+ services_made = list(dict.fromkeys(made))
700
+ graph.nodes.append(
701
+ GraphNode(
702
+ "start",
703
+ "start",
704
+ "At import",
705
+ "once, when it loads",
706
+ [
707
+ f"makes {_a(services_made[0])} {services_made[0]} client"
708
+ if len(services_made) == 1
709
+ else f"makes {_listed(services_made)} clients"
710
+ ]
711
+ if services_made
712
+ else [],
713
+ tip="What runs when Python imports the code, before the first call",
714
+ column=1,
715
+ code=_around(_start_places(cmap.startup), by_name),
716
+ )
717
+ )
718
+ entry_file = cmap.handler_file or ""
719
+ if not entry_is_function:
720
+ graph.nodes.append(
721
+ GraphNode(
722
+ "f-entry",
723
+ "handler",
724
+ entry.rsplit(".", 1)[-1],
725
+ f"{entry_file} line {cmap.entry_line}",
726
+ ["an object, not a function"],
727
+ tip=f"{entry} is called with its input",
728
+ column=1,
729
+ code=_around([(entry_file, cmap.entry_line, "")], by_name),
730
+ icon=platform.entry_icon(cmap),
731
+ )
732
+ )
733
+ for f in shown:
734
+ is_entry = f.name == entry
735
+ why = reasons.get(f.name, []) + (handler_reasons if is_entry else [])
736
+ more = list(
737
+ dict.fromkeys(
738
+ f"{_short_name(child)}()"
739
+ for child, _ in children.get(f.name, [])
740
+ if child in by_name and child not in ids
741
+ )
742
+ )
743
+ graph.nodes.append(
744
+ GraphNode(
745
+ ids[f.name],
746
+ "handler" if is_entry else "function",
747
+ f"{_short_name(f.name)}()",
748
+ f"{f.file} line {f.line}",
749
+ [f.doc] if f.doc else [],
750
+ "warn" if why else "",
751
+ warned_tip(
752
+ f"{f.name}({f.args}) in {f.file}, {_plural(f.lines, 'line')}"
753
+ + (f": {f.doc}" if f.doc else "")
754
+ + (
755
+ f"\nIt calls {_plural(len(more), 'function')} the graph leaves out: {', '.join(more)}"
756
+ if more
757
+ else ""
758
+ ),
759
+ why,
760
+ ),
761
+ column=1 + drawn_level.get(f.name, level.get(f.name, f.depth)),
762
+ code=[
763
+ CodeSpan(
764
+ f.file,
765
+ f.line,
766
+ f.line + f.lines - 1,
767
+ sorted(n for n in lit[f.name] if f.line <= n < f.line + f.lines),
768
+ )
769
+ ],
770
+ more=more,
771
+ when=_when(f, f.called_from),
772
+ icon=platform.entry_icon(cmap) if is_entry else "",
773
+ )
774
+ )
775
+ out_column = 1 + max([n.column for n in graph.nodes if n.kind in ("handler", "function", "start")] + [1])
776
+ for node in reach:
777
+ node.column = out_column
778
+ graph.nodes += reach
779
+ graph.edges = edges
780
+ return graph