code-node-graph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. code_node/__init__.py +40 -0
  2. code_node/__main__.py +7 -0
  3. code_node/analysis/__init__.py +3 -0
  4. code_node/analysis/aws_model.py +197 -0
  5. code_node/analysis/findings.py +462 -0
  6. code_node/analysis/graph.py +780 -0
  7. code_node/analysis/mapping.py +114 -0
  8. code_node/analysis/model.py +289 -0
  9. code_node/analysis/reader.py +1926 -0
  10. code_node/analysis/syntax.py +220 -0
  11. code_node/analysis/terms.py +505 -0
  12. code_node/cli.py +316 -0
  13. code_node/deploys/__init__.py +321 -0
  14. code_node/deploys/sam.py +371 -0
  15. code_node/deploys/serverless.py +117 -0
  16. code_node/kit/__init__.py +4 -0
  17. code_node/kit/blocks.py +184 -0
  18. code_node/kit/fmt.py +389 -0
  19. code_node/kit/highlight.py +131 -0
  20. code_node/kit/render.py +1066 -0
  21. code_node/kit/text.py +81 -0
  22. code_node/libraries/__init__.py +333 -0
  23. code_node/libraries/llm.py +124 -0
  24. code_node/libraries/mongo.py +169 -0
  25. code_node/libraries/redis.py +253 -0
  26. code_node/libraries/sql.py +232 -0
  27. code_node/pages.py +92 -0
  28. code_node/platforms/__init__.py +203 -0
  29. code_node/platforms/aws_lambda.py +290 -0
  30. code_node/platforms/web.py +301 -0
  31. code_node/project.py +402 -0
  32. code_node/report/__init__.py +3 -0
  33. code_node/report/brands.py +244 -0
  34. code_node/report/build.py +119 -0
  35. code_node/report/editor.py +40 -0
  36. code_node/report/graph.py +1411 -0
  37. code_node/report/logos/README.md +8 -0
  38. code_node/report/logos/anthropic-icon.svg +1 -0
  39. code_node/report/logos/aws-amplify.svg +1 -0
  40. code_node/report/logos/aws-api-gateway.svg +1 -0
  41. code_node/report/logos/aws-appflow.svg +1 -0
  42. code_node/report/logos/aws-appsync.svg +1 -0
  43. code_node/report/logos/aws-athena.svg +1 -0
  44. code_node/report/logos/aws-backup.svg +1 -0
  45. code_node/report/logos/aws-batch.svg +1 -0
  46. code_node/report/logos/aws-certificate-manager.svg +1 -0
  47. code_node/report/logos/aws-cloudformation.svg +1 -0
  48. code_node/report/logos/aws-cloudfront.svg +1 -0
  49. code_node/report/logos/aws-cloudtrail.svg +1 -0
  50. code_node/report/logos/aws-cloudwatch.svg +1 -0
  51. code_node/report/logos/aws-codebuild.svg +1 -0
  52. code_node/report/logos/aws-codecommit.svg +1 -0
  53. code_node/report/logos/aws-codedeploy.svg +1 -0
  54. code_node/report/logos/aws-codepipeline.svg +1 -0
  55. code_node/report/logos/aws-cognito.svg +1 -0
  56. code_node/report/logos/aws-config.svg +1 -0
  57. code_node/report/logos/aws-documentdb.svg +1 -0
  58. code_node/report/logos/aws-dynamodb.svg +1 -0
  59. code_node/report/logos/aws-ec2.svg +1 -0
  60. code_node/report/logos/aws-ecs.svg +1 -0
  61. code_node/report/logos/aws-eks.svg +1 -0
  62. code_node/report/logos/aws-elasticache.svg +1 -0
  63. code_node/report/logos/aws-elb.svg +1 -0
  64. code_node/report/logos/aws-eventbridge.svg +1 -0
  65. code_node/report/logos/aws-glacier.svg +1 -0
  66. code_node/report/logos/aws-glue.svg +1 -0
  67. code_node/report/logos/aws-iam.svg +1 -0
  68. code_node/report/logos/aws-keyspaces.svg +1 -0
  69. code_node/report/logos/aws-kinesis.svg +1 -0
  70. code_node/report/logos/aws-kms.svg +1 -0
  71. code_node/report/logos/aws-lake-formation.svg +1 -0
  72. code_node/report/logos/aws-lambda.svg +1 -0
  73. code_node/report/logos/aws-lightsail.svg +1 -0
  74. code_node/report/logos/aws-mq.svg +1 -0
  75. code_node/report/logos/aws-msk.svg +1 -0
  76. code_node/report/logos/aws-neptune.svg +1 -0
  77. code_node/report/logos/aws-open-search.svg +1 -0
  78. code_node/report/logos/aws-quicksight.svg +1 -0
  79. code_node/report/logos/aws-rds.svg +1 -0
  80. code_node/report/logos/aws-redshift.svg +1 -0
  81. code_node/report/logos/aws-route53.svg +1 -0
  82. code_node/report/logos/aws-s3.svg +1 -0
  83. code_node/report/logos/aws-secrets-manager.svg +1 -0
  84. code_node/report/logos/aws-ses.svg +1 -0
  85. code_node/report/logos/aws-shield.svg +1 -0
  86. code_node/report/logos/aws-sns.svg +1 -0
  87. code_node/report/logos/aws-sqs.svg +1 -0
  88. code_node/report/logos/aws-step-functions.svg +1 -0
  89. code_node/report/logos/aws-systems-manager.svg +1 -0
  90. code_node/report/logos/aws-timestream.svg +1 -0
  91. code_node/report/logos/aws-waf.svg +1 -0
  92. code_node/report/logos/aws-xray.svg +1 -0
  93. code_node/report/logos/aws.svg +1 -0
  94. code_node/report/logos/azure-icon.svg +1 -0
  95. code_node/report/logos/django-icon.svg +1 -0
  96. code_node/report/logos/elasticsearch.svg +1 -0
  97. code_node/report/logos/fastapi-icon.svg +1 -0
  98. code_node/report/logos/firebase-icon.svg +1 -0
  99. code_node/report/logos/flask.svg +1 -0
  100. code_node/report/logos/google-cloud.svg +1 -0
  101. code_node/report/logos/kafka-icon.svg +1 -0
  102. code_node/report/logos/mariadb-icon.svg +1 -0
  103. code_node/report/logos/mongodb-icon.svg +1 -0
  104. code_node/report/logos/mysql-icon.svg +1 -0
  105. code_node/report/logos/openai-icon.svg +1 -0
  106. code_node/report/logos/postgresql.svg +1 -0
  107. code_node/report/logos/python.svg +1 -0
  108. code_node/report/logos/rabbitmq-icon.svg +1 -0
  109. code_node/report/logos/redis.svg +1 -0
  110. code_node/report/logos/serverless.svg +1 -0
  111. code_node/report/logos/slack-icon.svg +1 -0
  112. code_node/report/logos/small/aws-api-gateway.svg +1 -0
  113. code_node/report/logos/small/aws-appflow.svg +1 -0
  114. code_node/report/logos/small/aws-appsync.svg +1 -0
  115. code_node/report/logos/small/aws-athena.svg +1 -0
  116. code_node/report/logos/small/aws-backup.svg +1 -0
  117. code_node/report/logos/small/aws-batch.svg +1 -0
  118. code_node/report/logos/small/aws-certificate-manager.svg +1 -0
  119. code_node/report/logos/small/aws-cloudformation.svg +1 -0
  120. code_node/report/logos/small/aws-cloudfront.svg +1 -0
  121. code_node/report/logos/small/aws-cloudtrail.svg +1 -0
  122. code_node/report/logos/small/aws-cloudwatch.svg +1 -0
  123. code_node/report/logos/small/aws-codebuild.svg +1 -0
  124. code_node/report/logos/small/aws-codecommit.svg +1 -0
  125. code_node/report/logos/small/aws-codedeploy.svg +1 -0
  126. code_node/report/logos/small/aws-codepipeline.svg +1 -0
  127. code_node/report/logos/small/aws-cognito.svg +1 -0
  128. code_node/report/logos/small/aws-config.svg +1 -0
  129. code_node/report/logos/small/aws-documentdb.svg +1 -0
  130. code_node/report/logos/small/aws-dynamodb.svg +1 -0
  131. code_node/report/logos/small/aws-ec2.svg +1 -0
  132. code_node/report/logos/small/aws-ecs.svg +1 -0
  133. code_node/report/logos/small/aws-eks.svg +1 -0
  134. code_node/report/logos/small/aws-elasticache.svg +1 -0
  135. code_node/report/logos/small/aws-elb.svg +1 -0
  136. code_node/report/logos/small/aws-eventbridge.svg +1 -0
  137. code_node/report/logos/small/aws-glacier.svg +1 -0
  138. code_node/report/logos/small/aws-glue.svg +1 -0
  139. code_node/report/logos/small/aws-iam.svg +1 -0
  140. code_node/report/logos/small/aws-keyspaces.svg +1 -0
  141. code_node/report/logos/small/aws-kinesis.svg +1 -0
  142. code_node/report/logos/small/aws-kms.svg +1 -0
  143. code_node/report/logos/small/aws-lake-formation.svg +1 -0
  144. code_node/report/logos/small/aws-lambda.svg +1 -0
  145. code_node/report/logos/small/aws-mq.svg +1 -0
  146. code_node/report/logos/small/aws-msk.svg +1 -0
  147. code_node/report/logos/small/aws-neptune.svg +1 -0
  148. code_node/report/logos/small/aws-open-search.svg +1 -0
  149. code_node/report/logos/small/aws-quicksight.svg +1 -0
  150. code_node/report/logos/small/aws-rds.svg +1 -0
  151. code_node/report/logos/small/aws-redshift.svg +1 -0
  152. code_node/report/logos/small/aws-route53.svg +1 -0
  153. code_node/report/logos/small/aws-s3.svg +1 -0
  154. code_node/report/logos/small/aws-secrets-manager.svg +1 -0
  155. code_node/report/logos/small/aws-ses.svg +1 -0
  156. code_node/report/logos/small/aws-shield.svg +1 -0
  157. code_node/report/logos/small/aws-sns.svg +1 -0
  158. code_node/report/logos/small/aws-sqs.svg +1 -0
  159. code_node/report/logos/small/aws-step-functions.svg +1 -0
  160. code_node/report/logos/small/aws-systems-manager.svg +1 -0
  161. code_node/report/logos/small/aws-timestream.svg +1 -0
  162. code_node/report/logos/small/aws-waf.svg +1 -0
  163. code_node/report/logos/small/aws-xray.svg +1 -0
  164. code_node/report/logos/snowflake-icon.svg +1 -0
  165. code_node/report/logos/sqlite.svg +1 -0
  166. code_node/report/logos/stripe.svg +1 -0
  167. code_node/report/logos/supabase-icon.svg +1 -0
  168. code_node/report/logos/twilio-icon.svg +1 -0
  169. code_node/report/outline.py +219 -0
  170. code_node/report/style.py +358 -0
  171. code_node/report/tabs.py +73 -0
  172. code_node/report/walk.py +624 -0
  173. code_node/serve.py +277 -0
  174. code_node_graph-0.1.0.dist-info/METADATA +165 -0
  175. code_node_graph-0.1.0.dist-info/RECORD +178 -0
  176. code_node_graph-0.1.0.dist-info/WHEEL +4 -0
  177. code_node_graph-0.1.0.dist-info/entry_points.txt +2 -0
  178. code_node_graph-0.1.0.dist-info/licenses/LICENSE +201 -0
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
+ ]