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/cli.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
code-node: run it in a folder of Python code and it opens a page showing how the code works from an entry point,
|
|
3
|
+
as a graph of who calls whom and what each path reaches, and the steps of each call.
|
|
4
|
+
|
|
5
|
+
code-node # this folder: picks an entry point (asks when there are several)
|
|
6
|
+
code-node src/app/main.py # start from a file: its main(), or pick one of its functions
|
|
7
|
+
code-node src/app/main.py:run # ...or name the function (or Class.method) in it
|
|
8
|
+
code-node path/to/repo -e pkg.cli.main
|
|
9
|
+
code-node --list # the entry points it found
|
|
10
|
+
code-node --all # every function in the graph, however deep
|
|
11
|
+
code-node --serve # a live page on localhost that redraws whenever the code changes
|
|
12
|
+
code-node --editor cursor # line links open in Cursor (vscode by default, none for no links)
|
|
13
|
+
code-node --plugins # the platforms, libraries and deploy files it reads code with
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import webbrowser
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from .deploys import deploys
|
|
28
|
+
from .libraries import libraries
|
|
29
|
+
from .pages import GRAPH_DEPTH, GRAPH_FUNCTIONS, PageOptions, make_mermaid, make_page
|
|
30
|
+
from .platforms import platforms
|
|
31
|
+
from .report.editor import EDITORS
|
|
32
|
+
from .project import (
|
|
33
|
+
EntryPoint,
|
|
34
|
+
Project,
|
|
35
|
+
entries_in_file,
|
|
36
|
+
entry_in_files,
|
|
37
|
+
entry_points,
|
|
38
|
+
functions_matching,
|
|
39
|
+
project_root,
|
|
40
|
+
read_project,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "code-node"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
prog="code-node", description="See how Python code works: a graph of what calls what and what it reaches."
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"path",
|
|
52
|
+
nargs="?",
|
|
53
|
+
default=".",
|
|
54
|
+
help="the folder to read (default: this one), or the file to start from: main.py or main.py:function",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"-e",
|
|
58
|
+
"--entry",
|
|
59
|
+
help="where to start: module.function, module.Class.method, "
|
|
60
|
+
"path/to/file.py[:function], or part of a function's name",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument("-l", "--list", action="store_true", help="list the entry points found, and stop")
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--plugins",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="list the plugins it reads code with (platforms, libraries, deploy files), and stop",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("-o", "--out", help="where to save the page (default: ~/.cache/code-node/)")
|
|
69
|
+
parser.add_argument("--no-open", action="store_true", help="save the page without opening a browser")
|
|
70
|
+
parser.add_argument("--all", action="store_true", help="draw every function, however deep (big graphs)")
|
|
71
|
+
parser.add_argument("--depth", type=int, default=GRAPH_DEPTH, help="calls deep the graph draws (default 5)")
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--functions", type=int, default=GRAPH_FUNCTIONS, help="functions the graph draws at most (default 24)"
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument("--mermaid", action="store_true", help="print the graph as Mermaid instead of a page")
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--serve", action="store_true", help="serve the page on localhost and redraw it whenever a .py file changes"
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--port",
|
|
81
|
+
type=int,
|
|
82
|
+
default=None,
|
|
83
|
+
help="the --serve port (default: the first free one from 8765; 0 picks any free one)",
|
|
84
|
+
)
|
|
85
|
+
names = [p.name for p in platforms()]
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--platform",
|
|
88
|
+
choices=names,
|
|
89
|
+
default="",
|
|
90
|
+
help="where the code runs, for that platform's checks (found entry points set it: a Lambda "
|
|
91
|
+
"handler is lambda; default plain Python)",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--lambda", dest="platform", action="store_const", const="lambda", help="the same as --platform lambda"
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--editor",
|
|
98
|
+
choices=[*EDITORS, "none"],
|
|
99
|
+
default="vscode",
|
|
100
|
+
help="the editor the page's line links open (default vscode; none for no links)",
|
|
101
|
+
)
|
|
102
|
+
return parser.parse_args(argv)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ask(candidates: list[EntryPoint]) -> EntryPoint:
|
|
106
|
+
"""
|
|
107
|
+
The entry point the user picks from a numbered list (the first, when nobody's there to ask).
|
|
108
|
+
"""
|
|
109
|
+
if len(candidates) == 1 or not sys.stdin.isatty():
|
|
110
|
+
return candidates[0]
|
|
111
|
+
_print_entries(candidates)
|
|
112
|
+
while True:
|
|
113
|
+
answer = input(f"Start from which one? [1-{len(candidates)}, Enter for 1] ").strip()
|
|
114
|
+
if not answer:
|
|
115
|
+
return candidates[0]
|
|
116
|
+
if answer.isdigit() and 1 <= int(answer) <= len(candidates):
|
|
117
|
+
return candidates[int(answer) - 1]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _print_entries(candidates: list[EntryPoint]) -> None:
|
|
121
|
+
width = max(len(c.name) for c in candidates)
|
|
122
|
+
for i, entry in enumerate(candidates, 1):
|
|
123
|
+
print(f" {i:>2}. {entry.name:<{width}} {entry.why} ({entry.file}:{entry.line})")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pick_entry(project: Project, given: str | None) -> EntryPoint | None:
|
|
127
|
+
"""
|
|
128
|
+
The entry point to start from: the one given (exactly, or found by part of its name), else one found.
|
|
129
|
+
"""
|
|
130
|
+
if given:
|
|
131
|
+
named = entry_in_files(project, given)
|
|
132
|
+
if named is not None:
|
|
133
|
+
return named
|
|
134
|
+
exact = [
|
|
135
|
+
c for c in entry_points(project) + functions_matching(project, given.rsplit(".", 1)[-1]) if c.name == given
|
|
136
|
+
]
|
|
137
|
+
if exact:
|
|
138
|
+
return exact[0]
|
|
139
|
+
if "." in given and not functions_matching(project, given):
|
|
140
|
+
return EntryPoint(given, "given", "", 0)
|
|
141
|
+
matches = functions_matching(project, given)
|
|
142
|
+
if not matches:
|
|
143
|
+
print(f"No function in {project.root} matches {given!r}.", file=sys.stderr)
|
|
144
|
+
return None
|
|
145
|
+
return _ask(matches[:30])
|
|
146
|
+
found = entry_points(project)
|
|
147
|
+
if not found:
|
|
148
|
+
kinds = [
|
|
149
|
+
"console script",
|
|
150
|
+
"main()",
|
|
151
|
+
*(p.entry_kind.split(":")[0].split(" (")[0].removeprefix("a ") for p in platforms() if p.entry_kind),
|
|
152
|
+
]
|
|
153
|
+
print(
|
|
154
|
+
f"Found no entry point in {project.root}: no {', '.join(kinds[:-1])}, or {kinds[-1]}. Name one with -e "
|
|
155
|
+
"module.function.",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
159
|
+
return _ask(found[:30])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _print_plugins() -> None:
|
|
163
|
+
"""
|
|
164
|
+
Every plugin installed, by kind: built-in ones and those other packages add.
|
|
165
|
+
"""
|
|
166
|
+
kinds = [
|
|
167
|
+
(
|
|
168
|
+
"Platforms (one per entry point: where it runs)",
|
|
169
|
+
[(p.name, p.title, p.entry_kind or "") for p in platforms()],
|
|
170
|
+
),
|
|
171
|
+
(
|
|
172
|
+
"Libraries (as many as the code imports: what it reaches)",
|
|
173
|
+
[(lib.name, lib.title, ", ".join(lib.modules)) for lib in libraries()],
|
|
174
|
+
),
|
|
175
|
+
(
|
|
176
|
+
"Deploy files (each found in the folder: how it's deployed)",
|
|
177
|
+
[(d.name, d.title, ", ".join(d.files)) for d in deploys()],
|
|
178
|
+
),
|
|
179
|
+
]
|
|
180
|
+
for title, rows in kinds:
|
|
181
|
+
print(title)
|
|
182
|
+
width = max((len(name) for name, _, _ in rows), default=0)
|
|
183
|
+
for name, label, what in rows:
|
|
184
|
+
print(f" {name:<{width}} {label}" + (f" ({what})" if what else ""))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_wsl() -> bool:
|
|
188
|
+
version = Path("/proc/version")
|
|
189
|
+
return version.exists() and "microsoft" in version.read_text(errors="ignore").lower()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _open(target: str) -> None:
|
|
193
|
+
"""
|
|
194
|
+
Opens a saved page (a path) or the server (a URL) in the browser, from WSL too (where Python's webbrowser often
|
|
195
|
+
can't reach Windows').
|
|
196
|
+
"""
|
|
197
|
+
is_url = target.startswith("http")
|
|
198
|
+
if _is_wsl():
|
|
199
|
+
if shutil.which("wslview"):
|
|
200
|
+
subprocess.run(["wslview", target], check=False)
|
|
201
|
+
return
|
|
202
|
+
if shutil.which("explorer.exe"):
|
|
203
|
+
where = (
|
|
204
|
+
target
|
|
205
|
+
if is_url
|
|
206
|
+
else subprocess.run(
|
|
207
|
+
["wslpath", "-w", target], capture_output=True, text=True, check=False
|
|
208
|
+
).stdout.strip()
|
|
209
|
+
)
|
|
210
|
+
subprocess.run(["explorer.exe", where], check=False)
|
|
211
|
+
return
|
|
212
|
+
webbrowser.open(target if is_url else Path(target).as_uri())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _out_path(given: str | None, project: Project, entry: str) -> Path:
|
|
216
|
+
if given:
|
|
217
|
+
return Path(given).expanduser().resolve()
|
|
218
|
+
slug = re.sub(r"[^\w.-]+", "-", f"{project.root.name}--{entry}")
|
|
219
|
+
return _CACHE / f"{slug}.html"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _split_file(text: str) -> tuple[Path, str] | None:
|
|
223
|
+
"""
|
|
224
|
+
'src/app.py:run' -> (src/app.py, 'run'); 'src/app.py' -> (src/app.py, ''); None when it isn't a .py file.
|
|
225
|
+
"""
|
|
226
|
+
file, _, function = text.partition(".py:") if ".py:" in text else (text, "", "")
|
|
227
|
+
path = Path(file + (".py" if function or text.endswith(".py:") else "")).expanduser()
|
|
228
|
+
return (path.resolve(), function) if path.suffix == ".py" and path.is_file() else None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _from_file(project: Project, file: Path, function: str) -> EntryPoint | None:
|
|
232
|
+
"""
|
|
233
|
+
The entry point in a file: the function given, else the best of the file's (asking when there are several).
|
|
234
|
+
"""
|
|
235
|
+
rel = file.relative_to(project.root).as_posix()
|
|
236
|
+
if rel not in project.files:
|
|
237
|
+
print(f"{rel} is skipped (a virtual environment, cache or build folder).", file=sys.stderr)
|
|
238
|
+
return None
|
|
239
|
+
found = entries_in_file(project, rel, function)
|
|
240
|
+
if not found:
|
|
241
|
+
print(
|
|
242
|
+
f"{rel} has no functions to start from: code-node follows a function (a script's top-level code "
|
|
243
|
+
"isn't one yet).",
|
|
244
|
+
file=sys.stderr,
|
|
245
|
+
)
|
|
246
|
+
return None
|
|
247
|
+
return _ask(found[:30])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _root_for(path: Path, file: Path | None) -> Path:
|
|
251
|
+
"""
|
|
252
|
+
The folder to read: the one given, unless a file to start from was given that isn't in it (or as the path
|
|
253
|
+
itself), when it's that file's project.
|
|
254
|
+
"""
|
|
255
|
+
if file is None:
|
|
256
|
+
return path
|
|
257
|
+
folder = path.resolve()
|
|
258
|
+
if path.is_dir() and folder in file.parents:
|
|
259
|
+
return folder if path != Path(".") else project_root(file)
|
|
260
|
+
return project_root(file)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main(argv: list[str] | None = None) -> int:
|
|
264
|
+
"""
|
|
265
|
+
The code-node command.
|
|
266
|
+
"""
|
|
267
|
+
args = _parse_args(argv)
|
|
268
|
+
if args.plugins:
|
|
269
|
+
_print_plugins()
|
|
270
|
+
return 0
|
|
271
|
+
in_file = _split_file(args.path) or (_split_file(args.entry) if args.entry else None)
|
|
272
|
+
root = _root_for(Path(args.path).expanduser(), in_file[0] if in_file else None)
|
|
273
|
+
if not root.is_dir():
|
|
274
|
+
print(f"{root} isn't a folder or a .py file.", file=sys.stderr)
|
|
275
|
+
return 2
|
|
276
|
+
project = read_project(root)
|
|
277
|
+
if not project.files:
|
|
278
|
+
print(f"No Python files under {project.root}.", file=sys.stderr)
|
|
279
|
+
return 1
|
|
280
|
+
if args.list:
|
|
281
|
+
found = entry_points(project)
|
|
282
|
+
print(f"{len(found)} entry point{'s' if len(found) != 1 else ''} in {project.root}:")
|
|
283
|
+
_print_entries(found or [EntryPoint("(none: name one with -e module.function)", "", "-", 0)])
|
|
284
|
+
return 0
|
|
285
|
+
entry = _from_file(project, *in_file) if in_file else _pick_entry(project, args.entry)
|
|
286
|
+
if entry is None:
|
|
287
|
+
return 1
|
|
288
|
+
options = PageOptions(
|
|
289
|
+
draw_all=args.all, functions=args.functions, depth=args.depth, platform=args.platform, editor=args.editor
|
|
290
|
+
)
|
|
291
|
+
if args.mermaid:
|
|
292
|
+
print(make_mermaid(project.root, entry, options))
|
|
293
|
+
return 0
|
|
294
|
+
if args.serve:
|
|
295
|
+
from .serve import serve
|
|
296
|
+
|
|
297
|
+
return serve(project.root, entry, options, port=args.port, open_page=None if args.no_open else _open)
|
|
298
|
+
page, cmap = make_page(project.root, entry, options)
|
|
299
|
+
out = _out_path(args.out, project, entry.name)
|
|
300
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
out.write_text(page, "utf-8")
|
|
302
|
+
services = list(dict.fromkeys(call.service for call in cmap.library_calls))
|
|
303
|
+
print(
|
|
304
|
+
f"{entry.name}: {len(cmap.functions)} functions, {len(cmap.aws)} AWS calls"
|
|
305
|
+
+ (f", {len(cmap.library_calls)} through libraries ({', '.join(services)})" if services else "")
|
|
306
|
+
+ (f" ({cmap.note})" if cmap.note else "")
|
|
307
|
+
+ (f"\nDeployed as {cmap.function.name} in {cmap.function.source}" if cmap.function is not None else "")
|
|
308
|
+
+ f"\nSaved {out}"
|
|
309
|
+
)
|
|
310
|
+
if not args.no_open:
|
|
311
|
+
_open(str(out))
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if __name__ == "__main__":
|
|
316
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deploy files: what a project's own deploy configuration says about where each entry point runs. A deploy plugin
|
|
3
|
+
reads one kind of file (a SAM template, serverless.yml) and returns a Deployment for each function it declares: its
|
|
4
|
+
handler, the Python it runs on, its timeout and environment variables, and what its role may call. Pure, like
|
|
5
|
+
analysis/: the folder's reader hands it the files' text.
|
|
6
|
+
|
|
7
|
+
Deploy files stack with the rest: a handler they name is an entry point even when it isn't called handler(), and
|
|
8
|
+
its platform's checks then run against what the file says (an environment variable the code needs that isn't set,
|
|
9
|
+
an AWS call its role doesn't allow, a module that Python version removed).
|
|
10
|
+
|
|
11
|
+
Another package adds one by subclassing Deploy and naming it under the `code_node.deploys` entry point group:
|
|
12
|
+
|
|
13
|
+
[project.entry-points."code_node.deploys"]
|
|
14
|
+
cdk = "my_package.cdk:CdkApp"
|
|
15
|
+
|
|
16
|
+
or, from a script, with register_deploy(CdkApp()).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import posixpath
|
|
22
|
+
import re
|
|
23
|
+
from fnmatch import fnmatchcase
|
|
24
|
+
from typing import Any, Iterable
|
|
25
|
+
|
|
26
|
+
from ..analysis.model import Deployment
|
|
27
|
+
|
|
28
|
+
ENTRY_POINT_GROUP = "code_node.deploys"
|
|
29
|
+
|
|
30
|
+
_BUILT_IN = ("code_node.deploys.sam:SamTemplate", "code_node.deploys.serverless:ServerlessFile")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Deploy:
|
|
34
|
+
"""
|
|
35
|
+
A kind of deploy file. A plugin sets `name`, `title` and `files` (the file names it reads, wherever they are in
|
|
36
|
+
the folder), and overrides deployments(); grant() says how to allow what a function's role is missing.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name = "" # 'sam'
|
|
40
|
+
title = "" # 'AWS SAM'
|
|
41
|
+
files: tuple[str, ...] = () # 'template.yaml', 'template.yml'
|
|
42
|
+
|
|
43
|
+
def deployments(self, files: dict[str, str], code: set[str]) -> list[Deployment]:
|
|
44
|
+
"""
|
|
45
|
+
The functions its files declare, each with the Python file and function it starts from. files is {path: text}
|
|
46
|
+
for the files named like its own; code is every Python file in the folder, to find handlers in.
|
|
47
|
+
"""
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
def grant(self, deployment: Deployment, actions: list[str]) -> str:
|
|
51
|
+
"""
|
|
52
|
+
How to give a function's role IAM actions it's missing, as an instruction: 'add dynamodb:PutItem to its
|
|
53
|
+
role's policy'.
|
|
54
|
+
"""
|
|
55
|
+
return f"allow {', '.join(actions)} in its role's policy in {deployment.source}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_deploys: dict[str, Deploy] = {}
|
|
59
|
+
|
|
60
|
+
_loaded = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def register_deploy(deploy: Deploy) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Adds a deploy plugin (or replaces the one with its name): its files are read from then on.
|
|
66
|
+
"""
|
|
67
|
+
if not deploy.name or not deploy.files:
|
|
68
|
+
raise ValueError("a deploy plugin needs a name and the files it reads")
|
|
69
|
+
deploys()
|
|
70
|
+
_deploys[deploy.name] = deploy
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _add(found: Deploy | type[Deploy]) -> None:
|
|
74
|
+
deploy = found() if isinstance(found, type) else found
|
|
75
|
+
if deploy.name and deploy.files:
|
|
76
|
+
_deploys.setdefault(deploy.name, deploy)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def deploys() -> list[Deploy]:
|
|
80
|
+
"""
|
|
81
|
+
Every deploy plugin: the built-in ones, then those installed packages add, loaded the first time it's asked.
|
|
82
|
+
"""
|
|
83
|
+
global _loaded
|
|
84
|
+
if not _loaded:
|
|
85
|
+
_loaded = True
|
|
86
|
+
from importlib import import_module
|
|
87
|
+
from importlib.metadata import entry_points
|
|
88
|
+
|
|
89
|
+
for target in _BUILT_IN:
|
|
90
|
+
module, _, attr = target.partition(":")
|
|
91
|
+
_add(getattr(import_module(module), attr))
|
|
92
|
+
for point in entry_points(group=ENTRY_POINT_GROUP):
|
|
93
|
+
try:
|
|
94
|
+
_add(point.load())
|
|
95
|
+
except Exception: # noqa: BLE001 a broken plugin leaves the others working
|
|
96
|
+
continue
|
|
97
|
+
return list(_deploys.values())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_deploy(name: str) -> Deploy | None:
|
|
101
|
+
return next((deploy for deploy in deploys() if deploy.name == name), None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def deploy_file_names() -> set[str]:
|
|
105
|
+
"""
|
|
106
|
+
The file names any deploy plugin reads: what the folder's reader keeps besides .py files.
|
|
107
|
+
"""
|
|
108
|
+
return {name for deploy in deploys() for name in deploy.files}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def all_deployments(files: dict[str, str], code: Iterable[str]) -> list[Deployment]:
|
|
112
|
+
"""
|
|
113
|
+
Every function the folder's deploy files declare, from every deploy plugin. A file a plugin can't read is
|
|
114
|
+
skipped: deploy files are a help, never a reason to fail.
|
|
115
|
+
"""
|
|
116
|
+
code = set(code)
|
|
117
|
+
found: list[Deployment] = []
|
|
118
|
+
for deploy in deploys():
|
|
119
|
+
mine = {path: text for path, text in files.items() if posixpath.basename(path) in deploy.files}
|
|
120
|
+
if not mine:
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
found += deploy.deployments(mine, code)
|
|
124
|
+
except Exception: # noqa: BLE001 a file it can't read
|
|
125
|
+
continue
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_deployment(files: dict[str, str], code: Iterable[str], file: str, function: str) -> Deployment | None:
|
|
130
|
+
"""
|
|
131
|
+
The deployment that starts at function() in file, if a deploy file declares one.
|
|
132
|
+
"""
|
|
133
|
+
return next((d for d in all_deployments(files, code) if d.file == file and d.function == function), None)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def allowed(action: str, permissions: Iterable[str]) -> bool:
|
|
137
|
+
"""
|
|
138
|
+
Whether an IAM action is allowed by a set of actions, with their wildcards: 's3:GetObject' by 's3:Get*'.
|
|
139
|
+
"""
|
|
140
|
+
action = action.lower()
|
|
141
|
+
return any(fnmatchcase(action, granted.lower()) for granted in permissions)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_handler(base: str, code_dir: str, handler: str, code: set[str]) -> tuple[str, str] | None:
|
|
145
|
+
"""
|
|
146
|
+
The Python file and function a handler setting names, from the deploy file's folder and the code's folder in
|
|
147
|
+
it: ('', 'src/', 'cart.app.on_checkout') -> ('src/cart/app.py', 'on_checkout'). None when no file matches.
|
|
148
|
+
"""
|
|
149
|
+
module, _, function = str(handler).rpartition(".")
|
|
150
|
+
if not module or not function:
|
|
151
|
+
return None
|
|
152
|
+
stem = posixpath.normpath(posixpath.join(base, code_dir or ".", module.replace(".", "/")))
|
|
153
|
+
for candidate in (f"{stem}.py", f"{stem}/__init__.py"):
|
|
154
|
+
if candidate in code:
|
|
155
|
+
return candidate, function
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def line_of(text: str, key: str, indent: int | None = None) -> int:
|
|
160
|
+
"""
|
|
161
|
+
The line a mapping key is on in a YAML file ('CartFunction' -> 14), 0 when it can't be found.
|
|
162
|
+
"""
|
|
163
|
+
spaces = r"[ \t]*" if indent is None else " " * indent
|
|
164
|
+
found = re.search(rf"^{spaces}['\"]?{re.escape(key)}['\"]?\s*:", text, re.M)
|
|
165
|
+
return text.count("\n", 0, found.start()) + 1 if found else 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load_yaml(text: str) -> Any:
|
|
169
|
+
"""
|
|
170
|
+
A YAML (or JSON) deploy file as plain data. CloudFormation's short tags (!Ref, !GetAtt, !Sub) are read as the
|
|
171
|
+
long forms ({'Ref': ...}, {'Fn::GetAtt': ...}); serverless.yml's ${...} variables stay as text. None when
|
|
172
|
+
PyYAML isn't installed or the file doesn't parse.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
import yaml
|
|
176
|
+
except ImportError:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
class Loader(yaml.SafeLoader):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def tagged(loader: Any, suffix: str, node: Any) -> Any:
|
|
183
|
+
if isinstance(node, yaml.ScalarNode):
|
|
184
|
+
value: Any = loader.construct_scalar(node)
|
|
185
|
+
elif isinstance(node, yaml.SequenceNode):
|
|
186
|
+
value = loader.construct_sequence(node, deep=True)
|
|
187
|
+
else:
|
|
188
|
+
value = loader.construct_mapping(node, deep=True)
|
|
189
|
+
if suffix == "Ref":
|
|
190
|
+
return {"Ref": value}
|
|
191
|
+
if suffix == "GetAtt" and isinstance(value, str):
|
|
192
|
+
value = value.split(".", 1)
|
|
193
|
+
return {f"Fn::{suffix}": value}
|
|
194
|
+
|
|
195
|
+
Loader.add_multi_constructor("!", tagged)
|
|
196
|
+
try:
|
|
197
|
+
return yaml.load(text, Loader=Loader) # noqa: S506 a SafeLoader, with CloudFormation's tags
|
|
198
|
+
except yaml.YAMLError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def as_list(value: Any) -> list[Any]:
|
|
203
|
+
if value is None:
|
|
204
|
+
return []
|
|
205
|
+
return list(value) if isinstance(value, list) else [value]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def statement_actions(document: Any) -> set[str] | None:
|
|
209
|
+
"""
|
|
210
|
+
The actions a policy document's statements allow: {'s3:GetObject', 'dynamodb:*'}. None when it can't be told:
|
|
211
|
+
a Deny or NotAction (what's left can't be listed), or an action that isn't written out.
|
|
212
|
+
"""
|
|
213
|
+
if isinstance(document, dict) and "Statement" in document:
|
|
214
|
+
document = document["Statement"]
|
|
215
|
+
actions: set[str] = set()
|
|
216
|
+
for statement in as_list(document):
|
|
217
|
+
if not isinstance(statement, dict) or "NotAction" in statement or statement.get("Effect") != "Allow":
|
|
218
|
+
return None
|
|
219
|
+
for action in as_list(statement.get("Action")):
|
|
220
|
+
if not isinstance(action, str):
|
|
221
|
+
return None
|
|
222
|
+
actions.add(action)
|
|
223
|
+
return actions
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
MANAGED_POLICIES: dict[str, set[str]] = { # AWS managed policies -> the actions they allow, for the ones Lambda
|
|
227
|
+
# functions are given most (a policy not here leaves what a role allows unknown)
|
|
228
|
+
"AWSLambdaBasicExecutionRole": {"logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"},
|
|
229
|
+
"AWSLambdaExecute": {"logs:*", "s3:GetObject", "s3:PutObject"},
|
|
230
|
+
"AWSLambdaSQSQueueExecutionRole": {
|
|
231
|
+
"sqs:ReceiveMessage",
|
|
232
|
+
"sqs:DeleteMessage",
|
|
233
|
+
"sqs:GetQueueAttributes",
|
|
234
|
+
"logs:CreateLogGroup",
|
|
235
|
+
"logs:CreateLogStream",
|
|
236
|
+
"logs:PutLogEvents",
|
|
237
|
+
},
|
|
238
|
+
"AWSLambdaDynamoDBExecutionRole": {
|
|
239
|
+
"dynamodb:DescribeStream",
|
|
240
|
+
"dynamodb:GetRecords",
|
|
241
|
+
"dynamodb:GetShardIterator",
|
|
242
|
+
"dynamodb:ListStreams",
|
|
243
|
+
"logs:CreateLogGroup",
|
|
244
|
+
"logs:CreateLogStream",
|
|
245
|
+
"logs:PutLogEvents",
|
|
246
|
+
},
|
|
247
|
+
"AWSLambdaKinesisExecutionRole": {
|
|
248
|
+
"kinesis:DescribeStream",
|
|
249
|
+
"kinesis:DescribeStreamSummary",
|
|
250
|
+
"kinesis:GetRecords",
|
|
251
|
+
"kinesis:GetShardIterator",
|
|
252
|
+
"kinesis:ListShards",
|
|
253
|
+
"kinesis:ListStreams",
|
|
254
|
+
"kinesis:SubscribeToShard",
|
|
255
|
+
"logs:CreateLogGroup",
|
|
256
|
+
"logs:CreateLogStream",
|
|
257
|
+
"logs:PutLogEvents",
|
|
258
|
+
},
|
|
259
|
+
"AWSLambdaVPCAccessExecutionRole": {
|
|
260
|
+
"ec2:CreateNetworkInterface",
|
|
261
|
+
"ec2:DescribeNetworkInterfaces",
|
|
262
|
+
"ec2:DescribeSubnets",
|
|
263
|
+
"ec2:DeleteNetworkInterface",
|
|
264
|
+
"ec2:AssignPrivateIpAddresses",
|
|
265
|
+
"ec2:UnassignPrivateIpAddresses",
|
|
266
|
+
"logs:CreateLogGroup",
|
|
267
|
+
"logs:CreateLogStream",
|
|
268
|
+
"logs:PutLogEvents",
|
|
269
|
+
},
|
|
270
|
+
"AWSXRayDaemonWriteAccess": {
|
|
271
|
+
"xray:PutTraceSegments",
|
|
272
|
+
"xray:PutTelemetryRecords",
|
|
273
|
+
"xray:GetSamplingRules",
|
|
274
|
+
"xray:GetSamplingTargets",
|
|
275
|
+
"xray:GetSamplingStatisticSummaries",
|
|
276
|
+
},
|
|
277
|
+
"AmazonS3ReadOnlyAccess": {
|
|
278
|
+
"s3:Get*",
|
|
279
|
+
"s3:List*",
|
|
280
|
+
"s3:Describe*",
|
|
281
|
+
"s3-object-lambda:Get*",
|
|
282
|
+
"s3-object-lambda:List*",
|
|
283
|
+
},
|
|
284
|
+
"AmazonS3FullAccess": {"s3:*", "s3-object-lambda:*"},
|
|
285
|
+
"AmazonDynamoDBReadOnlyAccess": {
|
|
286
|
+
"dynamodb:BatchGetItem",
|
|
287
|
+
"dynamodb:ConditionCheckItem",
|
|
288
|
+
"dynamodb:Describe*",
|
|
289
|
+
"dynamodb:GetItem",
|
|
290
|
+
"dynamodb:List*",
|
|
291
|
+
"dynamodb:PartiQLSelect",
|
|
292
|
+
"dynamodb:Query",
|
|
293
|
+
"dynamodb:Scan",
|
|
294
|
+
"dynamodb:GetRecords",
|
|
295
|
+
"dynamodb:GetShardIterator",
|
|
296
|
+
},
|
|
297
|
+
"AmazonDynamoDBFullAccess": {"dynamodb:*"},
|
|
298
|
+
"AmazonSQSFullAccess": {"sqs:*"},
|
|
299
|
+
"AmazonSNSFullAccess": {"sns:*"},
|
|
300
|
+
"AmazonKinesisFullAccess": {"kinesis:*"},
|
|
301
|
+
"AmazonEventBridgeFullAccess": {"events:*"},
|
|
302
|
+
"AWSStepFunctionsFullAccess": {"states:*"},
|
|
303
|
+
"SecretsManagerReadWrite": {"secretsmanager:*"},
|
|
304
|
+
"AmazonSSMReadOnlyAccess": {"ssm:Describe*", "ssm:Get*", "ssm:List*"},
|
|
305
|
+
"AmazonBedrockFullAccess": {"bedrock:*"},
|
|
306
|
+
"AdministratorAccess": {"*"},
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
__all__ = [
|
|
311
|
+
"ENTRY_POINT_GROUP",
|
|
312
|
+
"MANAGED_POLICIES",
|
|
313
|
+
"Deploy",
|
|
314
|
+
"all_deployments",
|
|
315
|
+
"allowed",
|
|
316
|
+
"deploy_file_names",
|
|
317
|
+
"deploys",
|
|
318
|
+
"find_deployment",
|
|
319
|
+
"get_deploy",
|
|
320
|
+
"register_deploy",
|
|
321
|
+
]
|