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,1411 @@
1
+ """
2
+ _Graph: a CodeGraph drawn as an SVG of cards in columns, with arrows routed between them.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ import re
9
+ import unicodedata
10
+ from collections import defaultdict
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Callable
13
+
14
+ from ..kit.blocks import _Report, _draws
15
+ from ..kit.fmt import _plural
16
+ from ..kit.highlight import _python_lines
17
+ from ..kit.render import _prose
18
+ from ..kit.text import _esc
19
+
20
+ from ..analysis.graph import CodeGraph, GraphEdge, GraphNode
21
+ from .brands import AWS_LOGOS, SERVICE_LOGOS, fills, logo, small_logo
22
+ from .editor import editor_name, open_at
23
+ from .style import _ROOT
24
+
25
+
26
+ @dataclass
27
+ class _Graph:
28
+ """
29
+ how_it_works()'s graph: a CodeGraph drawn as boxes in columns joined by arrows. In HTML an SVG, where pointing
30
+ at a box leaves only what it's joined to in sight, and clicking one opens its code in a panel on the right (from
31
+ `sources`, {file: its text}; without them the boxes aren't links); in text each box with the arrows that leave it.
32
+ """
33
+
34
+ graph: CodeGraph
35
+ title: str = ""
36
+ empty: str = "(nothing to draw)"
37
+ sources: dict[str, str] = field(default_factory=dict)
38
+ editor: str = "" # a link to open a line in an editor, from editor_link(): each place in a panel links to it
39
+
40
+
41
+ _EDGE_WORDS = {
42
+ "call": "calls",
43
+ "event": "passes its input to",
44
+ "env": "configures",
45
+ "client": "makes the client for",
46
+ "http": "sends HTTP requests to",
47
+ "reads": "reads from",
48
+ "writes": "writes to",
49
+ "sends": "sends to",
50
+ "runs": "calls",
51
+ "signs": "signs links for",
52
+ }
53
+
54
+
55
+ # how_it_works()'s graph, drawn: cards with an icon badge per kind on a quiet dotted canvas, column captions, arrows that
56
+ # land on the operation (or leave from the variable) they're about, and a legend of pills. The _g names are its helpers.
57
+ _GRAPH_PAD_X = 28 # left and right of the drawing
58
+
59
+ _GRAPH_BAND = 52 # the captions' band at the top
60
+
61
+ _GRAPH_PAD_B = 32
62
+
63
+ _GRAPH_GAP, _GRAPH_GAP_EACH, _GRAPH_GAP_MAX = 84, 3, 150 # between columns: room for the arrows, wider for each arrow
64
+ # that crosses the gap, so a busy gap's curves stay gentle
65
+
66
+ _GRAPH_ROW_GAP = 34 # between boxes in a column
67
+
68
+ _GRAPH_HOP_GAP = 16 # between an arrow passing through a column and what's beside it
69
+
70
+ _GRAPH_MIN_W, _GRAPH_MAX_W, _GRAPH_MAX_IN, _GRAPH_MAX_HANDLER = 184, 284, 268, 304 # a column's width, from its boxes
71
+
72
+ _GRAPH_MAX_FLAGGED = 320 # ...or a column whose box has a line a warning is about ('ListObjectsV2 first page only')
73
+
74
+ _GRAPH_LABEL_LINE = 17 # a name too long for one line takes two
75
+
76
+ _GRAPH_HEAD, _GRAPH_HEAD_BARE = 56, 50 # a card's header (badge, name, what's under it), and without a line under it
77
+
78
+ _GRAPH_ROW = 19 # a listed line: a field, a variable, an operation
79
+
80
+ _GRAPH_PROSE = 17 # a line of prose: a docstring, what start-up does
81
+
82
+ _GRAPH_ROUTE = 28 # a web route's pill
83
+
84
+ _GRAPH_SWEEPS = 16 # passes that reorder each column by where its neighbours are, so fewer arrows cross; the order
85
+ # with the fewest crossings is kept
86
+
87
+ _GRAPH_COUNTED = 4000 # arrow segments, at most, whose crossings are counted to pick the best order (past that, the
88
+ # last pass's order, after _GRAPH_SWEEPS_BIG passes)
89
+
90
+ _GRAPH_SWEEPS_BIG = 6
91
+
92
+ _GRAPH_TALL, _GRAPH_LANES = 1800, 4 # a column taller than this is split into lanes side by side, at most this many
93
+
94
+ _GRAPH_ZOOMS = ( # the graph's zoom buttons: (key, word, said on hover); auto is checked to start
95
+ ("auto", "Auto", "As big as it is, shrunk a little to fit the page; past that it scrolls"),
96
+ ("fit", "Fit", "All of it in sight"),
97
+ ("100", "100%", "As big as it is"),
98
+ ("150", "150%", "Bigger"),
99
+ )
100
+
101
+ _GRAPH_LISTS = ("event", "env", "aws", "service", "http") # boxes whose lines are names: fields, variables,
102
+ # operations, requests
103
+
104
+ # Arial / Helvetica advance widths (per 1000 em), what the system sans stack draws on most machines; mono is 0.6 em
105
+ _G_SANS = dict(
106
+ zip(
107
+ " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
108
+ [
109
+ 278,
110
+ 278,
111
+ 355,
112
+ 556,
113
+ 556,
114
+ 889,
115
+ 667,
116
+ 191,
117
+ 333,
118
+ 333,
119
+ 389,
120
+ 584,
121
+ 278,
122
+ 333,
123
+ 278,
124
+ 278,
125
+ *[556] * 10,
126
+ 278,
127
+ 278,
128
+ 584,
129
+ 584,
130
+ 584,
131
+ 556,
132
+ 1015,
133
+ 667,
134
+ 667,
135
+ 722,
136
+ 722,
137
+ 667,
138
+ 611,
139
+ 778,
140
+ 722,
141
+ 278,
142
+ 500,
143
+ 667,
144
+ 556,
145
+ 833,
146
+ 722,
147
+ 778,
148
+ 667,
149
+ 778,
150
+ 722,
151
+ 667,
152
+ 611,
153
+ 722,
154
+ 667,
155
+ 944,
156
+ 667,
157
+ 667,
158
+ 611,
159
+ 278,
160
+ 278,
161
+ 278,
162
+ 469,
163
+ 556,
164
+ 333,
165
+ 556,
166
+ 556,
167
+ 500,
168
+ 556,
169
+ 556,
170
+ 278,
171
+ 556,
172
+ 556,
173
+ 222,
174
+ 222,
175
+ 500,
176
+ 222,
177
+ 833,
178
+ 556,
179
+ 556,
180
+ 556,
181
+ 556,
182
+ 333,
183
+ 500,
184
+ 278,
185
+ 556,
186
+ 500,
187
+ 722,
188
+ 500,
189
+ 500,
190
+ 500,
191
+ 334,
192
+ 260,
193
+ 334,
194
+ 584,
195
+ ],
196
+ )
197
+ )
198
+
199
+ _G_LAMBDA = "M3.6 2.6h2.1l6 10.8h1.7M8.4 7.4 4.2 13.4"
200
+
201
+ _G_ICONS = { # 16 x 16 line drawings, stroked in the badge's colour
202
+ "function": _G_LAMBDA,
203
+ "handler": "M5.2 3.4v9.2l7.4-4.6z",
204
+ "event": "M9.2 1.8 3.6 9h4.2l-1 5.2L12.4 7H8.2z",
205
+ "env": "M2.5 4.5h1.6M7.1 4.5h6.4M2.5 8h6.4M11.9 8h1.6M2.5 11.5h3M8.5 11.5h5M5.6 3v3M10.4 6.5v3M7 10v3",
206
+ "start": "M8 2.2v5.2M4.9 4.4a5 5 0 1 0 6.2 0",
207
+ "http": "M8 2a6 6 0 1 0 0 12A6 6 0 1 0 8 2zM2.2 8h11.6M8 2c-2.1 2.2-2.1 9.8 0 12M8 2c2.1 2.2 2.1 9.8 0 12",
208
+ "cloud": "M4.6 12.6h6.9a2.8 2.8 0 0 0 .5-5.55A4 4 0 0 0 4.3 6.4a3.1 3.1 0 0 0 .3 6.2z",
209
+ "database": "M3 4c0-1.1 2.2-2 5-2s5 .9 5 2-2.2 2-5 2-5-.9-5-2zM3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4"
210
+ "M3 8c0 1.1 2.2 2 5 2s5-.9 5-2",
211
+ "bucket": "M2.4 4.2c0 1 2.5 1.8 5.6 1.8s5.6-.8 5.6-1.8S11.1 2.4 8 2.4 2.4 3.2 2.4 4.2zM2.4 4.2l1.5 8.5"
212
+ "c.2 1 1.9 1.6 4.1 1.6s3.9-.6 4.1-1.6l1.5-8.5",
213
+ "queue": "M2 5.2h3.2v5.6H2zM6.4 5.2h3.2v5.6H6.4zM10.8 5.2H14v5.6h-3.2z",
214
+ "send": "M13.8 2.2 6.9 9.1M13.8 2.2 9.6 13.8 6.9 9.1 2.2 6.4z",
215
+ "key": "M10.4 2.6a3 3 0 1 1-2.2 5.1L3 12.9v1.5h1.9v-1.5h1.5v-1.5l1.8-1.8M10.9 5.1h.01",
216
+ "spark": "M8 1.8l1.5 3.9 3.9 1.5-3.9 1.5L8 12.6 6.5 8.7 2.6 7.2l3.9-1.5zM12.6 11.2l.6 1.5 1.5.6-1.5.6-.6 1.5"
217
+ "-.6-1.5-1.5-.6 1.5-.6z",
218
+ "flow": "M4 2.8a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 1 0 0-3.2zM12 10a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 1 0 0-3.2z"
219
+ "M4 6v2.4c0 1.3 1 2.3 2.3 2.3h4.1",
220
+ }
221
+
222
+ _G_SERVICE_ICONS = ( # an AWS service's box id, by what it starts with -> its icon
223
+ (
224
+ (
225
+ "aws-dynamodb",
226
+ "aws-rds",
227
+ "aws-redshift",
228
+ "aws-timestream",
229
+ "aws-docdb",
230
+ "aws-neptune",
231
+ "aws-elasticache",
232
+ "aws-keyspaces",
233
+ "aws-athena",
234
+ ),
235
+ "database",
236
+ ),
237
+ (("aws-s3",), "bucket"),
238
+ (("aws-sqs", "aws-kinesis", "aws-firehose", "aws-mq"), "queue"),
239
+ (("aws-sns", "aws-events", "aws-ses", "aws-pinpoint", "aws-iot"), "send"),
240
+ (("aws-secretsmanager", "aws-ssm", "aws-kms", "aws-sts", "aws-cognito", "aws-iam", "aws-acm"), "key"),
241
+ (
242
+ (
243
+ "aws-bedrock",
244
+ "aws-sagemaker",
245
+ "aws-comprehend",
246
+ "aws-textract",
247
+ "aws-rekognition",
248
+ "aws-translate",
249
+ "aws-polly",
250
+ "aws-transcribe",
251
+ ),
252
+ "spark",
253
+ ),
254
+ (("aws-stepfunctions", "aws-states", "aws-glue", "aws-batch", "aws-ecs", "aws-codebuild"), "flow"),
255
+ (("aws-lambda",), "function"),
256
+ )
257
+
258
+ _G_LOOP = "M12.4 9.3A4.6 4.6 0 1 1 11.3 4.7M12.1 2.3v2.8H9.3"
259
+
260
+ _G_ACCESS = ("reads", "writes", "sends", "runs", "signs")
261
+
262
+ _G_LEGEND = (
263
+ ("call", "calls"),
264
+ ("reads", "reads"),
265
+ ("writes", "writes"),
266
+ ("sends", "sends"),
267
+ ("runs", "runs"),
268
+ ("http", "HTTP request"),
269
+ ("signs", "signs links"),
270
+ ("env", "configures"),
271
+ ("client", "makes a client"),
272
+ )
273
+
274
+
275
+ def _g_sans(text: str, size: float, bold: bool = False) -> float:
276
+ """
277
+ About how wide `text` is in the sans stack, a little generous (Segoe UI, Roboto and Inter run wider).
278
+ """
279
+ return (
280
+ sum(_G_SANS.get(ch) or (1000 if ch == "…" or _g_wide(ch) else 600) for ch in text)
281
+ / 1000
282
+ * size
283
+ * (1.1 if bold else 1.05)
284
+ )
285
+
286
+
287
+ def _g_mono(text: str, size: float) -> float:
288
+ return (len(text) + sum(map(_g_wide, text))) * 0.61 * size
289
+
290
+
291
+ def _g_wide(ch: str) -> bool:
292
+ """
293
+ A character drawn twice as wide: Chinese, Japanese, Korean.
294
+ """
295
+ return unicodedata.east_asian_width(ch) in "WF"
296
+
297
+
298
+ def _g_fit(text: str, room: float, measure: Callable[[str], float]) -> str:
299
+ """
300
+ text, cut with … to fit `room` pixels.
301
+ """
302
+ if measure(text) <= room:
303
+ return text
304
+ while text and measure(text + "…") > room:
305
+ text = text[:-1]
306
+ return text.rstrip() + "…"
307
+
308
+
309
+ def _g_wrap(text: str, room: float, measure: Callable[[str], float], most: int = 2) -> list[str]:
310
+ """
311
+ Words in lines that fit `room`, at most `most`, the last cut with … when there's more.
312
+ """
313
+ words, lines, line = text.split(), [], ""
314
+ for i, word in enumerate(words):
315
+ trial = f"{line} {word}".strip()
316
+ if measure(trial) <= room or not line:
317
+ line = trial
318
+ continue
319
+ lines.append(line)
320
+ line = word
321
+ if len(lines) == most:
322
+ line = ""
323
+ lines[-1] = _g_fit(lines[-1] + " " + " ".join(words[i:]), room, measure)
324
+ break
325
+ if line:
326
+ lines.append(line)
327
+ return [_g_fit(x, room, measure) for x in lines[:most]]
328
+
329
+
330
+ def _g_logo_name(n: GraphNode) -> str | None:
331
+ """
332
+ The name of the real logo a box shows, when it has one: the one its plugin names as its icon, else its AWS
333
+ service's ('aws-s3'), else its service's by name (Redis, PostgreSQL, OpenAI). None for a box drawn with a line
334
+ icon.
335
+ """
336
+ if n.icon and logo(n.icon) is not None:
337
+ return n.icon
338
+ if n.kind == "aws":
339
+ name = AWS_LOGOS.get(n.id.removeprefix("aws-"))
340
+ elif n.kind == "service":
341
+ name = SERVICE_LOGOS.get(n.label.lower())
342
+ else:
343
+ return None
344
+ return name if name and logo(name) is not None else None
345
+
346
+
347
+ def _g_logo(n: GraphNode) -> tuple[int, int, str] | None:
348
+ """
349
+ The full art of the logo a box shows, or None.
350
+ """
351
+ name = _g_logo_name(n)
352
+ return logo(name) if name else None
353
+
354
+
355
+ def _g_logo_svg(found: tuple[int, int, str], x: float, y: float, size: float, css: str) -> str:
356
+ width, height, body = found
357
+ return (
358
+ f'<svg class="logo{" " + css if css else ""}" x="{x:g}" y="{y:g}" width="{size:g}" height="{size:g}" '
359
+ f'viewBox="0 0 {width} {height}" fill="currentColor" aria-hidden="true">{body}</svg>'
360
+ )
361
+
362
+
363
+ _G_BADGE = 32 # a box's badge, square: a logo needs the pixels, or its fine lines blur together on a 1x screen
364
+
365
+
366
+ def _g_badge(n: GraphNode, badge_y: float) -> str:
367
+ """
368
+ A box's badge: an AWS service's icon filling it, another service's logo on a white tile, else a line icon in the
369
+ box's colour.
370
+ """
371
+ size = _G_BADGE
372
+ found = _g_logo(n)
373
+ if found is None:
374
+ icon = 18 # the 16-unit drawings, a little larger
375
+ return (
376
+ f'<rect class="bdg" x="12" y="{badge_y}" width="{size}" height="{size}" rx="8"/>'
377
+ f'<path class="ico" transform="translate({12 + (size - icon) / 2:g},{badge_y + (size - icon) / 2:g}) '
378
+ f'scale({icon / 16:g})" d="{_G_ICONS[_g_icon(n)]}"/>'
379
+ )
380
+ small = small_logo(_g_logo_name(n) or "")
381
+ if fills(found): # AWS's: both sizes fill the badge, the small mark on a 1x screen and the full art on a 2x one
382
+ clip = f"cn-badge-{n.id}"
383
+ art = (
384
+ _g_logo_svg(small, 12, badge_y, size, "lgs") + _g_logo_svg(found, 12, badge_y, size, "lgf")
385
+ if small is not None
386
+ else _g_logo_svg(found, 12, badge_y, size, "")
387
+ )
388
+ return (
389
+ f'<clipPath id="{clip}"><rect x="12" y="{badge_y}" width="{size}" height="{size}" rx="8"/></clipPath>'
390
+ f'<g clip-path="url(#{clip})">{art}</g>'
391
+ )
392
+ pad = 4
393
+ return f'<rect class="bdg lg" x="12" y="{badge_y}" width="{size}" height="{size}" rx="8"/>' + _g_logo_svg(
394
+ found, 12 + pad, badge_y + pad, size - 2 * pad, ""
395
+ )
396
+
397
+
398
+ def _g_icon(n: GraphNode) -> str:
399
+ if n.icon in _G_ICONS:
400
+ return n.icon
401
+ if n.kind == "aws":
402
+ return next((icon for starts, icon in _G_SERVICE_ICONS if n.id.startswith(starts)), "cloud")
403
+ return n.kind if n.kind in _G_ICONS else "cloud"
404
+
405
+
406
+ class _GCard:
407
+ """
408
+ A box of the graph being drawn: its node, its size, and what it shows in what order.
409
+ """
410
+
411
+ def __init__(self, node: GraphNode) -> None:
412
+ self.node = node
413
+ self.width = self.height = self.body = 0.0
414
+ self.head = float(_GRAPH_HEAD)
415
+ self.route = "" # the web route that leads to it ('GET /orders')
416
+ self.rows: list[str] = [] # its listed lines, in the order drawn
417
+ self.prose: list[str] = []
418
+ self.label: list[str] = [] # its name, in one line or two
419
+ self.arrows = 0 # the most arrows on one of its sides: a box with many is tall enough to space them
420
+
421
+ def row_y(self, text: str) -> float | None:
422
+ if text in self.rows:
423
+ return self.body + 6 + _GRAPH_ROW * self.rows.index(text) + _GRAPH_ROW / 2
424
+ return None
425
+
426
+
427
+ def _g_measure_label(n: GraphNode, text: str) -> float:
428
+ if n.kind in ("handler", "function"):
429
+ return _g_mono(text, 14 if n.kind == "handler" else 13.5) * 1.03
430
+ return _g_sans(text, 14, bold=True)
431
+
432
+
433
+ def _g_need(card: _GCard) -> float:
434
+ """
435
+ How wide its card would like to be: its name, what's under it and its listed lines whole.
436
+ """
437
+ n = card.node
438
+ need = 52 + _g_measure_label(n, n.label) + 16 + (26 if n.tone == "warn" else 0)
439
+ if n.sub:
440
+ whole = 52 + _g_sans(n.sub, 12.5) + 14
441
+ if n.kind in ("handler", "function") and "/" in n.sub: # past 190px its folder is shortened instead
442
+ whole = min(whole, max(190.0, 52 + _g_sans(n.sub.rsplit("/", 1)[-1], 12.5) + 14))
443
+ need = max(need, whole)
444
+ if card.route:
445
+ need = max(need, 16 + _g_mono(_g_route(card)[0] + _g_route(card)[2], 12.5) + 36)
446
+ if n.kind in _GRAPH_LISTS:
447
+ need = max(
448
+ [need]
449
+ + [
450
+ 28 + _g_mono(line, 12.5) + 16 + (_g_sans(n.flags[line], 12, True) + 12 if line in n.flags else 0)
451
+ for line in n.lines
452
+ if not line.startswith(("+", "("))
453
+ ]
454
+ )
455
+ return need
456
+
457
+
458
+ def _g_route(card: _GCard) -> tuple[str, str, str]:
459
+ """
460
+ A route's pill: the first route the box answers, its method ('GET', or 'GET, POST') and ' +1' for the rest.
461
+ """
462
+ first, *rest = card.route.split("; ")
463
+ return first, first.rpartition(" ")[0], f" +{len(rest)}" if rest else ""
464
+
465
+
466
+ def _g_cards(graph: CodeGraph) -> dict[str, _GCard]:
467
+ cards = {n.id: _GCard(n) for n in graph.nodes}
468
+ sides: dict[tuple[str, int], int] = defaultdict(int)
469
+ for e in graph.edges:
470
+ sides[(e.source, 0)] += 1
471
+ sides[(e.target, 1)] += 1
472
+ for (node_id, _), count in sides.items():
473
+ if node_id in cards:
474
+ cards[node_id].arrows = max(cards[node_id].arrows, count)
475
+ for e in graph.edges: # a web app's route goes on the function it leads to
476
+ if e.kind == "call" and e.label and not e.label.startswith("line ") and e.target in cards:
477
+ cards[e.target].route = e.label
478
+ for card in cards.values():
479
+ if card.node.kind in _GRAPH_LISTS:
480
+ card.rows = list(card.node.lines)
481
+ return cards
482
+
483
+
484
+ def _g_code_lines(n: GraphNode, room: float) -> list[str]:
485
+ """
486
+ A function's name in one line, or in two after the dot before its own name when it's too long for one.
487
+ """
488
+ name = n.label
489
+ dot = name.rfind(".", 0, name.find("(") if "(" in name else len(name))
490
+ if dot < 1 or _g_measure_label(n, name) <= room:
491
+ return [name]
492
+ return [name[: dot + 1], name[dot + 1 :]]
493
+
494
+
495
+ def _g_size(cards: dict[str, _GCard], widths: dict[int, float], column_of: dict[str, int]) -> None:
496
+ for card in cards.values():
497
+ n = card.node
498
+ card.width = widths[column_of[n.id]]
499
+ room = card.width - 52 - 14 - (24 if n.tone == "warn" else 0)
500
+ if n.kind in ("handler", "function"): # code: broken only after its last dot ('Store.' 'save()'), else clipped
501
+ card.label = [_g_fit(part, room, lambda t: _g_measure_label(n, t)) for part in _g_code_lines(n, room)]
502
+ else:
503
+ card.label = _g_wrap(n.label, room, lambda t: _g_measure_label(n, t))
504
+ card.head = (_GRAPH_HEAD if n.sub else _GRAPH_HEAD_BARE) + _GRAPH_LABEL_LINE * (len(card.label) - 1)
505
+ top = card.head + (_GRAPH_ROUTE if card.route else 0) + (_GRAPH_ROUTE if n.when else 0)
506
+ card.body = top
507
+ if n.kind in _GRAPH_LISTS and card.rows:
508
+ card.height = top + 6 + _GRAPH_ROW * len(card.rows) + 8
509
+ else:
510
+ card.prose = [
511
+ part for line in n.lines for part in _g_wrap(line, card.width - 30, lambda t: _g_sans(t, 12.5))
512
+ ][:3]
513
+ card.height = top + (_GRAPH_PROSE * len(card.prose) + 10 if card.prose else 4)
514
+ if n.more:
515
+ card.height += _GRAPH_PROSE + (0 if card.prose else 8)
516
+ card.height = max(card.height, 24 + 12 * card.arrows)
517
+
518
+
519
+ def _g_lanes(graph: CodeGraph, cards: dict[str, _GCard]) -> dict[str, int]:
520
+ """
521
+ Each box's column in the drawing, left to right from 0: its graph column, except that a column of functions (or
522
+ of what the code reaches) taller than _GRAPH_TALL is dealt into lanes side by side, at most _GRAPH_LANES, so a wide
523
+ codebase is drawn wide rather than as one long strip. The arrows into a far lane pass between the near lane's boxes.
524
+ """
525
+ _g_size(cards, {0: float(_GRAPH_MAX_W)}, {n.id: 0 for n in graph.nodes}) # about how tall each box will be
526
+ by_column: dict[int, list[GraphNode]] = defaultdict(list)
527
+ for n in graph.nodes:
528
+ by_column[n.column].append(n)
529
+ column_of, k = {}, 0
530
+ for column in sorted(by_column):
531
+ nodes = by_column[column]
532
+ tall = sum(cards[n.id].height for n in nodes) + _GRAPH_ROW_GAP * (len(nodes) - 1)
533
+ lanes = 1
534
+ if all(n.kind in ("function", "aws", "service", "http") for n in nodes):
535
+ lanes = max(1, min(_GRAPH_LANES, math.ceil(tall / _GRAPH_TALL), len(nodes) // 2))
536
+ for j, n in enumerate(nodes):
537
+ column_of[n.id] = k + j % lanes
538
+ k += lanes
539
+ return column_of
540
+
541
+
542
+ def _graph_layout(
543
+ graph: CodeGraph, cards: dict[str, _GCard]
544
+ ) -> tuple[
545
+ dict[str, tuple[float, float, float, float]],
546
+ dict[int, list[float]],
547
+ float,
548
+ float,
549
+ dict[int, float],
550
+ dict[int, float],
551
+ dict[str, int],
552
+ ]:
553
+ """
554
+ Where each box goes: (x, y, width, height) by its id; for each arrow that skips columns, the heights where it
555
+ crosses the columns between (a waypoint in each, so it runs between their boxes, not behind them); the drawing's
556
+ width and height; and each column's x and width, and each box's column. A column is as wide as its boxes want,
557
+ within limits, and a gap as wide as the arrows through it need. Arrows that skip columns from one box (the
558
+ environment's, start-up's), or into one, share their waypoints, so they run as one line and part near the end,
559
+ instead of each finding its own way. A column too tall to take in is split into lanes side by side. Each column is put in the order of its neighbours' heights, a few passes
560
+ left to right and back (the barycenter method), keeping the order where the fewest arrows cross; then each box is
561
+ moved as near its neighbours as the boxes around it let it be.
562
+ """
563
+ column_of = _g_lanes(graph, cards)
564
+ order = sorted(set(column_of.values()))
565
+ widths = {k: 0.0 for k in range(len(order))}
566
+ for card in cards.values():
567
+ k = column_of[card.node.id]
568
+ widths[k] = max(widths[k], _g_need(card))
569
+ most = {k: float(_GRAPH_MAX_W) for k in widths}
570
+ for n in graph.nodes:
571
+ if n.kind in ("event", "env", "handler"):
572
+ k = column_of[n.id]
573
+ most[k] = max(most[k], _GRAPH_MAX_HANDLER if n.kind == "handler" else _GRAPH_MAX_IN)
574
+ if n.kind in _GRAPH_LISTS and n.flags: # the line a warning is about, whole beside its flag
575
+ k = column_of[n.id]
576
+ most[k] = max(most[k], min(_g_need(cards[n.id]), _GRAPH_MAX_FLAGGED))
577
+ widths = {k: min(max(w, _GRAPH_MIN_W), most[k]) for k, w in widths.items()}
578
+ _g_size(cards, widths, column_of)
579
+ height = {n.id: cards[n.id].height for n in graph.nodes}
580
+ columns: dict[int, list[str]] = defaultdict(list)
581
+ for node in graph.nodes:
582
+ columns[column_of[node.id]].append(node.id)
583
+ skips = [(i, e) for i, e in enumerate(graph.edges) if abs(column_of[e.target] - column_of[e.source]) > 1]
584
+ fan_out: dict[str, int] = defaultdict(int)
585
+ fan_in: dict[str, int] = defaultdict(int)
586
+ for _, e in skips:
587
+ fan_out[e.source] += 1
588
+ fan_in[e.target] += 1
589
+ near: dict[str, list[str]] = defaultdict(list)
590
+ hops: dict[int, list[str]] = {}
591
+ for i, e in enumerate(graph.edges):
592
+ a, b = column_of[e.source], column_of[e.target]
593
+ chain = [e.source]
594
+ if abs(b - a) > 1: # a waypoint in each column it crosses, either way, shared with the arrows it bundles with
595
+ step = 1 if b > a else -1
596
+ if fan_out[e.source] > 1 and fan_out[e.source] >= fan_in[e.target]:
597
+ key = f"~s{step}.{e.source}"
598
+ elif fan_in[e.target] > 1:
599
+ key = f"~t{step}.{e.target}"
600
+ else:
601
+ key = f"~{i}"
602
+ hops[i] = [f"{key}.{k}" for k in range(a + step, b, step)]
603
+ for k, hop in zip(range(a + step, b, step), hops[i]):
604
+ if hop not in column_of:
605
+ columns[k].append(hop)
606
+ column_of[hop] = k
607
+ height[hop] = 4.0
608
+ chain += hops[i]
609
+ chain.append(e.target)
610
+ for x, y in zip(chain, chain[1:]):
611
+ if x != y:
612
+ near[x].append(y)
613
+ near[y].append(x)
614
+ segments = {
615
+ tuple(sorted((x, y), key=lambda item: column_of[item]))
616
+ for x, ys in near.items()
617
+ for y in ys
618
+ if abs(column_of[x] - column_of[y]) == 1
619
+ }
620
+ by_gap: dict[int, list[tuple[str, str]]] = defaultdict(list)
621
+ for x, y in segments:
622
+ by_gap[column_of[x]].append((x, y))
623
+ center: dict[str, float] = {}
624
+ for k in range(len(order)):
625
+ top = 0.0
626
+ for item in columns[k]:
627
+ center[item] = top + height[item] / 2
628
+ top += height[item] + _GRAPH_ROW_GAP
629
+
630
+ def gap(x: str, y: str) -> float:
631
+ return _GRAPH_ROW_GAP if not (x.startswith("~") or y.startswith("~")) else _GRAPH_HOP_GAP
632
+
633
+ def settle(items: list[str], wanted: list[float]) -> None:
634
+ """
635
+ Each box at the height it wants, pushed down so none overlaps, then the column moved back up by how far
636
+ it was pushed on average.
637
+ """
638
+ at = list(wanted)
639
+ for j in range(1, len(items)):
640
+ at[j] = max(
641
+ at[j], at[j - 1] + height[items[j - 1]] / 2 + gap(items[j - 1], items[j]) + height[items[j]] / 2
642
+ )
643
+ shift = sum(w - a for w, a in zip(wanted, at)) / len(items)
644
+ for item, a in zip(items, at):
645
+ center[item] = a + shift
646
+
647
+ def crossings() -> int:
648
+ """
649
+ How many pairs of arrows cross in the gaps, with the columns in their order now.
650
+ """
651
+ rank = {item: j for items in columns.values() for j, item in enumerate(items)}
652
+ count = 0
653
+ for pairs in by_gap.values():
654
+ ends = sorted((rank[x], rank[y]) for x, y in pairs)
655
+ for j, (a1, b1) in enumerate(ends):
656
+ count += sum(1 for a2, b2 in ends[j + 1 :] if a2 > a1 and b2 < b1)
657
+ return count
658
+
659
+ counted = len(segments) <= _GRAPH_COUNTED
660
+ best = (crossings() if counted else 0, {k: list(v) for k, v in columns.items()}, dict(center))
661
+ for sweep in range(_GRAPH_SWEEPS if counted else _GRAPH_SWEEPS_BIG):
662
+ for k in range(len(order)) if sweep % 2 == 0 else range(len(order) - 1, -1, -1):
663
+ items = columns[k]
664
+
665
+ def pull(item: str) -> float:
666
+ ys = [center[m] for m in near[item] if column_of[m] != k]
667
+ return sum(ys) / len(ys) if ys else center[item]
668
+
669
+ items.sort(key=pull)
670
+ settle(items, [pull(item) for item in items])
671
+ if counted and (crossed := crossings()) < best[0]:
672
+ best = (crossed, {k: list(v) for k, v in columns.items()}, dict(center))
673
+ if counted:
674
+ columns.clear()
675
+ columns.update(best[1])
676
+ center = best[2]
677
+ top = min(center[item] - height[item] / 2 for item in center)
678
+ last = len(order) - 1 # a call within the last column curls round into the margin; the others, into a gap
679
+ through: dict[int, int] = defaultdict(
680
+ int
681
+ ) # arrows that cross each gap (the gap after column k), counted once per line drawn
682
+ for x, y in segments:
683
+ through[column_of[x]] += 1
684
+ for e in graph.edges:
685
+ if column_of[e.source] == column_of[e.target] and column_of[e.source] < last:
686
+ through[column_of[e.source]] += 1
687
+ xs, x = {}, float(_GRAPH_PAD_X)
688
+ for k in range(len(order)):
689
+ xs[k] = x
690
+ x += widths[k] + (min(_GRAPH_GAP + _GRAPH_GAP_EACH * through[k], _GRAPH_GAP_MAX) if k < last else 0)
691
+ boxes = {
692
+ n.id: (
693
+ xs[column_of[n.id]],
694
+ center[n.id] - height[n.id] / 2 - top + _GRAPH_BAND,
695
+ widths[column_of[n.id]],
696
+ height[n.id],
697
+ )
698
+ for n in graph.nodes
699
+ }
700
+ routes = {i: [center[hop] - top + _GRAPH_BAND for hop in chain] for i, chain in hops.items()}
701
+ loops = 64 if any(column_of[e.source] == column_of[e.target] == last for e in graph.edges) else 0
702
+ width = x + _GRAPH_PAD_X + loops
703
+ tall = (
704
+ max(max(y + h for _, y, _, h in boxes.values()), max((y for ys in routes.values() for y in ys), default=0))
705
+ + _GRAPH_PAD_B
706
+ )
707
+ return boxes, routes, width, tall, xs, widths, column_of
708
+
709
+
710
+ def _g_names(e: GraphEdge) -> list[str]:
711
+ return [part for part in e.label.split(", ") if part]
712
+
713
+
714
+ def _g_rows(card: _GCard, e: GraphEdge) -> list[str]:
715
+ """
716
+ The rows of its box an arrow leaves from or lands on: the variables it carries, the operations it calls (the
717
+ '+3 more' row for one that isn't listed); none for the event as a whole or a client.
718
+ """
719
+ if card.node.kind not in _GRAPH_LISTS or e.kind in ("event", "client"):
720
+ return []
721
+ if e.label in card.rows:
722
+ return [e.label]
723
+ hit = [r for r in card.rows if f", {r}, " in f", {e.label}, "]
724
+ if not hit and any(r.startswith("+") for r in card.rows):
725
+ hit = [r for r in card.rows if r.startswith("+")]
726
+ return [r for r in hit if card.row_y(r) is not None]
727
+
728
+
729
+ def _g_anchors(
730
+ graph: CodeGraph, cards: dict[str, _GCard], boxes: dict[str, tuple[float, float, float, float]]
731
+ ) -> dict[int, tuple[list[float], list[float]]]:
732
+ """
733
+ Where each arrow leaves and arrives, as heights on the page: from the rows of the variables it carries, to the
734
+ rows of the operations or requests it makes; otherwise spread down the box's side in the order of what's at the
735
+ other end, outgoing arrows and incoming ones on separate parts of a side they share. Rows are first put in the
736
+ order of what they're joined to, so arrows cross less.
737
+ """
738
+
739
+ def middle(node_id: str) -> float:
740
+ return boxes[node_id][1] + boxes[node_id][3] / 2
741
+
742
+ rows_for = _g_rows
743
+ joined: dict[str, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list))
744
+ for e in graph.edges:
745
+ for here, there in ((e.source, e.target), (e.target, e.source)):
746
+ for row in rows_for(cards[here], e):
747
+ joined[here][row].append(middle(there))
748
+ for node_id, by_row in joined.items():
749
+ rank = {row: sum(ys) / len(ys) for row, ys in by_row.items()}
750
+ cards[node_id].rows.sort(key=lambda r: (r.startswith("+"), rank.get(r, 1e9)))
751
+
752
+ ends: dict[int, list[list[float]]] = {}
753
+ loose: dict[tuple[str, str], list[tuple[int, int]]] = defaultdict(list) # (node, side of the box) -> (edge, end)
754
+ for i, e in enumerate(graph.edges):
755
+ pair: list[list[float]] = []
756
+ for end, node_id in ((0, e.source), (1, e.target)):
757
+ card = cards[node_id]
758
+ ys = [boxes[node_id][1] + y for r in rows_for(card, e) if (y := card.row_y(r)) is not None]
759
+ if not ys: # which side of the box the arrow touches there: right, unless it arrives from the left
760
+ forward = boxes[e.target][0] > boxes[e.source][0]
761
+ side = (
762
+ "left"
763
+ if (end == 1 and forward) or (end == 0 and boxes[e.target][0] < boxes[e.source][0])
764
+ else "right"
765
+ )
766
+ loose[(node_id, side)].append((i, end))
767
+ pair.append(ys)
768
+ ends[i] = pair
769
+
770
+ def other(i: int, end: int) -> float:
771
+ far = ends[i][1 - end]
772
+ e = graph.edges[i]
773
+ return sum(far) / len(far) if far else middle(e.target if end == 0 else e.source)
774
+
775
+ for (node_id, _), items in loose.items():
776
+ card, (_, y, _, h) = cards[node_id], boxes[node_id]
777
+ items.sort(key=lambda item: (item[1], other(*item))) # arrivals above departures on a shared side
778
+ if card.node.kind in _GRAPH_LISTS: # at the name: the event as a whole, a client made for the service
779
+ hub, room = y + 27, 22.0
780
+ else:
781
+ hub, room = y + h / 2, h - 22
782
+ step = min(12.0, room / max(len(items), 1))
783
+ for k, (i, end) in enumerate(items):
784
+ ends[i][end] = [hub + (k - (len(items) - 1) / 2) * step]
785
+ shared: dict[tuple[str, int, float], list[int]] = defaultdict(list) # arrows that share a row's port
786
+ for i, e in enumerate(graph.edges):
787
+ for end, node_id in ((0, e.source), (1, e.target)):
788
+ for y in ends[i][end]:
789
+ shared[(node_id, end, round(y, 1))].append(i)
790
+ for (node_id, end, y), edges in shared.items():
791
+ edges = [i for i in edges if graph.edges[i].kind != "env"] # the environment's leave as one line, then part
792
+ if len(edges) > 1 and end == 0: # leaving one row: side by side, within the row (arriving: one head)
793
+ edges.sort(key=lambda i: other(i, end))
794
+ step = min(5.0, (_GRAPH_ROW - 6) / (len(edges) - 1))
795
+ for k, i in enumerate(edges):
796
+ ends[i][end] = [v + (k - (len(edges) - 1) / 2) * step if round(v, 1) == y else v for v in ends[i][end]]
797
+ return {i: (pair[0], pair[1]) for i, pair in ends.items()}
798
+
799
+
800
+ def _g_cubic(
801
+ p0: tuple[float, float], p1: tuple[float, float], p2: tuple[float, float], p3: tuple[float, float], t: float
802
+ ) -> tuple[float, float]:
803
+ u = 1 - t
804
+ x, y = (u**3 * a + 3 * u * u * t * b + 3 * u * t * t * c + t**3 * d for a, b, c, d in zip(p0, p1, p2, p3))
805
+ return x, y
806
+
807
+
808
+ def _g_spots(curve: tuple[tuple[float, float], ...]) -> list[tuple[float, float]]:
809
+ return [_g_cubic(*curve, t) for t in (0.5, 0.3, 0.7, 0.18, 0.82)] # type: ignore[arg-type]
810
+
811
+
812
+ def _g_strand(points: list[tuple[float, float]]) -> tuple[str, list[tuple[float, float]]]:
813
+ """
814
+ One line through `points`: (start, then a column's left and right for each column it skips, then the end).
815
+ Curves in the gaps, straight across each column it skips; returns the path and where its marks can go, the best
816
+ first: along its middle straight run, in the clear between a column's boxes, or else along its curve.
817
+ """
818
+ (x1, y1) = points[0]
819
+ parts: list[str] = [f"M{x1:.1f},{y1:.1f}"]
820
+ runs: list[tuple[float, float, float]] = []
821
+ curve: tuple[tuple[float, float], ...] = ()
822
+ for j in range(1, len(points)):
823
+ (ax, ay), (bx, by) = points[j - 1], points[j]
824
+ if j % 2 == 0 and j < len(points) - 1:
825
+ parts.append(f"L{bx:.1f},{by:.1f}")
826
+ runs.append((ax, bx, by))
827
+ continue
828
+ reach = max(18.0, abs(bx - ax) * 0.5) * (1 if bx >= ax else -1)
829
+ parts.append(f"C{ax + reach:.1f},{ay:.1f} {bx - reach:.1f},{by:.1f} {bx:.1f},{by:.1f}")
830
+ curve = curve or ((ax, ay), (ax + reach, ay), (bx - reach, by), (bx, by))
831
+ if not runs:
832
+ return " ".join(parts), _g_spots(curve)
833
+ ax, bx, y = runs[(len(runs) - 1) // 2]
834
+ middle, d = (ax + bx) / 2, (1 if bx >= ax else -1)
835
+ return " ".join(parts), [(middle + k * 19 * d, y) for k in (0, 1, -1, 2, -2) if abs(k * 19) < abs(bx - ax) / 2 - 9]
836
+
837
+
838
+ def _g_head(x: float, y: float, d: int) -> str:
839
+ """
840
+ A slim arrowhead with a notched back, its tip at (x, y), pointing right (d=1) or left (d=-1).
841
+ """
842
+ return f"M{x:.1f},{y:.1f} L{x - 9 * d:.1f},{y - 4.4:.1f} Q{x - 6.2 * d:.1f},{y:.1f} {x - 9 * d:.1f},{y + 4.4:.1f} Z"
843
+
844
+
845
+ def _g_edge_tip(e: GraphEdge, graph: CodeGraph) -> str:
846
+ """
847
+ What an arrow says on hover, in a sentence.
848
+ """
849
+ source, target = graph.node(e.source), graph.node(e.target)
850
+ src = source.label if source else e.source
851
+ dst = target.label if target else e.target
852
+ if e.kind == "event":
853
+ said = f"{dst} is called with its input"
854
+ elif e.kind == "env":
855
+ said = f"{dst} reads {e.label} from its environment"
856
+ elif e.kind == "call":
857
+ said = f"{src} calls {dst}" + (f" ({e.label})" if e.label else "") + (f", only when {e.when}" if e.when else "")
858
+ elif e.kind == "client":
859
+ said = f"{src} makes the {dst} client"
860
+ elif e.kind == "http":
861
+ said = f"{src} sends {e.label}"
862
+ else:
863
+ said = f"{src} {_EDGE_WORDS.get(e.kind, e.kind)} {dst}: {e.label}"
864
+ said += "; in a loop, once per item" if e.loop else ""
865
+ said += "; known only by the methods' names (their client couldn't be followed)" if e.guessed else ""
866
+ said += "; in functions further down that the graph leaves out" if e.indirect else ""
867
+ return said + ("; a warning is about it" if e.tone == "warn" else "")
868
+
869
+
870
+ def _edge_svg(
871
+ e: GraphEdge,
872
+ graph: CodeGraph,
873
+ boxes: dict[str, tuple[float, float, float, float]],
874
+ xs: dict[int, float],
875
+ widths: dict[int, float],
876
+ column_of: dict[str, int],
877
+ ys_out: list[float],
878
+ ys_in: list[float],
879
+ hops: list[float] | None,
880
+ mark_loop: bool = True,
881
+ hot: set[tuple[int, int]] | None = None,
882
+ taken: list[tuple[float, float]] | None = None,
883
+ lane: int = 0,
884
+ ) -> tuple[str, str]:
885
+ """
886
+ An arrow: its lines (one from each row it leaves to each row it reaches), its head, its loop mark; and, drawn
887
+ over the boxes, the dots where it leaves them. A warned arrow's glow and ! go on the lines in `hot` (by the index
888
+ of the row each leaves and reaches), or on all of them. Its marks go on its line, clear of the marks in `taken`
889
+ (other arrows'), which they're added to; `lane` puts a call within a column outside the ones drawn before it.
890
+ """
891
+ sx, _, sw, _ = boxes[e.source]
892
+ tx, _, tw, _ = boxes[e.target]
893
+ lines: list[str] = []
894
+ spots: list[tuple[float, float]] = [] # where its marks can go, along its first line (or the first one warned)
895
+ start, end = column_of[e.source], column_of[e.target]
896
+ if end > start: # left to right: from the source's right side to the target's left side
897
+ x1, tip, d = sx + sw, tx - 0.6, 1
898
+ between = [(xs[start + 1 + k], xs[start + 1 + k] + widths[start + 1 + k], y) for k, y in enumerate(hops or [])]
899
+ elif end < start: # back to an earlier column: from the source's left side to the target's right side
900
+ x1, tip, d = sx, tx + tw + 0.6, -1
901
+ between = [(xs[start - 1 - k] + widths[start - 1 - k], xs[start - 1 - k], y) for k, y in enumerate(hops or [])]
902
+ else: # the same column: out of the right side, round, and back into the target's right side
903
+ x1, tip, d = sx + sw, tx + tw + 0.6, -1
904
+ between = []
905
+ warned: list[str] = []
906
+ for ia, a in enumerate(ys_out):
907
+ for ib, b in enumerate(ys_in):
908
+ if end == start:
909
+ bulge = 34 + 16 * lane + 8 * len(lines)
910
+ curve = ((x1, a), (x1 + bulge, a), (tip + 6.6 + bulge, b), (tip + 6.6, b))
911
+ path = "M{:.1f},{:.1f} C{:.1f},{:.1f} {:.1f},{:.1f} {:.1f},{:.1f}".format(
912
+ *(v for p in curve for v in p)
913
+ )
914
+ at = _g_spots(curve)
915
+ else:
916
+ points = (
917
+ [(x1, a)] + [(p, y) for left, right, y in between for p in (left, right)] + [(tip - 6.6 * d, b)]
918
+ )
919
+ path, at = _g_strand(points)
920
+ lines.append(path)
921
+ spots = spots or at
922
+ if e.tone == "warn" and (hot is None or (ia, ib) in hot):
923
+ if not warned: # the marks go on the first line the warning is about
924
+ spots = at
925
+ warned.append(path)
926
+ heads = [_g_head(tip, b, d) for b in dict.fromkeys(ys_in)]
927
+ d_attr = " ".join(lines)
928
+ classes = (
929
+ f"ce ek-{_esc(e.kind)} s-{_esc(e.source)} t-{_esc(e.target)}"
930
+ + (" warn" if e.tone == "warn" else "")
931
+ + (" guess" if e.guessed else "")
932
+ + (" via" if e.indirect else "")
933
+ )
934
+ taken = [] if taken is None else taken
935
+
936
+ def place() -> tuple[float, float]: # the first spot clear of every mark drawn so far
937
+ at = next((p for p in spots if all(math.dist(p, q) >= 17.5 for q in taken)), spots[0])
938
+ taken.append(at)
939
+ return at
940
+
941
+ mark = ""
942
+ if spots and e.tone == "warn":
943
+ cx, cy = place()
944
+ mark += (
945
+ f'<g class="wm" transform="translate({cx:.1f},{cy:.1f})"><circle r="7.5"/>'
946
+ '<path d="M0,-3.7V.5M0,3.4V3.5"/></g>'
947
+ )
948
+ if spots and e.loop and mark_loop:
949
+ cx, cy = place()
950
+ mark += (
951
+ f'<g class="lpb" transform="translate({cx:.1f},{cy:.1f})"><circle r="8.5"/>'
952
+ f'<path transform="scale(.66) translate(-8,-8)" d="{_G_LOOP}"/></g>'
953
+ )
954
+ halo = (
955
+ f'<path class="halo" d="{" ".join(warned)}"/><path class="cs" d="{" ".join(warned)}"/>' # amber, edged
956
+ if warned
957
+ else ""
958
+ )
959
+ body = (
960
+ f'<g class="{classes}"><title>{_esc(_g_edge_tip(e, graph))}</title><path class="hit" d="{d_attr}"/>{halo}'
961
+ f'<path class="ln" d="{d_attr}"/>' + "".join(f'<path class="ah" d="{h}"/>' for h in heads) + mark + "</g>"
962
+ )
963
+ dots = (
964
+ f'<g class="{classes} pt">'
965
+ + "".join(f'<circle cx="{x1:.1f}" cy="{y:.1f}" r="2.8"/>' for y in dict.fromkeys(ys_out))
966
+ + "</g>"
967
+ )
968
+ return body, dots
969
+
970
+
971
+ def _g_sub_svg(n: GraphNode, room: float) -> str:
972
+ if n.kind in ("handler", "function") and "/" in n.sub: # the folder dimmed, shortened from the left
973
+ folder, _, rest = n.sub.rpartition("/")
974
+ folder += "/"
975
+ if _g_sans(rest, 12.5) > room:
976
+ return _esc(_g_fit(rest, room, lambda t: _g_sans(t, 12.5)))
977
+ if _g_sans(folder + rest, 12.5) > room: # whole folders dropped from the left: '…/orders.py line 10'
978
+ parts = folder.rstrip("/").split("/")
979
+ while parts and _g_sans("…/" + "/".join(parts) + "/" + rest, 12.5) > room:
980
+ parts.pop(0)
981
+ folder = "…/" + ("/".join(parts) + "/" if parts else "")
982
+ if not parts and _g_sans(folder + rest, 12.5) > room:
983
+ folder = ""
984
+ return (f'<tspan class="dir">{_esc(folder)}</tspan>' if folder else "") + _esc(rest)
985
+ if (
986
+ n.kind in ("aws", "service") and n.sub
987
+ ): # 'reads · writes', each in its arrows' colour, cut with … when they don't fit
988
+ words = n.sub.split(" · ")
989
+ shown = list(words)
990
+ while len(shown) > 1 and _g_sans(" · ".join(shown) + (" · …" if shown != words else ""), 12.5, True) > room:
991
+ shown.pop()
992
+ return '<tspan class="sep"> · </tspan>'.join(
993
+ [f'<tspan class="ac ek-{_esc(w)}">{_esc(w)}</tspan>' for w in shown] + (["…"] if shown != words else [])
994
+ )
995
+ return _esc(_g_fit(n.sub, room, lambda t: _g_sans(t, 12.5)))
996
+
997
+
998
+ def _g_calls(count: int) -> str:
999
+ return f"{count} call" if count == 1 else f"{count} calls"
1000
+
1001
+
1002
+ def _node_svg(
1003
+ card: _GCard, box: tuple[float, float, float, float], neighbours: list[str], access: dict[str, str]
1004
+ ) -> str:
1005
+ """
1006
+ A box: its badge and name, what's under it, a route's pill, its listed lines (an operation's dot in its arrows'
1007
+ colour, a line a warning is about with its flag) or its docstring, and a warning badge.
1008
+ """
1009
+ n = card.node
1010
+ x, y, w, h = box
1011
+ warn = n.tone == "warn"
1012
+ label_y = 27 if n.sub else 31
1013
+ parts = [
1014
+ f'<text class="lb" x="52" y="{label_y + _GRAPH_LABEL_LINE * k}">{_esc(line)}</text>'
1015
+ for k, line in enumerate(card.label)
1016
+ ]
1017
+ if n.sub:
1018
+ parts.append(
1019
+ f'<text class="sb" x="52" y="{45 + _GRAPH_LABEL_LINE * (len(card.label) - 1)}">'
1020
+ f"{_g_sub_svg(n, w - 52 - 12)}</text>"
1021
+ )
1022
+ badge_y = 11 if n.sub else 9
1023
+ parts.append(_g_badge(n, badge_y))
1024
+ top = card.head
1025
+ if card.route:
1026
+ first, method, more = _g_route(card)
1027
+ path = first[len(method) + 1 :]
1028
+ pill_w = min(_g_mono(first + more, 12.5) + 20, w - 28)
1029
+ shown = _g_fit(path, pill_w - 20 - _g_mono(method + " " + more, 12.5), lambda t: _g_mono(t, 12.5))
1030
+ parts.append(
1031
+ f'<g class="rt"><rect x="14" y="{top:.0f}" width="{pill_w:.0f}" height="21" rx="10.5"/>'
1032
+ f'<text x="24" y="{top + 14.8:.1f}"><tspan class="mt">{_esc(method)}</tspan> {_esc(shown)}'
1033
+ f"{_esc(more)}</text></g>"
1034
+ )
1035
+ top += _GRAPH_ROUTE
1036
+ if n.when: # called only under an if: its test, in a pill like a route's
1037
+ test = _g_fit(n.when, w - 28 - 20 - _g_mono("when ", 12.5), lambda t: _g_mono(t, 12.5))
1038
+ pill_w = _g_mono("when " + test, 12.5) + 20
1039
+ parts.append(
1040
+ f'<g class="rt wh"><rect x="14" y="{top:.0f}" width="{pill_w:.0f}" height="21" rx="10.5"/>'
1041
+ f'<text x="24" y="{top + 14.8:.1f}"><tspan class="ww">when </tspan>{_esc(test)}</text></g>'
1042
+ )
1043
+ top += _GRAPH_ROUTE
1044
+ if n.kind in _GRAPH_LISTS and card.rows:
1045
+ parts.append(f'<path class="dv" d="M1,{card.body:.0f}H{w - 1:.0f}"/>')
1046
+ for row in card.rows:
1047
+ ry = card.row_y(row)
1048
+ if ry is None:
1049
+ continue
1050
+ if row.startswith("(") or (row.startswith("+") and row.endswith(" more")):
1051
+ parts.append(f'<text class="rw mo" x="16" y="{ry + 4.3:.1f}">{_esc(row)}</text>')
1052
+ continue
1053
+ tone = (" ac ek-" + _esc(access[row])) if n.kind in ("aws", "service") and row in access else ""
1054
+ flag = n.flags.get(row, "")
1055
+ room = w - 28 - 14 - (_g_sans(flag, 12, True) + 12 if flag else 0)
1056
+ parts.append(
1057
+ f'<circle class="bu{tone}{" flagged" if flag else ""}" cx="17" cy="{ry:.1f}" r="2.6"/>'
1058
+ f'<text class="rw" x="27" y="{ry + 4.3:.1f}">{_esc(_g_fit(row, room, lambda t: _g_mono(t, 12.5)))}'
1059
+ "</text>"
1060
+ )
1061
+ if flag:
1062
+ parts.append(
1063
+ f'<text class="ns" x="{w - 14:.0f}" y="{ry + 4.3:.1f}" text-anchor="end">{_esc(flag)}</text>'
1064
+ )
1065
+ for k, line in enumerate(card.prose):
1066
+ parts.append(f'<text class="pr" x="14" y="{top + 13 + _GRAPH_PROSE * k:.1f}">{_esc(line)}</text>')
1067
+ if n.more:
1068
+ said = f"+{_g_calls(len(n.more))} not drawn"
1069
+ for k in range(len(n.more), 0, -1): # as many of their names as fit whole, the rest as …
1070
+ names = ", ".join(n.more[:k]) + (", …" if k < len(n.more) else "")
1071
+ if _g_sans(f"{said}: {names}", 12.5) <= w - 28:
1072
+ said = f"{said}: {names}"
1073
+ break
1074
+ parts.append(
1075
+ f'<text class="pr mo" x="14" y="{top + 13 + _GRAPH_PROSE * len(card.prose):.1f}">{_esc(said)}</text>'
1076
+ )
1077
+ if warn:
1078
+ parts.append(
1079
+ f'<g class="wb" transform="translate({w - 21:.0f},{badge_y + _G_BADGE / 2:.0f})"><circle r="8.5"/>'
1080
+ '<path d="M0,-4.2V.6M0,3.9V4"/></g>'
1081
+ )
1082
+ classes = " ".join(["cn", f"k-{n.kind}", f"n-{n.id}", *(f"a-{m}" for m in neighbours)]) + (" warn" if warn else "")
1083
+ return (
1084
+ f'<g class="{_esc(classes)}" transform="translate({x:.1f},{y:.1f})"><title>{_esc(n.tip or n.label)}</title>'
1085
+ f'<rect class="bg" width="{w:.0f}" height="{h:.0f}" rx="11"/>'
1086
+ f'<rect class="fg" x=".5" y=".5" width="{w - 1:.0f}" height="{h - 1:.0f}" rx="10.5"/>{"".join(parts)}</g>'
1087
+ )
1088
+
1089
+
1090
+ _G_PANEL_LINES = 400 # lines of one function a box's panel shows, at most
1091
+
1092
+ _G_PANEL_SPANS = 12 # places a box's panel shows, at most
1093
+
1094
+ _G_PANEL_KINDS = {
1095
+ "event": "What comes in",
1096
+ "env": "Environment",
1097
+ "start": "At import",
1098
+ "handler": "Entry point",
1099
+ "function": "Function",
1100
+ "aws": "AWS service",
1101
+ "service": "Service",
1102
+ "http": "HTTP",
1103
+ }
1104
+
1105
+
1106
+ def _g_panel_id(n: GraphNode) -> str:
1107
+ return f"cn-code-{n.id}"
1108
+
1109
+
1110
+ def _g_filled(line: str) -> bool:
1111
+ """
1112
+ Whether a coloured line has anything on it.
1113
+ """
1114
+ return bool(re.sub(r"<[^>]*>", "", line).strip())
1115
+
1116
+
1117
+ def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "") -> str:
1118
+ """
1119
+ A box's code, in a panel that slides in from the right when the box is clicked (its id is the link's target, so
1120
+ it's CSS's :target that opens it): what the box is, then each place it's about with its lines numbered, the lines
1121
+ worth pointing at lit. '' when none of its code is in the files read.
1122
+ """
1123
+ shown, lit_any = [], False
1124
+ for span in n.code[:_G_PANEL_SPANS]:
1125
+ lines = lines_of(span.file)
1126
+ start, end = max(1, span.start), min(len(lines), span.end)
1127
+ if n.kind in ("handler", "function"): # its decorators, over its def
1128
+ while start > 1 and lines[start - 2].lstrip().startswith("@"):
1129
+ start -= 1
1130
+ while start < end and start not in span.lit and not _g_filled(lines[start - 1]):
1131
+ start += 1
1132
+ while end > start and end not in span.lit and not _g_filled(lines[end - 1]):
1133
+ end -= 1
1134
+ if start > end:
1135
+ continue
1136
+ more = end - start + 1 - _G_PANEL_LINES
1137
+ end = min(end, start + _G_PANEL_LINES - 1)
1138
+ lit_any |= any(start <= k <= end for k in span.lit)
1139
+ rows = "".join(
1140
+ f'<span class="sl{" lit" if k in span.lit else ""}"><span class="sn">{k}</span>{lines[k - 1]}</span>'
1141
+ for k in range(start, end + 1)
1142
+ )
1143
+ where = f"line {start}" if start == end else f"lines {start}–{end}"
1144
+ href = open_at(editor, span.file, min(span.lit, default=start))
1145
+ link = (
1146
+ f' <a class="ced" href="{_esc(href)}" title="Open it in {_esc(editor_name(editor))}">open ↗</a>'
1147
+ if href
1148
+ else ""
1149
+ )
1150
+ shown.append(
1151
+ f'<div class="csw">{_esc(span.file)} <span>{where}</span>{link}</div>'
1152
+ f'<pre class="csc">{rows}</pre>'
1153
+ + (f'<div class="csm">+{_plural(more, "more line")}</div>' if more > 0 else "")
1154
+ )
1155
+ if not shown:
1156
+ return ""
1157
+ rest = len(n.code) - _G_PANEL_SPANS
1158
+ if rest > 0:
1159
+ shown.append(f'<div class="csm">+{_plural(rest, "more place")}: the tables below list them all</div>')
1160
+ said = "".join(f'<p class="csd">{_prose(line)}</p>' for line in (n.tip or "").split("\n") if line)
1161
+ if lit_any:
1162
+ said += (
1163
+ '<p class="csd csl">The lit lines are where it calls, reads or sends what its arrows show.</p>'
1164
+ if n.kind in ("handler", "function", "start")
1165
+ else '<p class="csd csl">The lit lines are where the code uses it.</p>'
1166
+ )
1167
+ mono = " mono" if n.kind in ("handler", "function") else ""
1168
+ return (
1169
+ f'<aside class="cside" id="{_esc(_g_panel_id(n))}" aria-label="{_esc("The code of " + n.label)}">'
1170
+ f'<div class="csh"><div class="cst"><span class="csk">{_esc(_G_PANEL_KINDS.get(n.kind, n.kind))}</span>'
1171
+ f'<b class="csn{mono}">{_esc(n.label)}</b>'
1172
+ + (f'<span class="css">{_esc(n.sub)}</span>' if n.sub else "")
1173
+ + '</div><a class="csx" href="#cn-closed" aria-label="Close" title="Close">×</a></div>'
1174
+ f"{said}{''.join(shown)}</aside>"
1175
+ )
1176
+
1177
+
1178
+ def _g_captions(graph: CodeGraph, xs: dict[int, float], widths: dict[int, float], column_of: dict[str, int]) -> str:
1179
+ """
1180
+ The words over the columns: COMES IN, HANDLER, CALLS, REACHES, each over the columns it covers.
1181
+ """
1182
+ kinds: dict[int, set[str]] = defaultdict(set)
1183
+ for n in graph.nodes:
1184
+ kinds[column_of[n.id]].add(n.kind)
1185
+
1186
+ def word(found: set[str]) -> str:
1187
+ return (
1188
+ "Reaches"
1189
+ if found & {"aws", "service", "http"}
1190
+ else "Entry"
1191
+ if "handler" in found
1192
+ else "Comes in"
1193
+ if found & {"event", "env"}
1194
+ else "Calls"
1195
+ if "function" in found
1196
+ else "Start-up"
1197
+ )
1198
+
1199
+ groups: list[list[Any]] = []
1200
+ for k in sorted(kinds):
1201
+ said = word(kinds[k])
1202
+ if groups and groups[-1][0] == said:
1203
+ groups[-1][2] = k
1204
+ else:
1205
+ groups.append([said, k, k])
1206
+ out = []
1207
+ for said, first, last in groups:
1208
+ x, right = xs[first], xs[last] + widths[last]
1209
+ text = said.upper()
1210
+ end = x + _g_sans(text, 12, bold=True) + len(text) * 1.2 + 10
1211
+ out.append(f'<text class="cap" x="{x + 2:.0f}" y="25">{_esc(text)}</text>')
1212
+ if right - end > 12:
1213
+ out.append(f'<path class="capl" d="M{end:.0f},21H{right:.0f}"/>')
1214
+ return "".join(out)
1215
+
1216
+
1217
+ def _g_swatch(kind: str) -> str:
1218
+ return (
1219
+ f'<svg class="sw ek-{kind}" width="26" height="10" viewBox="0 0 26 10" aria-hidden="true">'
1220
+ f'<path class="ln" d="M1.5,5H17"/><path class="ah" d="{_g_head(24.5, 5, 1)}"/></svg>'
1221
+ )
1222
+
1223
+
1224
+ @_draws(_Graph, "html")
1225
+ def _graph_html(block: _Graph, report: _Report) -> str:
1226
+ title = f"<h4>{_prose(block.title)}</h4>" if block.title else ""
1227
+ graph = block.graph
1228
+ if not graph.nodes:
1229
+ return title + f'<div class="more">{_esc(block.empty)}</div>'
1230
+ cards = _g_cards(graph)
1231
+ boxes, routes, width, tall, xs, widths, column_of = _graph_layout(graph, cards)
1232
+ ends = _g_anchors(graph, cards, boxes)
1233
+ near: dict[str, list[str]] = defaultdict(list)
1234
+ access: dict[str, dict[str, str]] = defaultdict(dict) # an AWS box's operation -> what it does
1235
+ for e in graph.edges:
1236
+ near[e.source].append(e.target)
1237
+ near[e.target].append(e.source)
1238
+ if e.kind in _G_ACCESS:
1239
+ for name in _g_names(e):
1240
+ access[e.target].setdefault(name, e.kind)
1241
+ root = report.root or _ROOT
1242
+ highlighted: dict[str, list[str]] = {}
1243
+
1244
+ def lines_of(file: str) -> list[str]: # a file's lines in colour, each file coloured once
1245
+ if file not in highlighted:
1246
+ highlighted[file] = _python_lines(block.sources[file]) if file in block.sources else []
1247
+ return highlighted[file]
1248
+
1249
+ panels = {n.id: _g_panel(n, lines_of, block.editor) for n in graph.nodes} if block.sources else {}
1250
+ for n in graph.nodes:
1251
+ if panels.get(n.id): # the box whose code is open, outlined
1252
+ report.rules.add(
1253
+ f".{root} .cgw:has(#{_g_panel_id(n)}:target) .cg .n-{n.id} .fg"
1254
+ "{stroke:light-dark(#2563eb,#60a5fa);stroke-opacity:1;stroke-width:2}"
1255
+ )
1256
+ for n in graph.nodes: # pointing at a box leaves only it, its arrows and the boxes they join in sight
1257
+ report.rules.add(f".{root} .cg:has(.n-{n.id}:hover) .ce:not(.s-{n.id}):not(.t-{n.id}){{opacity:.06}}")
1258
+ report.rules.add(f".{root} .cg:has(.n-{n.id}:hover) .cn:not(.n-{n.id}):not(.a-{n.id}){{opacity:.28}}")
1259
+ report.rules.add(
1260
+ f".{root} .cg:has(.n-{n.id}:hover) :is(.s-{n.id},.t-{n.id}) .ln{{stroke-width:2.4;stroke-opacity:1}}"
1261
+ )
1262
+ marked: set[tuple[str, str]] = set() # one loop mark between two boxes, even for two arrows
1263
+ taken: list[tuple[float, float]] = [] # where marks are, so the next one goes clear of them
1264
+ lanes: dict[frozenset[str], int] = defaultdict(int) # calls within a column between the same two boxes
1265
+ drawn = []
1266
+ for i, e in enumerate(graph.edges):
1267
+ hot = None
1268
+ if e.tone == "warn": # the lines from or to the row the warning is about (TEAM_TOPIC, not set), else all
1269
+ out, into = _g_rows(cards[e.source], e), _g_rows(cards[e.target], e)
1270
+ hot = {
1271
+ (a, b)
1272
+ for a in range(len(ends[i][0]))
1273
+ for b in range(len(ends[i][1]))
1274
+ if (a < len(out) and out[a] in cards[e.source].node.flags)
1275
+ or (b < len(into) and into[b] in cards[e.target].node.flags)
1276
+ } or None
1277
+ lane = 0
1278
+ if column_of[e.source] == column_of[e.target]:
1279
+ lane = lanes[frozenset((e.source, e.target))]
1280
+ lanes[frozenset((e.source, e.target))] += 1
1281
+ drawn.append(
1282
+ _edge_svg(
1283
+ e,
1284
+ graph,
1285
+ boxes,
1286
+ xs,
1287
+ widths,
1288
+ column_of,
1289
+ *ends[i],
1290
+ routes.get(i),
1291
+ (e.source, e.target) not in marked,
1292
+ hot,
1293
+ taken,
1294
+ lane,
1295
+ )
1296
+ )
1297
+ if e.loop:
1298
+ marked.add((e.source, e.target))
1299
+ edges = "".join(body for body, _ in drawn)
1300
+ ports = "".join(dots for _, dots in drawn)
1301
+ nodes = "".join(
1302
+ (
1303
+ f'<a class="cl" href="#{_esc(_g_panel_id(n))}" aria-label="{_esc("Show the code of " + n.label)}">{box}</a>'
1304
+ if panels.get(n.id)
1305
+ else box
1306
+ )
1307
+ for n in graph.nodes
1308
+ for box in [_node_svg(cards[n.id], boxes[n.id], list(dict.fromkeys(near[n.id])), access[n.id])]
1309
+ )
1310
+ kinds = {e.kind for e in graph.edges}
1311
+ pills = [f'<span class="kp">{_g_swatch(k)}{_esc(word)}</span>' for k, word in _G_LEGEND if k in kinds]
1312
+ if any(e.guessed for e in graph.edges):
1313
+ pills.append(f'<span class="kp">{_g_swatch("guess")}guessed from a method\'s name</span>')
1314
+ if any(e.indirect for e in graph.edges):
1315
+ pills.append(f'<span class="kp">{_g_swatch("via")}through functions not drawn</span>')
1316
+ if any(e.tone == "warn" for e in graph.edges) or any(n.tone == "warn" for n in graph.nodes):
1317
+ pills.append(
1318
+ '<span class="kp"><svg class="sw kw" width="16" height="16" viewBox="-8 -8 16 16" '
1319
+ 'aria-hidden="true"><circle r="7"/><path d="M0,-3.6V.5M0,3.4V3.5"/></svg>'
1320
+ "a warning is about it</span>"
1321
+ )
1322
+ if any(e.loop for e in graph.edges):
1323
+ pills.append(
1324
+ '<span class="kp"><svg class="sw kl" width="16" height="16" viewBox="-8 -8 16 16" '
1325
+ f'aria-hidden="true"><circle r="7"/><path transform="scale(.6) translate(-8,-8)" d="{_G_LOOP}"/>'
1326
+ "</svg>in a loop: once per item</span>"
1327
+ )
1328
+ hidden = (
1329
+ (
1330
+ f'<span class="kp kh"><b>+{graph.hidden}</b>{"more function" if graph.hidden == 1 else "more functions"} '
1331
+ "not drawn (too deep or too many): the Step by step tab has them all</span>"
1332
+ )
1333
+ if graph.hidden
1334
+ else ""
1335
+ )
1336
+ hint = (
1337
+ '<span class="kt">Point at a box to see only what it\'s joined to'
1338
+ + ("; click it to read its code" if any(panels.values()) else "")
1339
+ + "</span>"
1340
+ )
1341
+ made = report.shared("graphs", lambda: [0]) # graphs drawn so far, so each one's buttons have ids of their own
1342
+ group = f"cn-graph{made[0]}"
1343
+ made[0] += 1
1344
+ # its zoom and full screen: inputs before the box they change, so CSS reaches it with ~ (no :has(), no script)
1345
+ switches = f'<input type="checkbox" class="cgs cgfs" id="{group}-full">' + "".join(
1346
+ f'<input type="radio" class="cgs cgz-{key}" name="{group}-zoom" id="{group}-{key}"'
1347
+ f"{' checked' if key == 'auto' else ''}>"
1348
+ for key, _, _ in _GRAPH_ZOOMS
1349
+ )
1350
+ bar = (
1351
+ '<div class="cgt"><span class="cgtz" role="group" aria-label="Zoom">'
1352
+ + "".join(
1353
+ f'<label class="zl-{key}" for="{group}-{key}" title="{_esc(tip)}">{_esc(word)}</label>'
1354
+ for key, word, tip in _GRAPH_ZOOMS
1355
+ )
1356
+ + f'</span><label class="cgtf" for="{group}-full"><span class="on">Full screen</span>'
1357
+ '<span class="off">Close full screen</span></label></div>'
1358
+ )
1359
+ # in a box of its own with its panels: the :has() rules that outline a box whose code is open look only in here,
1360
+ # so a radio button elsewhere (the walk's steps) doesn't make the browser restyle the whole page
1361
+ return (
1362
+ f'{title}<div class="cgw">{switches}<div class="cgb">{bar}<div class="cg">'
1363
+ f'<svg viewBox="0 0 {width:.0f} {tall:.0f}" width="{width:.0f}" height="{tall:.0f}" '
1364
+ f'style="--w:{width:.0f}px" role="img" aria-label="{_esc(block.title or "How the code fits together")}">'
1365
+ f"{_g_captions(graph, xs, widths, column_of)}{edges}{nodes}{ports}</svg></div>"
1366
+ f'<div class="cgk">{"".join(pills)}{hidden}{hint}</div></div>{"".join(panels.values())}</div>'
1367
+ )
1368
+
1369
+
1370
+ @_draws(_Graph, "text")
1371
+ def _graph_lines(block: _Graph, report: _Report) -> list[str]:
1372
+ out = ["", f"-- {block.title} --"] if block.title else [""]
1373
+ graph = block.graph
1374
+ if not graph.nodes:
1375
+ return out + [block.empty]
1376
+ leaving: dict[str, list[GraphEdge]] = defaultdict(list)
1377
+ for e in graph.edges:
1378
+ leaving[e.source].append(e)
1379
+ for n in sorted(graph.nodes, key=lambda n: n.column):
1380
+ if n.kind in ("aws", "service", "http"):
1381
+ continue
1382
+ lines = [f"{line} ({n.flags[line]})" if line in n.flags else line for line in n.lines]
1383
+ head = n.label + (
1384
+ f": {', '.join(lines)}" if n.kind in ("event", "env") and n.lines else f" {n.sub}" if n.sub else ""
1385
+ )
1386
+ out.append(f" {head}" + (" (!)" if n.tone == "warn" else ""))
1387
+ for e in leaving.get(n.id, []):
1388
+ target = graph.node(e.target)
1389
+ name = target.label if target else e.target
1390
+ said = {
1391
+ "call": f"calls {name}" + (f" ({e.label})" if e.label else "") + (f" when {e.when}" if e.when else ""),
1392
+ "event": f"→ {name}",
1393
+ "env": f"→ {name} reads {e.label}",
1394
+ "client": f"makes the {name} client",
1395
+ "http": f"sends {e.label}",
1396
+ }.get(e.kind) or f"{_EDGE_WORDS.get(e.kind, e.kind)} {name}: {e.label}"
1397
+ said += "".join(
1398
+ [
1399
+ ", in a loop" if e.loop else "",
1400
+ ", guessed from a method's name" if e.guessed else "",
1401
+ ", through functions not drawn" if e.indirect else "",
1402
+ ]
1403
+ )
1404
+ out.append(f" {said}" + (" (!)" if e.tone == "warn" else ""))
1405
+ if n.more:
1406
+ out.append(f" +{_g_calls(len(n.more))} not drawn: {', '.join(n.more)}")
1407
+ elif n.kind in ("handler", "function", "start") and not leaving.get(n.id):
1408
+ out.append(" (does nothing this follows: no AWS calls, own functions or HTTP)")
1409
+ if graph.hidden:
1410
+ out.append(f" ({_plural(graph.hidden, 'more function')} not drawn: the steps below have them all)")
1411
+ return out