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.
- code_node/__init__.py +40 -0
- code_node/__main__.py +7 -0
- code_node/analysis/__init__.py +3 -0
- code_node/analysis/aws_model.py +197 -0
- code_node/analysis/findings.py +462 -0
- code_node/analysis/graph.py +780 -0
- code_node/analysis/mapping.py +114 -0
- code_node/analysis/model.py +289 -0
- code_node/analysis/reader.py +1926 -0
- code_node/analysis/syntax.py +220 -0
- code_node/analysis/terms.py +505 -0
- code_node/cli.py +316 -0
- code_node/deploys/__init__.py +321 -0
- code_node/deploys/sam.py +371 -0
- code_node/deploys/serverless.py +117 -0
- code_node/kit/__init__.py +4 -0
- code_node/kit/blocks.py +184 -0
- code_node/kit/fmt.py +389 -0
- code_node/kit/highlight.py +131 -0
- code_node/kit/render.py +1066 -0
- code_node/kit/text.py +81 -0
- code_node/libraries/__init__.py +333 -0
- code_node/libraries/llm.py +124 -0
- code_node/libraries/mongo.py +169 -0
- code_node/libraries/redis.py +253 -0
- code_node/libraries/sql.py +232 -0
- code_node/pages.py +92 -0
- code_node/platforms/__init__.py +203 -0
- code_node/platforms/aws_lambda.py +290 -0
- code_node/platforms/web.py +301 -0
- code_node/project.py +402 -0
- code_node/report/__init__.py +3 -0
- code_node/report/brands.py +244 -0
- code_node/report/build.py +119 -0
- code_node/report/editor.py +40 -0
- code_node/report/graph.py +1411 -0
- code_node/report/logos/README.md +8 -0
- code_node/report/logos/anthropic-icon.svg +1 -0
- code_node/report/logos/aws-amplify.svg +1 -0
- code_node/report/logos/aws-api-gateway.svg +1 -0
- code_node/report/logos/aws-appflow.svg +1 -0
- code_node/report/logos/aws-appsync.svg +1 -0
- code_node/report/logos/aws-athena.svg +1 -0
- code_node/report/logos/aws-backup.svg +1 -0
- code_node/report/logos/aws-batch.svg +1 -0
- code_node/report/logos/aws-certificate-manager.svg +1 -0
- code_node/report/logos/aws-cloudformation.svg +1 -0
- code_node/report/logos/aws-cloudfront.svg +1 -0
- code_node/report/logos/aws-cloudtrail.svg +1 -0
- code_node/report/logos/aws-cloudwatch.svg +1 -0
- code_node/report/logos/aws-codebuild.svg +1 -0
- code_node/report/logos/aws-codecommit.svg +1 -0
- code_node/report/logos/aws-codedeploy.svg +1 -0
- code_node/report/logos/aws-codepipeline.svg +1 -0
- code_node/report/logos/aws-cognito.svg +1 -0
- code_node/report/logos/aws-config.svg +1 -0
- code_node/report/logos/aws-documentdb.svg +1 -0
- code_node/report/logos/aws-dynamodb.svg +1 -0
- code_node/report/logos/aws-ec2.svg +1 -0
- code_node/report/logos/aws-ecs.svg +1 -0
- code_node/report/logos/aws-eks.svg +1 -0
- code_node/report/logos/aws-elasticache.svg +1 -0
- code_node/report/logos/aws-elb.svg +1 -0
- code_node/report/logos/aws-eventbridge.svg +1 -0
- code_node/report/logos/aws-glacier.svg +1 -0
- code_node/report/logos/aws-glue.svg +1 -0
- code_node/report/logos/aws-iam.svg +1 -0
- code_node/report/logos/aws-keyspaces.svg +1 -0
- code_node/report/logos/aws-kinesis.svg +1 -0
- code_node/report/logos/aws-kms.svg +1 -0
- code_node/report/logos/aws-lake-formation.svg +1 -0
- code_node/report/logos/aws-lambda.svg +1 -0
- code_node/report/logos/aws-lightsail.svg +1 -0
- code_node/report/logos/aws-mq.svg +1 -0
- code_node/report/logos/aws-msk.svg +1 -0
- code_node/report/logos/aws-neptune.svg +1 -0
- code_node/report/logos/aws-open-search.svg +1 -0
- code_node/report/logos/aws-quicksight.svg +1 -0
- code_node/report/logos/aws-rds.svg +1 -0
- code_node/report/logos/aws-redshift.svg +1 -0
- code_node/report/logos/aws-route53.svg +1 -0
- code_node/report/logos/aws-s3.svg +1 -0
- code_node/report/logos/aws-secrets-manager.svg +1 -0
- code_node/report/logos/aws-ses.svg +1 -0
- code_node/report/logos/aws-shield.svg +1 -0
- code_node/report/logos/aws-sns.svg +1 -0
- code_node/report/logos/aws-sqs.svg +1 -0
- code_node/report/logos/aws-step-functions.svg +1 -0
- code_node/report/logos/aws-systems-manager.svg +1 -0
- code_node/report/logos/aws-timestream.svg +1 -0
- code_node/report/logos/aws-waf.svg +1 -0
- code_node/report/logos/aws-xray.svg +1 -0
- code_node/report/logos/aws.svg +1 -0
- code_node/report/logos/azure-icon.svg +1 -0
- code_node/report/logos/django-icon.svg +1 -0
- code_node/report/logos/elasticsearch.svg +1 -0
- code_node/report/logos/fastapi-icon.svg +1 -0
- code_node/report/logos/firebase-icon.svg +1 -0
- code_node/report/logos/flask.svg +1 -0
- code_node/report/logos/google-cloud.svg +1 -0
- code_node/report/logos/kafka-icon.svg +1 -0
- code_node/report/logos/mariadb-icon.svg +1 -0
- code_node/report/logos/mongodb-icon.svg +1 -0
- code_node/report/logos/mysql-icon.svg +1 -0
- code_node/report/logos/openai-icon.svg +1 -0
- code_node/report/logos/postgresql.svg +1 -0
- code_node/report/logos/python.svg +1 -0
- code_node/report/logos/rabbitmq-icon.svg +1 -0
- code_node/report/logos/redis.svg +1 -0
- code_node/report/logos/serverless.svg +1 -0
- code_node/report/logos/slack-icon.svg +1 -0
- code_node/report/logos/small/aws-api-gateway.svg +1 -0
- code_node/report/logos/small/aws-appflow.svg +1 -0
- code_node/report/logos/small/aws-appsync.svg +1 -0
- code_node/report/logos/small/aws-athena.svg +1 -0
- code_node/report/logos/small/aws-backup.svg +1 -0
- code_node/report/logos/small/aws-batch.svg +1 -0
- code_node/report/logos/small/aws-certificate-manager.svg +1 -0
- code_node/report/logos/small/aws-cloudformation.svg +1 -0
- code_node/report/logos/small/aws-cloudfront.svg +1 -0
- code_node/report/logos/small/aws-cloudtrail.svg +1 -0
- code_node/report/logos/small/aws-cloudwatch.svg +1 -0
- code_node/report/logos/small/aws-codebuild.svg +1 -0
- code_node/report/logos/small/aws-codecommit.svg +1 -0
- code_node/report/logos/small/aws-codedeploy.svg +1 -0
- code_node/report/logos/small/aws-codepipeline.svg +1 -0
- code_node/report/logos/small/aws-cognito.svg +1 -0
- code_node/report/logos/small/aws-config.svg +1 -0
- code_node/report/logos/small/aws-documentdb.svg +1 -0
- code_node/report/logos/small/aws-dynamodb.svg +1 -0
- code_node/report/logos/small/aws-ec2.svg +1 -0
- code_node/report/logos/small/aws-ecs.svg +1 -0
- code_node/report/logos/small/aws-eks.svg +1 -0
- code_node/report/logos/small/aws-elasticache.svg +1 -0
- code_node/report/logos/small/aws-elb.svg +1 -0
- code_node/report/logos/small/aws-eventbridge.svg +1 -0
- code_node/report/logos/small/aws-glacier.svg +1 -0
- code_node/report/logos/small/aws-glue.svg +1 -0
- code_node/report/logos/small/aws-iam.svg +1 -0
- code_node/report/logos/small/aws-keyspaces.svg +1 -0
- code_node/report/logos/small/aws-kinesis.svg +1 -0
- code_node/report/logos/small/aws-kms.svg +1 -0
- code_node/report/logos/small/aws-lake-formation.svg +1 -0
- code_node/report/logos/small/aws-lambda.svg +1 -0
- code_node/report/logos/small/aws-mq.svg +1 -0
- code_node/report/logos/small/aws-msk.svg +1 -0
- code_node/report/logos/small/aws-neptune.svg +1 -0
- code_node/report/logos/small/aws-open-search.svg +1 -0
- code_node/report/logos/small/aws-quicksight.svg +1 -0
- code_node/report/logos/small/aws-rds.svg +1 -0
- code_node/report/logos/small/aws-redshift.svg +1 -0
- code_node/report/logos/small/aws-route53.svg +1 -0
- code_node/report/logos/small/aws-s3.svg +1 -0
- code_node/report/logos/small/aws-secrets-manager.svg +1 -0
- code_node/report/logos/small/aws-ses.svg +1 -0
- code_node/report/logos/small/aws-shield.svg +1 -0
- code_node/report/logos/small/aws-sns.svg +1 -0
- code_node/report/logos/small/aws-sqs.svg +1 -0
- code_node/report/logos/small/aws-step-functions.svg +1 -0
- code_node/report/logos/small/aws-systems-manager.svg +1 -0
- code_node/report/logos/small/aws-timestream.svg +1 -0
- code_node/report/logos/small/aws-waf.svg +1 -0
- code_node/report/logos/small/aws-xray.svg +1 -0
- code_node/report/logos/snowflake-icon.svg +1 -0
- code_node/report/logos/sqlite.svg +1 -0
- code_node/report/logos/stripe.svg +1 -0
- code_node/report/logos/supabase-icon.svg +1 -0
- code_node/report/logos/twilio-icon.svg +1 -0
- code_node/report/outline.py +219 -0
- code_node/report/style.py +358 -0
- code_node/report/tabs.py +73 -0
- code_node/report/walk.py +624 -0
- code_node/serve.py +277 -0
- code_node_graph-0.1.0.dist-info/METADATA +165 -0
- code_node_graph-0.1.0.dist-info/RECORD +178 -0
- code_node_graph-0.1.0.dist-info/WHEEL +4 -0
- code_node_graph-0.1.0.dist-info/entry_points.txt +2 -0
- code_node_graph-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""
|
|
2
|
+
What the code map finds wrong, and the one-paragraph summary of what the code does
|
|
3
|
+
and returns.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
|
|
13
|
+
from ..kit.fmt import _plural
|
|
14
|
+
from ..kit.text import _clip
|
|
15
|
+
|
|
16
|
+
from .terms import LATEST_RUNTIMES
|
|
17
|
+
from .terms import _BATCHES, _REMOVED_MODULES
|
|
18
|
+
from .syntax import _a, _const, _parse
|
|
19
|
+
from .aws_model import _service_name
|
|
20
|
+
from .model import AwsCall, CodeMap, CodeSpot, LibraryCall
|
|
21
|
+
from .mapping import _python_version
|
|
22
|
+
from ..libraries import get_library
|
|
23
|
+
from ..platforms import get_platform
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_PIP_NAMES = {
|
|
27
|
+
"yaml": "pyyaml",
|
|
28
|
+
"PIL": "pillow",
|
|
29
|
+
"sklearn": "scikit-learn",
|
|
30
|
+
"cv2": "opencv-python-headless",
|
|
31
|
+
"dateutil": "python-dateutil",
|
|
32
|
+
"jwt": "PyJWT",
|
|
33
|
+
"dotenv": "python-dotenv",
|
|
34
|
+
"bs4": "beautifulsoup4",
|
|
35
|
+
"psycopg2": "psycopg2-binary",
|
|
36
|
+
"Crypto": "pycryptodome",
|
|
37
|
+
"jose": "python-jose",
|
|
38
|
+
"magic": "python-magic",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_REPLACEMENTS = {
|
|
42
|
+
"distutils": "setuptools, or shutil and sysconfig",
|
|
43
|
+
"imp": "importlib",
|
|
44
|
+
"asyncore": "asyncio",
|
|
45
|
+
"asynchat": "asyncio",
|
|
46
|
+
"smtpd": "aiosmtpd",
|
|
47
|
+
"cgi": "email.message and urllib.parse",
|
|
48
|
+
"pipes": "shlex.quote",
|
|
49
|
+
"crypt": "hashlib",
|
|
50
|
+
"telnetlib": "telnetlib3",
|
|
51
|
+
"imghdr": "Pillow or filetype",
|
|
52
|
+
"uu": "base64",
|
|
53
|
+
"xdrlib": "struct",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _short_name(key: str) -> str:
|
|
58
|
+
"""
|
|
59
|
+
One of its functions as people say it: 'app.main.handler' -> 'handler', 'app.store.Orders.save' ->
|
|
60
|
+
'Orders.save', 'app.store.Orders.__init__' -> 'Orders' (what you call to run it).
|
|
61
|
+
"""
|
|
62
|
+
parts = key.split(".")
|
|
63
|
+
if len(parts) >= 2 and parts[-2][:1].isupper():
|
|
64
|
+
return parts[-2] if parts[-1] == "__init__" else f"{parts[-2]}.{parts[-1]}"
|
|
65
|
+
return parts[-1]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _where(file: str, line: int, function: str = "") -> str:
|
|
69
|
+
"""
|
|
70
|
+
'etl.py line 17', or 'etl.py line 17 in handler()'.
|
|
71
|
+
"""
|
|
72
|
+
return f"{file} line {line}" + (f" in {_short_name(function)}()" if function else "")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _listed(items: list[str], most: int = 3) -> str:
|
|
76
|
+
"""
|
|
77
|
+
'a', 'a and b', 'a, b and c', 'a, b, c and 2 more'.
|
|
78
|
+
"""
|
|
79
|
+
if len(items) > most:
|
|
80
|
+
return ", ".join(items[:most]) + f" and {len(items) - most} more"
|
|
81
|
+
return " and ".join([", ".join(items[:-1]), items[-1]]) if len(items) > 1 else (items[0] if items else "")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def code_map_findings(cmap: CodeMap) -> list[tuple[str, str]]:
|
|
85
|
+
"""
|
|
86
|
+
What's worth fixing in the code, from its CodeMap: an entry point that isn't there, imports that can't be found
|
|
87
|
+
or that newer Pythons removed, HTTP requests without a timeout, list calls that read only their first page,
|
|
88
|
+
errors it drops, secrets written in the code, and AWS calls in a loop that a batch call would do in fewer
|
|
89
|
+
requests. Then what its platform adds (a Lambda handler's own checks), worded for where it runs.
|
|
90
|
+
"""
|
|
91
|
+
found: list[tuple[str, str]] = []
|
|
92
|
+
if cmap.note:
|
|
93
|
+
return found
|
|
94
|
+
platform = get_platform(cmap.platform)
|
|
95
|
+
version = platform.python_version(cmap)
|
|
96
|
+
entry_name = (cmap.handler or "").rsplit(".", 1)[-1]
|
|
97
|
+
spots: dict[str, list[CodeSpot]] = defaultdict(list)
|
|
98
|
+
for spot in cmap.spots:
|
|
99
|
+
spots[spot.kind].append(spot)
|
|
100
|
+
|
|
101
|
+
for spot in spots["syntax"]:
|
|
102
|
+
mine = sys.version_info[:2]
|
|
103
|
+
if version and version > mine:
|
|
104
|
+
found.append(
|
|
105
|
+
(
|
|
106
|
+
"info",
|
|
107
|
+
(
|
|
108
|
+
f"{spot.file} doesn't parse with this Python {mine[0]}.{mine[1]} ({spot.detail}): it may use syntax "
|
|
109
|
+
f"only Python {version[0]}.{version[1]} knows, so code-node couldn't follow it. Run code-node with "
|
|
110
|
+
f"Python {version[0]}.{version[1]} to read it."
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
found.append(
|
|
116
|
+
(
|
|
117
|
+
"warn",
|
|
118
|
+
(
|
|
119
|
+
f"{spot.file} doesn't parse ({spot.detail}): Python can't load it either, so code that imports it fails "
|
|
120
|
+
"with SyntaxError, and the map stops there. Fix that line."
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
if cmap.entry is None and cmap.handler_file and not any(s.file == cmap.handler_file for s in spots["syntax"]):
|
|
125
|
+
others = f" ({_listed(cmap.handlers)} look like entry points)" if cmap.handlers else ""
|
|
126
|
+
found.append(("warn", platform.missing_entry(cmap, entry_name, others)))
|
|
127
|
+
found += platform.entry_findings(cmap)
|
|
128
|
+
|
|
129
|
+
gone = [i for i in cmap.imports if i.source == "missing" and not i.optional]
|
|
130
|
+
if gone:
|
|
131
|
+
tops = list(dict.fromkeys(i.top for i in gone))
|
|
132
|
+
startup = any(i.startup for i in gone)
|
|
133
|
+
pip = " ".join(_PIP_NAMES.get(top, top) for top in tops[:5])
|
|
134
|
+
install = platform.install(pip, "it" if len(tops) == 1 else "them")
|
|
135
|
+
found.append(
|
|
136
|
+
(
|
|
137
|
+
"warn",
|
|
138
|
+
(
|
|
139
|
+
f"It imports {_listed(tops)}, which {'isn' if len(tops) == 1 else 'aren'}'t in the code, Python's "
|
|
140
|
+
f"standard library or the Python environment ({gone[0].file} line {gone[0].line}): "
|
|
141
|
+
+ ("every call fails" if startup else "calls that reach that line fail")
|
|
142
|
+
+ f" with {platform.import_error(tops[0])}. {install[0].upper()}{install[1:]}."
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
for i in [i for i in cmap.imports if i.source == "removed"]:
|
|
147
|
+
removed = _REMOVED_MODULES[i.top]
|
|
148
|
+
found.append(
|
|
149
|
+
(
|
|
150
|
+
"warn",
|
|
151
|
+
(
|
|
152
|
+
f"It imports {i.top} ({i.file} line {i.line}), which Python {removed[0]}.{removed[1]} removed: on the "
|
|
153
|
+
f"Python it runs on that fails with {platform.import_error(i.top)}. Use "
|
|
154
|
+
f"{_REPLACEMENTS.get(i.top, 'a replacement')} instead."
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
family_latest = LATEST_RUNTIMES["python"]
|
|
159
|
+
latest = _python_version(family_latest)
|
|
160
|
+
later = [
|
|
161
|
+
i
|
|
162
|
+
for i in cmap.imports
|
|
163
|
+
if i.source == "stdlib" and i.top in _REMOVED_MODULES and latest and _REMOVED_MODULES[i.top] <= latest
|
|
164
|
+
]
|
|
165
|
+
if later:
|
|
166
|
+
tops = list(dict.fromkeys(i.top for i in later))
|
|
167
|
+
removed = _REMOVED_MODULES[tops[0]]
|
|
168
|
+
swaps = _listed([f"{top} by {_REPLACEMENTS.get(top, 'a replacement')}" for top in tops], 2)
|
|
169
|
+
found.append(
|
|
170
|
+
(
|
|
171
|
+
"info",
|
|
172
|
+
(
|
|
173
|
+
f"It imports {_listed(tops)}, which Python {removed[0]}.{removed[1]} removed: on Python "
|
|
174
|
+
f"{removed[0]}.{removed[1]} or later it fails with {platform.import_error(tops[0])} until "
|
|
175
|
+
f"{'that is' if len(tops) == 1 else 'they are'} replaced ({swaps})."
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
slow = [s for s in spots["http"] if not s.ok]
|
|
181
|
+
if slow:
|
|
182
|
+
found.append(
|
|
183
|
+
(
|
|
184
|
+
"warn",
|
|
185
|
+
(
|
|
186
|
+
f"{_plural(len(slow), 'HTTP request')} {'has' if len(slow) == 1 else 'have'} no timeout "
|
|
187
|
+
f"({_listed([f'{s.detail} at {_where(s.file, s.line)}' for s in slow], 2)}): if the server doesn't "
|
|
188
|
+
f"answer, {platform.hang(cmap)} Pass timeout=10 (or what the call needs)."
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
for call in cmap.aws:
|
|
193
|
+
if call.pages == "one" and call.operation.startswith(("List", "Scan", "Query")) and not call.guessed:
|
|
194
|
+
first = {"ListObjectsV2": "1,000 keys", "ListObjects": "1,000 keys", "Scan": "1 MB", "Query": "1 MB"}.get(
|
|
195
|
+
call.operation, "the first page"
|
|
196
|
+
)
|
|
197
|
+
fix = (
|
|
198
|
+
"loop while the answer has LastEvaluatedKey, passing it back as ExclusiveStartKey"
|
|
199
|
+
if call.service == "dynamodb"
|
|
200
|
+
else f"use get_paginator('{_snake(call.operation)}').paginate(...) on its client"
|
|
201
|
+
)
|
|
202
|
+
found.append(
|
|
203
|
+
(
|
|
204
|
+
"warn",
|
|
205
|
+
(
|
|
206
|
+
f"{call.label} at {_where(call.file, call.line)} reads only its first page: past {first} the rest is "
|
|
207
|
+
f"left out without an error. To read every page, {fix}."
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
for spot in spots["swallow"]:
|
|
212
|
+
found.append(
|
|
213
|
+
(
|
|
214
|
+
"warn",
|
|
215
|
+
(
|
|
216
|
+
f"Errors are dropped at {_where(spot.file, spot.line, spot.function)} ({spot.code}): a run that failed "
|
|
217
|
+
f"{platform.lost_error()} Log it (logger.exception(...)) and raise it again, or report the failed item."
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
secrets = spots["secret"]
|
|
222
|
+
if secrets:
|
|
223
|
+
where = _listed([f"{s.detail} at {s.file} line {s.line}" for s in secrets], 2)
|
|
224
|
+
found.append(
|
|
225
|
+
(
|
|
226
|
+
"warn",
|
|
227
|
+
(
|
|
228
|
+
f"Secret-looking values are written in the code ({where}; "
|
|
229
|
+
"the values aren't shown here): anyone who can read the code can read them. "
|
|
230
|
+
"Keep them in Secrets Manager or Parameter Store, and read them when the code starts."
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
batched = [c for c in cmap.aws if c.loop and (c.service, c.operation) in _BATCHES and not c.guessed]
|
|
236
|
+
for call in list({(c.service, c.operation): c for c in batched}.values()):
|
|
237
|
+
alternative, size = _BATCHES[(call.service, call.operation)]
|
|
238
|
+
found.append(
|
|
239
|
+
(
|
|
240
|
+
"info",
|
|
241
|
+
(
|
|
242
|
+
f"{call.label} runs once per item in {call.loop.rstrip(':')} ({_where(call.file, call.line)}): a request "
|
|
243
|
+
f"for each. {alternative} sends up to {size:,} at a time, so a run takes fewer round trips."
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
found += _library_findings(cmap)
|
|
248
|
+
for spot in [s for s in spots["command"] if not s.ok]:
|
|
249
|
+
found.append(
|
|
250
|
+
(
|
|
251
|
+
"warn",
|
|
252
|
+
(
|
|
253
|
+
f"It runs a shell command with {spot.detail} in it, at {_where(spot.file, spot.line, spot.function)}: "
|
|
254
|
+
f"whoever sends {platform.input_name} can make it run commands of their own (shell injection). Pass the "
|
|
255
|
+
"command as a list of arguments, without shell=True, and check the value before it goes in."
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
return found + platform.findings(cmap)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _library_findings(cmap: CodeMap) -> list[tuple[str, str]]:
|
|
263
|
+
"""
|
|
264
|
+
What's worth fixing in the calls through libraries plugins know (Redis, SQL, MongoDB, OpenAI...): the calls a
|
|
265
|
+
plugin flagged, calls made once per item that a batch call would do together, and clients that wait too long
|
|
266
|
+
when their service doesn't answer. Then each plugin's own.
|
|
267
|
+
"""
|
|
268
|
+
platform = get_platform(cmap.platform)
|
|
269
|
+
found: list[tuple[str, str]] = []
|
|
270
|
+
for call in list({(c.service, c.line, c.why): c for c in cmap.library_calls if c.why}.values()):
|
|
271
|
+
where = _where(call.file, call.line, "" if call.startup else call.function)
|
|
272
|
+
found.append(("warn", f"{call.label} at {where} {call.why}"))
|
|
273
|
+
looped = [c for c in cmap.library_calls if c.loop and c.batch]
|
|
274
|
+
for call in list({(c.service, c.operation, c.target): c for c in looped}.values()):
|
|
275
|
+
found.append(
|
|
276
|
+
(
|
|
277
|
+
"info",
|
|
278
|
+
(
|
|
279
|
+
f"{call.label} runs once per item in {call.loop.rstrip(':')} ({_where(call.file, call.line)}), a round "
|
|
280
|
+
f"trip for each, where {call.batch} would need fewer."
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
for spot in [s for s in cmap.spots if s.kind == "connect"]:
|
|
285
|
+
if spot.waits:
|
|
286
|
+
found.append(
|
|
287
|
+
(
|
|
288
|
+
"info" if not platform.name else "warn",
|
|
289
|
+
(
|
|
290
|
+
f"The {spot.detail} client made at {_where(spot.file, spot.line, '' if spot.startup else spot.function)}"
|
|
291
|
+
f" has no timeout, so a call through it waits {spot.waits}: if {spot.detail} doesn't answer, "
|
|
292
|
+
f"{platform.hang(cmap)} Give it one where it's made" + (f": {spot.fix}." if spot.fix else ".")
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
if spot.loop and not spot.ok:
|
|
297
|
+
found.append(
|
|
298
|
+
(
|
|
299
|
+
"info",
|
|
300
|
+
(
|
|
301
|
+
f"It makes a new {spot.detail} client for each item in {spot.loop.rstrip(':')}, at "
|
|
302
|
+
f"{_where(spot.file, spot.line, spot.function)}: each one opens its own connection. Make it once, "
|
|
303
|
+
"before the loop."
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
by_library: dict[str, list[LibraryCall]] = defaultdict(list)
|
|
308
|
+
for call in cmap.library_calls:
|
|
309
|
+
by_library[call.library].append(call)
|
|
310
|
+
for name, calls in by_library.items():
|
|
311
|
+
library = get_library(name)
|
|
312
|
+
if library is not None:
|
|
313
|
+
found += library.findings(cmap, calls)
|
|
314
|
+
return found
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _snake(operation: str) -> str:
|
|
318
|
+
"""
|
|
319
|
+
'ListObjectsV2' -> 'list_objects_v2', boto3's method name.
|
|
320
|
+
"""
|
|
321
|
+
try:
|
|
322
|
+
from botocore import xform_name
|
|
323
|
+
except ImportError:
|
|
324
|
+
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", operation).lower()
|
|
325
|
+
return xform_name(operation)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _code_summary(cmap: CodeMap) -> list[str]:
|
|
329
|
+
"""
|
|
330
|
+
How the code works in a few sentences: what it does when it's imported (when Lambda starts it, for a handler),
|
|
331
|
+
then what each call reads from its input, which AWS services it reaches and how, its own functions it calls, and
|
|
332
|
+
what it returns.
|
|
333
|
+
"""
|
|
334
|
+
platform = get_platform(cmap.platform)
|
|
335
|
+
out = []
|
|
336
|
+
file = cmap.handler_file or ""
|
|
337
|
+
early = []
|
|
338
|
+
names = list(
|
|
339
|
+
dict.fromkeys(
|
|
340
|
+
_service_name(s.detail) for s in cmap.spots if s.kind == "client" and s.startup and s.detail != "?"
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
if names:
|
|
344
|
+
early.append(
|
|
345
|
+
f"makes {_a(names[0])} {names[0]} client" if len(names) == 1 else f"makes {_listed(names)} clients"
|
|
346
|
+
)
|
|
347
|
+
env = list(dict.fromkeys(e.name for e in cmap.env if e.startup))
|
|
348
|
+
if env:
|
|
349
|
+
early.append(f"reads {_listed(env)} from its environment")
|
|
350
|
+
early += _aws_phrase([c for c in cmap.aws if c.startup])
|
|
351
|
+
if early:
|
|
352
|
+
out.append(f"{platform.started}, the code {_listed(early, 4)}.")
|
|
353
|
+
if cmap.entry is None:
|
|
354
|
+
return out
|
|
355
|
+
entry = cmap.entry.rsplit(".", 1)[-1]
|
|
356
|
+
if any(f.name == cmap.entry for f in cmap.functions): # not an app object: its routes are said below
|
|
357
|
+
out.append(f"Each call runs {entry}() in {file}.")
|
|
358
|
+
labels = [f.label for f in cmap.event]
|
|
359
|
+
leaves = [
|
|
360
|
+
label
|
|
361
|
+
for label in labels
|
|
362
|
+
if not any(other.startswith((label + "[", label + ".", label + " (")) for other in labels)
|
|
363
|
+
]
|
|
364
|
+
if leaves:
|
|
365
|
+
kind = platform.input_kind(cmap.event)
|
|
366
|
+
out.append(
|
|
367
|
+
f"It reads {_listed(leaves, 4)} from {platform.input_name}"
|
|
368
|
+
+ (f", which looks like {kind}" if kind else "")
|
|
369
|
+
+ "."
|
|
370
|
+
)
|
|
371
|
+
phrase = _aws_phrase([c for c in cmap.aws if not c.startup])
|
|
372
|
+
phrase += _library_phrase([c for c in cmap.library_calls if not c.startup])
|
|
373
|
+
http = [s for s in cmap.spots if s.kind == "http" and not s.startup]
|
|
374
|
+
more = list(phrase)
|
|
375
|
+
if http:
|
|
376
|
+
more.append(f"sends {_plural(len(http), 'HTTP request')}")
|
|
377
|
+
commands = [path for method, path, _ in cmap.routes if method == "CLI" and not path.startswith("(")]
|
|
378
|
+
web = [f"{method} {path}" for method, path, _ in cmap.routes if method != "CLI"]
|
|
379
|
+
if commands:
|
|
380
|
+
more.append(f"has {_plural(len(commands), 'command')} ({_listed(commands)})")
|
|
381
|
+
if web:
|
|
382
|
+
more.append(f"answers {_plural(len(web), 'web route')} ({_listed(web)})")
|
|
383
|
+
if more:
|
|
384
|
+
out.append(f"It {_listed(more, 6) if len(more) < 3 else '; '.join(more[:-1]) + '; and ' + more[-1]}.")
|
|
385
|
+
called = [f for f in cmap.functions if f.depth == 1]
|
|
386
|
+
returns = list(dict.fromkeys(code for code, _, _ in cmap.returns))
|
|
387
|
+
last = []
|
|
388
|
+
if called:
|
|
389
|
+
last.append(
|
|
390
|
+
"calls "
|
|
391
|
+
+ _listed([f"{_short_name(f.name)}()" + (f" from {f.file}" if f.file != file else "") for f in called], 4)
|
|
392
|
+
)
|
|
393
|
+
if returns:
|
|
394
|
+
last.append(
|
|
395
|
+
f"returns {_returned(returns[0])}"
|
|
396
|
+
+ (f" (or {_plural(len(returns) - 1, 'other value')})" if len(returns) > 1 else "")
|
|
397
|
+
)
|
|
398
|
+
if last:
|
|
399
|
+
out.append(f"It {' and '.join(last)}.")
|
|
400
|
+
if not (leaves or more or last):
|
|
401
|
+
out.append(
|
|
402
|
+
f"It does nothing this can follow: no AWS calls, own functions, HTTP requests or fields of "
|
|
403
|
+
f"{platform.input_name}."
|
|
404
|
+
)
|
|
405
|
+
return out
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _returned(code: str) -> str:
|
|
409
|
+
"""
|
|
410
|
+
What a return statement gives back, said shortly: "{'statusCode': 200, 'body': ...}" -> 'a dict with
|
|
411
|
+
statusCode and body'; short code as it is.
|
|
412
|
+
"""
|
|
413
|
+
try:
|
|
414
|
+
node = _parse(code, mode="eval").body
|
|
415
|
+
except SyntaxError:
|
|
416
|
+
return _clip(code, 60)
|
|
417
|
+
if isinstance(node, ast.Dict) and node.keys and all(isinstance(_const(k), str) for k in node.keys):
|
|
418
|
+
keys = [str(_const(k)) for k in node.keys]
|
|
419
|
+
return f"a dict with {_listed(keys, 4)}" if len(code) > 50 else code
|
|
420
|
+
return _clip(code, 60)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _library_phrase(calls: list[LibraryCall]) -> list[str]:
|
|
424
|
+
"""
|
|
425
|
+
['reads from Redis (GET) and PostgreSQL (SELECT orders)', 'runs OpenAI (chat.completions.create gpt-4o)'].
|
|
426
|
+
"""
|
|
427
|
+
grouped: dict[str, dict[str, list[LibraryCall]]] = {}
|
|
428
|
+
for call in calls:
|
|
429
|
+
grouped.setdefault(call.access, {}).setdefault(call.service, []).append(call)
|
|
430
|
+
verbs = {"reads": "reads from", "writes": "writes to", "sends": "sends to", "runs": "calls"}
|
|
431
|
+
parts = []
|
|
432
|
+
for access, services in grouped.items():
|
|
433
|
+
each = []
|
|
434
|
+
for service, group in services.items():
|
|
435
|
+
rows = list(dict.fromkeys(c.row for c in group))
|
|
436
|
+
each.append(f"{service} ({', '.join(rows[:3])}{', …' if len(rows) > 3 else ''})")
|
|
437
|
+
parts.append(f"{verbs.get(access, 'calls')} {_listed(each, 4)}")
|
|
438
|
+
return parts
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _aws_phrase(calls: list[AwsCall]) -> list[str]:
|
|
442
|
+
"""
|
|
443
|
+
['reads from S3 (GetObject) and SQS (ListQueues)', 'writes to DynamoDB (PutItem)'].
|
|
444
|
+
"""
|
|
445
|
+
grouped: dict[str, dict[str, list[AwsCall]]] = {}
|
|
446
|
+
for call in calls:
|
|
447
|
+
grouped.setdefault(call.access, {}).setdefault(call.service, []).append(call)
|
|
448
|
+
verbs = {
|
|
449
|
+
"reads": "reads from",
|
|
450
|
+
"writes": "writes to",
|
|
451
|
+
"sends": "sends to",
|
|
452
|
+
"runs": "calls",
|
|
453
|
+
"signs": "signs links for",
|
|
454
|
+
}
|
|
455
|
+
parts = []
|
|
456
|
+
for access, services in grouped.items():
|
|
457
|
+
each = []
|
|
458
|
+
for service, group in services.items():
|
|
459
|
+
ops = list(dict.fromkeys(c.operation for c in group))
|
|
460
|
+
each.append(f"{_service_name(service)} ({', '.join(ops[:3])}{', …' if len(ops) > 3 else ''})")
|
|
461
|
+
parts.append(f"{verbs[access]} {_listed(each, 4)}")
|
|
462
|
+
return parts
|