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
code_node/pages.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One page, made from scratch: read the folder, follow the code from the entry point, draw it. The command saves it
|
|
3
|
+
once, and the server makes it again on every change.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .analysis.graph import code_graph
|
|
13
|
+
from .analysis.mapping import entry_file, map_code
|
|
14
|
+
from .analysis.model import CodeMap
|
|
15
|
+
from .deploys import find_deployment
|
|
16
|
+
from .project import EntryPoint, Project, read_project
|
|
17
|
+
from .report.build import page_blocks
|
|
18
|
+
from .report.editor import editor_link
|
|
19
|
+
from .report.style import render_page
|
|
20
|
+
|
|
21
|
+
GRAPH_FUNCTIONS = 24 # functions the graph draws by default, the ones that lead to the most code first
|
|
22
|
+
|
|
23
|
+
GRAPH_DEPTH = 5 # ...and how many calls deep
|
|
24
|
+
|
|
25
|
+
_ALL = 10_000 # --all: no limit that a real codebase reaches
|
|
26
|
+
|
|
27
|
+
_ALL_READ_DEPTH = 40 # --all: calls followed from the entry point
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PageOptions:
|
|
32
|
+
"""
|
|
33
|
+
How much of the code the page draws, and which platform's checks it runs.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
draw_all: bool = False
|
|
37
|
+
functions: int = GRAPH_FUNCTIONS
|
|
38
|
+
depth: int = GRAPH_DEPTH
|
|
39
|
+
platform: str = "" # --platform: its checks run whatever the entry point is ('' leaves it to the entry point)
|
|
40
|
+
editor: str = "vscode" # --editor: the editor its line links open ('vscode', 'cursor'; 'none' for no links)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def limits(self) -> tuple[int, int]:
|
|
44
|
+
"""
|
|
45
|
+
(functions, depth) the graph draws.
|
|
46
|
+
"""
|
|
47
|
+
return (_ALL, _ALL) if self.draw_all else (self.functions, self.depth)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def map_entry(root: Path, entry: EntryPoint, options: PageOptions) -> tuple[CodeMap, Project]:
|
|
51
|
+
"""
|
|
52
|
+
The folder read as it is now, and the code followed from the entry point: the CodeMap, and the folder.
|
|
53
|
+
"""
|
|
54
|
+
project = read_project(root)
|
|
55
|
+
file = entry.file or (entry_file(entry.name, project.files) or ("",))[0]
|
|
56
|
+
deployment = find_deployment(project.deploy_files, project.files, file, entry.name.rsplit(".", 1)[-1])
|
|
57
|
+
cmap = map_code(
|
|
58
|
+
project.files,
|
|
59
|
+
entry.name,
|
|
60
|
+
libraries=project.libraries(),
|
|
61
|
+
file=entry.file or None,
|
|
62
|
+
max_depth=_ALL_READ_DEPTH if options.draw_all else None,
|
|
63
|
+
platform=options.platform or entry.platform,
|
|
64
|
+
deployment=deployment,
|
|
65
|
+
)
|
|
66
|
+
return cmap, project
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def make_page(root: Path, entry: EntryPoint, options: PageOptions) -> tuple[str, CodeMap]:
|
|
70
|
+
"""
|
|
71
|
+
The whole HTML page for an entry point, and the CodeMap it was drawn from.
|
|
72
|
+
"""
|
|
73
|
+
cmap, project = map_entry(root, entry, options)
|
|
74
|
+
max_functions, max_depth = options.limits
|
|
75
|
+
blocks = page_blocks(
|
|
76
|
+
cmap,
|
|
77
|
+
project=project.root.name,
|
|
78
|
+
max_functions=max_functions,
|
|
79
|
+
max_depth=max_depth,
|
|
80
|
+
sources=project.files,
|
|
81
|
+
editor=editor_link(options.editor, str(project.root), os.environ.get("WSL_DISTRO_NAME", "")),
|
|
82
|
+
)
|
|
83
|
+
return render_page(blocks, f"How {entry.name} works"), cmap
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def make_mermaid(root: Path, entry: EntryPoint, options: PageOptions) -> str:
|
|
87
|
+
"""
|
|
88
|
+
The graph as Mermaid.
|
|
89
|
+
"""
|
|
90
|
+
cmap, _ = map_entry(root, entry, options)
|
|
91
|
+
max_functions, max_depth = options.limits
|
|
92
|
+
return code_graph(cmap, max_functions=max_functions, max_depth=max_depth).mermaid()
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Platforms: what a kind of entry point adds to reading plain Python. The reader, the findings and the graph are
|
|
3
|
+
generic; a platform says what its entry points look like, what they're called with, what its code can't do there,
|
|
4
|
+
and how to say so. AWS Lambda is one (aws_lambda.py), a web app (FastAPI, Flask, Django) another (web.py). Pure,
|
|
5
|
+
like analysis/: no I/O, no rendering.
|
|
6
|
+
|
|
7
|
+
Another package adds one by subclassing Platform and naming it under the `code_node.platforms` entry point group:
|
|
8
|
+
|
|
9
|
+
[project.entry-points."code_node.platforms"]
|
|
10
|
+
cloud_run = "my_package.cloud_run:CloudRun"
|
|
11
|
+
|
|
12
|
+
or, from a script, with register_platform(CloudRun()).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
from typing import TYPE_CHECKING, Iterable
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..analysis.model import CodeMap, EventField
|
|
22
|
+
|
|
23
|
+
ENTRY_POINT_GROUP = "code_node.platforms"
|
|
24
|
+
|
|
25
|
+
_BUILT_IN = ("code_node.platforms.aws_lambda:LambdaPlatform", "code_node.platforms.web:WebPlatform")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Platform:
|
|
29
|
+
"""
|
|
30
|
+
Plain Python, and the base a platform plugin overrides: an entry point is a function called with nothing the
|
|
31
|
+
reader knows, code runs when Python imports it, and findings are worded for any code. A plugin sets `name` and
|
|
32
|
+
overrides what's different where it runs.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name = "" # what --platform takes and CodeMap.platform holds: 'lambda'; '' is plain Python
|
|
36
|
+
title = "Python" # 'AWS Lambda'
|
|
37
|
+
entry_kind = "" # how its entry points are listed: 'a Lambda handler (event, context)'
|
|
38
|
+
inputs: tuple[str, ...] = () # what its entry function is called with: ('event', 'context'); the reader records
|
|
39
|
+
# the fields the code reads from the one named 'event'
|
|
40
|
+
input_name = "its input" # 'the event'
|
|
41
|
+
input_word = "input" # what a field of it is said to be read from, beside a step: 'event' ('event body.id')
|
|
42
|
+
draws_input = False # the graph draws the input box even when the code reads no field of it
|
|
43
|
+
started = "When Python imports it" # what runs at import is said to run: 'When Lambda starts it (...)'
|
|
44
|
+
web_apps: tuple[str, ...] = () # app classes that make an entry point of a module-level object, like Chalice
|
|
45
|
+
input_objects: tuple[str, ...] = () # names from a library that hold the input being handled: ('flask.request',)
|
|
46
|
+
input_aliases: dict[str, str] = {} # its input's methods that give back a part of it: {'get_json': 'json'}
|
|
47
|
+
|
|
48
|
+
def find_entries(self, module: str, tree: ast.Module) -> list[tuple[str, int, str]]:
|
|
49
|
+
"""
|
|
50
|
+
Its entry points in one module, as (name in the module, line, why): [('lambda_handler', 12, 'a Lambda
|
|
51
|
+
handler (event, context)')].
|
|
52
|
+
"""
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
def route_inputs(
|
|
56
|
+
self, function: ast.FunctionDef | ast.AsyncFunctionDef, path: str, imports: set[str], bound: bool = False
|
|
57
|
+
) -> dict[str, tuple[str, ...]]:
|
|
58
|
+
"""
|
|
59
|
+
What each parameter of a function a route runs holds from the input, as a path into it: {'order_id':
|
|
60
|
+
('path', 'order_id'), 'request': ()}. path is the route's ('/orders/{order_id}'), imports the libraries its
|
|
61
|
+
module imports ({'fastapi'}), bound whether the first parameter is self. A parameter left out holds nothing
|
|
62
|
+
it knows.
|
|
63
|
+
"""
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
def entry_icon(self, cmap: CodeMap) -> str:
|
|
67
|
+
"""
|
|
68
|
+
The logo the entry point's box shows, by its name in report/logos/ ('fastapi-icon'); '' for its line icon.
|
|
69
|
+
"""
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
def input_kind(self, fields: Iterable[EventField]) -> str | None:
|
|
73
|
+
"""
|
|
74
|
+
What sends the input, from the fields the code reads: 'an SQS batch'. None when it can't tell.
|
|
75
|
+
"""
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def python_version(self, cmap: CodeMap) -> tuple[int, int] | None:
|
|
79
|
+
"""
|
|
80
|
+
The Python the code runs on, when it's known: (3, 12).
|
|
81
|
+
"""
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def unset_env(self, cmap: CodeMap) -> set[str]:
|
|
85
|
+
"""
|
|
86
|
+
The environment variables the code needs that are known not to be set where it runs.
|
|
87
|
+
"""
|
|
88
|
+
return set()
|
|
89
|
+
|
|
90
|
+
def missing_entry(self, cmap: CodeMap, name: str, others: str) -> str:
|
|
91
|
+
"""
|
|
92
|
+
The finding when the entry point isn't a function in its file.
|
|
93
|
+
"""
|
|
94
|
+
return (
|
|
95
|
+
f"No function {name}() in {cmap.handler_file}: there's nothing to start from. Name one of its "
|
|
96
|
+
f"functions{others}."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def hang(self, cmap: CodeMap) -> str:
|
|
100
|
+
"""
|
|
101
|
+
What an HTTP request without a timeout does when the server doesn't answer.
|
|
102
|
+
"""
|
|
103
|
+
return "the call hangs for as long as the connection stays open."
|
|
104
|
+
|
|
105
|
+
def lost_error(self) -> str:
|
|
106
|
+
"""
|
|
107
|
+
What a run whose errors are dropped looks like.
|
|
108
|
+
"""
|
|
109
|
+
return "looks like it worked, so whoever called it never learns it didn't."
|
|
110
|
+
|
|
111
|
+
def import_error(self, module: str) -> str:
|
|
112
|
+
"""
|
|
113
|
+
The error an import that can't be found raises: "ModuleNotFoundError: No module named 'x'".
|
|
114
|
+
"""
|
|
115
|
+
return f"ModuleNotFoundError: No module named '{module}'"
|
|
116
|
+
|
|
117
|
+
def install(self, pip: str, them: str) -> str:
|
|
118
|
+
"""
|
|
119
|
+
How to add libraries the code imports: 'install it (pip install x) and list it in the project's
|
|
120
|
+
dependencies'.
|
|
121
|
+
"""
|
|
122
|
+
return f"install {them} (pip install {pip}) and list {them} in the project's dependencies"
|
|
123
|
+
|
|
124
|
+
def entry_findings(self, cmap: CodeMap) -> list[tuple[str, str]]:
|
|
125
|
+
"""
|
|
126
|
+
Findings about the entry point itself, as (level, message): the graph marks its box with them.
|
|
127
|
+
"""
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
def findings(self, cmap: CodeMap) -> list[tuple[str, str]]:
|
|
131
|
+
"""
|
|
132
|
+
Findings only this platform has, as (level, message): after the ones for any code.
|
|
133
|
+
"""
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
def denied(self, cmap: CodeMap) -> dict[str, str]:
|
|
137
|
+
"""
|
|
138
|
+
The permissions its AWS calls need that where it runs is known not to allow, each with why, in a few words:
|
|
139
|
+
{'sns:Publish': 'not in its Policies in template.yaml'}. The graph marks those calls.
|
|
140
|
+
"""
|
|
141
|
+
return {}
|
|
142
|
+
|
|
143
|
+
def marks(self, cmap: CodeMap) -> dict[str, list[str]]:
|
|
144
|
+
"""
|
|
145
|
+
Why a function's box in the graph is amber, for this platform's findings: {'etl.save': ['writes data.csv
|
|
146
|
+
outside /tmp (line 9)']}.
|
|
147
|
+
"""
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
PYTHON = Platform()
|
|
152
|
+
|
|
153
|
+
_platforms: dict[str, Platform] = {}
|
|
154
|
+
|
|
155
|
+
_loaded = False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def register_platform(platform: Platform) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Adds a platform (or replaces the one with its name): --platform takes its name, and its entry points are found.
|
|
161
|
+
"""
|
|
162
|
+
if not platform.name:
|
|
163
|
+
raise ValueError("a platform needs a name")
|
|
164
|
+
_platforms[platform.name] = platform
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _add(found: Platform | type[Platform]) -> None:
|
|
168
|
+
platform = found() if isinstance(found, type) else found
|
|
169
|
+
if platform.name:
|
|
170
|
+
_platforms.setdefault(platform.name, platform) # one registered by hand first wins
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def platforms() -> list[Platform]:
|
|
174
|
+
"""
|
|
175
|
+
Every platform: the built-in ones, then those installed packages add, loaded the first time it's asked.
|
|
176
|
+
"""
|
|
177
|
+
global _loaded
|
|
178
|
+
if not _loaded:
|
|
179
|
+
_loaded = True
|
|
180
|
+
from importlib import import_module
|
|
181
|
+
from importlib.metadata import entry_points
|
|
182
|
+
|
|
183
|
+
for target in _BUILT_IN:
|
|
184
|
+
module, _, attr = target.partition(":")
|
|
185
|
+
_add(getattr(import_module(module), attr))
|
|
186
|
+
for point in entry_points(group=ENTRY_POINT_GROUP):
|
|
187
|
+
try:
|
|
188
|
+
_add(point.load())
|
|
189
|
+
except Exception: # noqa: BLE001 a broken plugin leaves the others working
|
|
190
|
+
continue
|
|
191
|
+
return list(_platforms.values())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_platform(name: str | None) -> Platform:
|
|
195
|
+
"""
|
|
196
|
+
The platform with that name; plain Python for '' or a name no platform has.
|
|
197
|
+
"""
|
|
198
|
+
if not name:
|
|
199
|
+
return PYTHON
|
|
200
|
+
return next((p for p in platforms() if p.name == name), PYTHON)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
__all__ = ["ENTRY_POINT_GROUP", "PYTHON", "Platform", "get_platform", "platforms", "register_platform"]
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AWS Lambda as a platform: handlers are entry points, the handler is called with (event, context), code at import
|
|
3
|
+
runs once per cold start, the disk is read-only outside /tmp, and the run is billed while it waits. Its findings
|
|
4
|
+
check what a handler needs that plain Python doesn't.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import posixpath
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
from ..analysis.aws_model import _event_kind, _service_name
|
|
15
|
+
from ..analysis.findings import _listed, _where
|
|
16
|
+
from ..analysis.mapping import _python_version
|
|
17
|
+
from ..analysis.model import CodeMap, CodeSpot, EnvRead, EventField
|
|
18
|
+
from ..deploys import allowed, get_deploy
|
|
19
|
+
from ..kit.fmt import _plural
|
|
20
|
+
from . import Platform
|
|
21
|
+
|
|
22
|
+
_HANDLERS = ("lambda_handler", "handler")
|
|
23
|
+
|
|
24
|
+
_WRITABLE = ("/tmp", "/mnt/") # Lambda's disk is read-only but for /tmp (and a file system mounted under /mnt)
|
|
25
|
+
|
|
26
|
+
_LAMBDA_ENV = frozenset(
|
|
27
|
+
{ # environment variables Lambda sets itself: reading one needs no setting
|
|
28
|
+
"AWS_REGION",
|
|
29
|
+
"AWS_DEFAULT_REGION",
|
|
30
|
+
"AWS_EXECUTION_ENV",
|
|
31
|
+
"AWS_LAMBDA_FUNCTION_NAME",
|
|
32
|
+
"AWS_LAMBDA_FUNCTION_MEMORY_SIZE",
|
|
33
|
+
"AWS_LAMBDA_FUNCTION_VERSION",
|
|
34
|
+
"AWS_LAMBDA_INITIALIZATION_TYPE",
|
|
35
|
+
"AWS_LAMBDA_LOG_GROUP_NAME",
|
|
36
|
+
"AWS_LAMBDA_LOG_STREAM_NAME",
|
|
37
|
+
"AWS_ACCESS_KEY_ID",
|
|
38
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
39
|
+
"AWS_SESSION_TOKEN",
|
|
40
|
+
"AWS_LAMBDA_RUNTIME_API",
|
|
41
|
+
"LAMBDA_TASK_ROOT",
|
|
42
|
+
"LAMBDA_RUNTIME_DIR",
|
|
43
|
+
"_HANDLER",
|
|
44
|
+
"_X_AMZN_TRACE_ID",
|
|
45
|
+
"TZ",
|
|
46
|
+
"LANG",
|
|
47
|
+
"PATH",
|
|
48
|
+
"LD_LIBRARY_PATH",
|
|
49
|
+
"PYTHONPATH",
|
|
50
|
+
"AWS_XRAY_CONTEXT_MISSING",
|
|
51
|
+
"AWS_XRAY_DAEMON_ADDRESS",
|
|
52
|
+
"AWS_LAMBDA_LOG_FORMAT",
|
|
53
|
+
"AWS_LAMBDA_LOG_LEVEL",
|
|
54
|
+
"AWS_LAMBDA_MAX_CONCURRENCY",
|
|
55
|
+
"HOME",
|
|
56
|
+
"PWD",
|
|
57
|
+
"_AWS_XRAY_DAEMON_ADDRESS",
|
|
58
|
+
"_AWS_XRAY_DAEMON_PORT",
|
|
59
|
+
"AWS_LAMBDA_EXEC_WRAPPER",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LambdaPlatform(Platform):
|
|
65
|
+
"""
|
|
66
|
+
An AWS Lambda handler: def handler(event, context) in its deployment package.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
name = "lambda"
|
|
70
|
+
title = "AWS Lambda"
|
|
71
|
+
entry_kind = "a Lambda handler (event, context)"
|
|
72
|
+
inputs = ("event", "context")
|
|
73
|
+
input_name = "the event"
|
|
74
|
+
input_word = "event"
|
|
75
|
+
draws_input = True
|
|
76
|
+
started = "When Lambda starts it (once per cold start)"
|
|
77
|
+
web_apps = (
|
|
78
|
+
"Chalice",
|
|
79
|
+
"APIGatewayRestResolver",
|
|
80
|
+
"APIGatewayHttpResolver",
|
|
81
|
+
"ALBResolver",
|
|
82
|
+
"LambdaFunctionUrlResolver",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def find_entries(self, module: str, tree: ast.Module) -> list[tuple[str, int, str]]:
|
|
86
|
+
found = []
|
|
87
|
+
for node in tree.body:
|
|
88
|
+
is_handler = (
|
|
89
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
90
|
+
and node.name in _HANDLERS
|
|
91
|
+
and len(node.args.posonlyargs + node.args.args) == 2
|
|
92
|
+
)
|
|
93
|
+
if is_handler:
|
|
94
|
+
found.append((node.name, node.lineno, self.entry_kind))
|
|
95
|
+
return sorted(found, key=lambda entry: _HANDLERS.index(entry[0]))
|
|
96
|
+
|
|
97
|
+
def input_kind(self, fields: Iterable[EventField]) -> str | None:
|
|
98
|
+
return _event_kind(fields)
|
|
99
|
+
|
|
100
|
+
def python_version(self, cmap: CodeMap) -> tuple[int, int] | None:
|
|
101
|
+
return _python_version(cmap.function.runtime) if cmap.function is not None else None
|
|
102
|
+
|
|
103
|
+
def unset_env(self, cmap: CodeMap) -> set[str]:
|
|
104
|
+
fn = cmap.function
|
|
105
|
+
if fn is None or fn.env_error is not None:
|
|
106
|
+
return set()
|
|
107
|
+
return {e.name for e in cmap.env if e.required and e.name not in fn.env_names and e.name not in _LAMBDA_ENV}
|
|
108
|
+
|
|
109
|
+
def denied(self, cmap: CodeMap) -> dict[str, str]:
|
|
110
|
+
fn = cmap.function
|
|
111
|
+
if fn is None or getattr(fn, "permissions", None) is None:
|
|
112
|
+
return {}
|
|
113
|
+
return {
|
|
114
|
+
c.permission: f"{fn.name}'s role in {fn.source} doesn't allow {c.permission}"
|
|
115
|
+
for c in cmap.aws
|
|
116
|
+
if c.permission and not c.guessed and not allowed(c.permission, fn.permissions)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def missing_entry(self, cmap: CodeMap, name: str, others: str) -> str:
|
|
120
|
+
return (
|
|
121
|
+
f"No function {name}() in {cmap.handler_file}: every call fails with Runtime.HandlerNotFound before "
|
|
122
|
+
f"any of its code runs. Point the handler setting at one of its functions{others}, or add it."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def hang(self, cmap: CodeMap) -> str:
|
|
126
|
+
fn = cmap.function
|
|
127
|
+
timeout = f"its {fn.timeout:,} s timeout" if fn is not None else "its timeout"
|
|
128
|
+
return f"the run waits until Lambda stops it at {timeout}, and you pay for the wait."
|
|
129
|
+
|
|
130
|
+
def lost_error(self) -> str:
|
|
131
|
+
return "reports success, so Lambda doesn't retry it, CloudWatch doesn't count the error, and its event is lost."
|
|
132
|
+
|
|
133
|
+
def import_error(self, module: str) -> str:
|
|
134
|
+
return f"Runtime.ImportModuleError: No module named '{module}'"
|
|
135
|
+
|
|
136
|
+
def install(self, pip: str, them: str) -> str:
|
|
137
|
+
return f"add {them} to the package (pip install {pip} -t .) or a layer"
|
|
138
|
+
|
|
139
|
+
def entry_findings(self, cmap: CodeMap) -> list[tuple[str, str]]:
|
|
140
|
+
found: list[tuple[str, str]] = []
|
|
141
|
+
if cmap.note or cmap.entry is None:
|
|
142
|
+
return found
|
|
143
|
+
name = (cmap.handler or "").rsplit(".", 1)[-1]
|
|
144
|
+
if cmap.is_async:
|
|
145
|
+
found.append(
|
|
146
|
+
(
|
|
147
|
+
"warn",
|
|
148
|
+
(
|
|
149
|
+
f"{name}() is async def: the Python runtime calls it without awaiting it, so its code never runs "
|
|
150
|
+
"and the call returns a coroutine. Wrap it in a plain function that runs it: def handler(event, "
|
|
151
|
+
"context): return asyncio.run(main(event, context))."
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
elif any(f.name == cmap.entry for f in cmap.functions): # a function, not an app object
|
|
156
|
+
positional = [p for p in cmap.params if not p.startswith("*")]
|
|
157
|
+
required = [p for p in positional if "=" not in p]
|
|
158
|
+
star = any(p.startswith("*") and not p.startswith("**") for p in cmap.params)
|
|
159
|
+
if len(required) > 2 or (len(positional) < 2 and not star):
|
|
160
|
+
found.append(
|
|
161
|
+
(
|
|
162
|
+
"warn",
|
|
163
|
+
(
|
|
164
|
+
f"{name}() takes {_plural(len(required), 'argument')}, and Lambda passes two (event and "
|
|
165
|
+
f"context): every call fails with TypeError. Make it def {name}(event, context)."
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
unset = self.unset_env(cmap)
|
|
170
|
+
if unset:
|
|
171
|
+
missing: dict[str, list[EnvRead]] = defaultdict(list)
|
|
172
|
+
for read in cmap.env:
|
|
173
|
+
if read.name in unset:
|
|
174
|
+
missing[read.name].append(read)
|
|
175
|
+
names = list(missing)
|
|
176
|
+
first = [reads[0] for reads in missing.values()]
|
|
177
|
+
startup = any(r.startup for r in first)
|
|
178
|
+
where = _listed([f"{r.file} line {r.line}" for r in first], 2)
|
|
179
|
+
them = "it" if len(names) == 1 else "them"
|
|
180
|
+
found.append(
|
|
181
|
+
(
|
|
182
|
+
"warn",
|
|
183
|
+
(
|
|
184
|
+
f"{_listed(names)} {'isn' if len(names) == 1 else 'aren'}'t set on {cmap.function.name}, and its code "
|
|
185
|
+
f"reads {them} with os.environ[...] ({where}): that line fails with KeyError: '{names[0]}'"
|
|
186
|
+
+ (", when Lambda loads the code, so every call fails" if startup else "")
|
|
187
|
+
+ f". Add {them} to its environment variables (the console's Configuration tab, Environment "
|
|
188
|
+
"variables)."
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return found
|
|
193
|
+
|
|
194
|
+
def findings(self, cmap: CodeMap) -> list[tuple[str, str]]:
|
|
195
|
+
found: list[tuple[str, str]] = []
|
|
196
|
+
if cmap.note:
|
|
197
|
+
return found
|
|
198
|
+
fn = cmap.function
|
|
199
|
+
layered = [i for i in cmap.imports if i.source == "layer" and not i.optional]
|
|
200
|
+
if layered and fn is not None:
|
|
201
|
+
tops = list(dict.fromkeys(i.top for i in layered))
|
|
202
|
+
found.append(
|
|
203
|
+
(
|
|
204
|
+
"info",
|
|
205
|
+
(
|
|
206
|
+
f"{_listed(tops)} {'isn' if len(tops) == 1 else 'aren'}'t in the package, so "
|
|
207
|
+
f"{'it has' if len(tops) == 1 else 'they have'} to come from its layers "
|
|
208
|
+
f"({_listed([layer.rsplit(':', 2)[0].rsplit(':', 1)[-1] for layer in fn.layers], 2)}): if no layer has "
|
|
209
|
+
f"{'it' if len(tops) == 1 else 'them'}, calls fail with Runtime.ImportModuleError."
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
for spot in self._outside_tmp(cmap):
|
|
214
|
+
name = posixpath.basename(spot.detail.rstrip("/")) or "out"
|
|
215
|
+
found.append(
|
|
216
|
+
(
|
|
217
|
+
"warn",
|
|
218
|
+
(
|
|
219
|
+
f"It writes {spot.detail} at {_where(spot.file, spot.line)}, outside /tmp: Lambda's disk is read-only "
|
|
220
|
+
f"there, so that line fails with OSError: [Errno 30] Read-only file system. Write under /tmp instead "
|
|
221
|
+
f"(/tmp/{name})."
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
for spot in [s for s in cmap.spots if s.kind == "client" and not s.ok]:
|
|
226
|
+
service = _service_name(spot.detail) if spot.detail != "?" else "AWS"
|
|
227
|
+
found.append(
|
|
228
|
+
(
|
|
229
|
+
"info",
|
|
230
|
+
(
|
|
231
|
+
f"It makes a new {service} client on every call, at {_where(spot.file, spot.line, spot.function)}"
|
|
232
|
+
+ (" inside a loop" if spot.loop else "")
|
|
233
|
+
+ ": making one takes time, more on a cold start. Make it once at module level (above the handler), "
|
|
234
|
+
"so warm calls reuse it."
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
for spot in [s for s in cmap.spots if s.kind == "connect" and not s.ok and not s.startup]:
|
|
239
|
+
found.append(
|
|
240
|
+
(
|
|
241
|
+
"info",
|
|
242
|
+
(
|
|
243
|
+
f"It opens a new {spot.detail} connection on every call, at "
|
|
244
|
+
f"{_where(spot.file, spot.line, spot.function)}"
|
|
245
|
+
+ (" inside a loop" if spot.loop else "")
|
|
246
|
+
+ ": that's a round trip or more before any work, and many runs at once can use up the server's "
|
|
247
|
+
"connections. Make it once at module level (above the handler), so warm calls reuse it."
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
denied = self.denied(cmap)
|
|
252
|
+
if denied and fn is not None:
|
|
253
|
+
calls = [c for c in cmap.aws if c.permission in denied]
|
|
254
|
+
missing = list(dict.fromkeys(c.permission for c in calls))
|
|
255
|
+
deploy = get_deploy(fn.reader)
|
|
256
|
+
fix = deploy.grant(fn, missing) if deploy is not None else f"allow {', '.join(missing)} in its role"
|
|
257
|
+
given = f" (it has {_listed(fn.policies, 3)})" if fn.policies else ""
|
|
258
|
+
found.append(
|
|
259
|
+
(
|
|
260
|
+
"warn",
|
|
261
|
+
(
|
|
262
|
+
f"{fn.name}'s role in {fn.source}{given} doesn't allow {_listed(missing)}, which "
|
|
263
|
+
+ _listed([f"{c.label} at {_where(c.file, c.line)}" for c in calls], 2)
|
|
264
|
+
+ (" needs: that call fails" if len(calls) == 1 else " need: those calls fail")
|
|
265
|
+
+ f" with AccessDenied. {fix[0].upper()}{fix[1:]}."
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
for spot in [s for s in cmap.spots if s.kind == "sleep" and not s.startup]:
|
|
270
|
+
found.append(
|
|
271
|
+
(
|
|
272
|
+
"info",
|
|
273
|
+
(
|
|
274
|
+
f"It sleeps at {_where(spot.file, spot.line, spot.function)} ({spot.code}): the function is billed "
|
|
275
|
+
"for the time it waits. If it waits for something to finish, a Step Functions Wait state or an SQS "
|
|
276
|
+
"delay costs nothing while it waits."
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
return found
|
|
281
|
+
|
|
282
|
+
def marks(self, cmap: CodeMap) -> dict[str, list[str]]:
|
|
283
|
+
marked: dict[str, list[str]] = defaultdict(list)
|
|
284
|
+
for spot in self._outside_tmp(cmap):
|
|
285
|
+
marked[spot.function].append(f"writes {spot.detail} outside /tmp (line {spot.line})")
|
|
286
|
+
return marked
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _outside_tmp(cmap: CodeMap) -> list[CodeSpot]:
|
|
290
|
+
return [s for s in cmap.spots if s.kind == "write" and not s.detail.startswith(_WRITABLE)]
|