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/deploys/sam.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AWS SAM and CloudFormation templates: each AWS::Serverless::Function (and AWS::Lambda::Function) on a Python
|
|
3
|
+
runtime, with Globals applied, its environment variables, and what its role allows: SAM's policy templates
|
|
4
|
+
(DynamoDBCrudPolicy), the managed policies it knows, inline statements, a role in the same template, and the
|
|
5
|
+
permissions its event sources add.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import posixpath
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..analysis.model import Deployment
|
|
15
|
+
from . import MANAGED_POLICIES, Deploy, allowed, as_list, line_of, load_yaml, resolve_handler, statement_actions
|
|
16
|
+
|
|
17
|
+
_D = "dynamodb:"
|
|
18
|
+
|
|
19
|
+
POLICY_TEMPLATES: dict[str, set[str]] = { # SAM's policy templates -> the actions they allow
|
|
20
|
+
"DynamoDBCrudPolicy": {
|
|
21
|
+
_D + a
|
|
22
|
+
for a in (
|
|
23
|
+
"GetItem",
|
|
24
|
+
"DeleteItem",
|
|
25
|
+
"PutItem",
|
|
26
|
+
"Scan",
|
|
27
|
+
"Query",
|
|
28
|
+
"UpdateItem",
|
|
29
|
+
"BatchWriteItem",
|
|
30
|
+
"BatchGetItem",
|
|
31
|
+
"DescribeTable",
|
|
32
|
+
"ConditionCheckItem",
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
"DynamoDBReadPolicy": {_D + a for a in ("GetItem", "Scan", "Query", "BatchGetItem", "DescribeTable")},
|
|
36
|
+
"DynamoDBWritePolicy": {_D + a for a in ("PutItem", "UpdateItem", "BatchWriteItem")},
|
|
37
|
+
"DynamoDBReconfigurePolicy": {_D + "DescribeTable", _D + "UpdateTable"},
|
|
38
|
+
"DynamoDBStreamReadPolicy": {_D + a for a in ("DescribeStream", "GetRecords", "GetShardIterator", "ListStreams")},
|
|
39
|
+
"S3ReadPolicy": {
|
|
40
|
+
"s3:GetObject",
|
|
41
|
+
"s3:ListBucket",
|
|
42
|
+
"s3:GetBucketLocation",
|
|
43
|
+
"s3:GetObjectVersion",
|
|
44
|
+
"s3:GetLifecycleConfiguration",
|
|
45
|
+
},
|
|
46
|
+
"S3WritePolicy": {"s3:PutObject", "s3:PutObjectAcl", "s3:PutLifecycleConfiguration"},
|
|
47
|
+
"S3CrudPolicy": {
|
|
48
|
+
"s3:GetObject",
|
|
49
|
+
"s3:ListBucket",
|
|
50
|
+
"s3:GetBucketLocation",
|
|
51
|
+
"s3:GetObjectVersion",
|
|
52
|
+
"s3:PutObject",
|
|
53
|
+
"s3:PutObjectAcl",
|
|
54
|
+
"s3:GetLifecycleConfiguration",
|
|
55
|
+
"s3:PutLifecycleConfiguration",
|
|
56
|
+
"s3:DeleteObject",
|
|
57
|
+
},
|
|
58
|
+
"S3FullAccessPolicy": {
|
|
59
|
+
"s3:GetObject",
|
|
60
|
+
"s3:GetObjectAcl",
|
|
61
|
+
"s3:GetObjectVersion",
|
|
62
|
+
"s3:PutObject",
|
|
63
|
+
"s3:PutObjectAcl",
|
|
64
|
+
"s3:DeleteObject",
|
|
65
|
+
"s3:DeleteObjectTagging",
|
|
66
|
+
"s3:DeleteObjectVersionTagging",
|
|
67
|
+
"s3:GetObjectTagging",
|
|
68
|
+
"s3:GetObjectVersionTagging",
|
|
69
|
+
"s3:PutObjectTagging",
|
|
70
|
+
"s3:PutObjectVersionTagging",
|
|
71
|
+
"s3:ListBucket",
|
|
72
|
+
"s3:GetBucketLocation",
|
|
73
|
+
"s3:GetLifecycleConfiguration",
|
|
74
|
+
"s3:PutLifecycleConfiguration",
|
|
75
|
+
},
|
|
76
|
+
"SQSSendMessagePolicy": {"sqs:SendMessage*"},
|
|
77
|
+
"SQSPollerPolicy": {
|
|
78
|
+
"sqs:ChangeMessageVisibility",
|
|
79
|
+
"sqs:ChangeMessageVisibilityBatch",
|
|
80
|
+
"sqs:DeleteMessage",
|
|
81
|
+
"sqs:DeleteMessageBatch",
|
|
82
|
+
"sqs:GetQueueAttributes",
|
|
83
|
+
"sqs:ReceiveMessage",
|
|
84
|
+
},
|
|
85
|
+
"SNSPublishMessagePolicy": {"sns:Publish"},
|
|
86
|
+
"SNSCrudPolicy": {
|
|
87
|
+
"sns:ListSubscriptionsByTopic",
|
|
88
|
+
"sns:CreateTopic",
|
|
89
|
+
"sns:SetTopicAttributes",
|
|
90
|
+
"sns:Subscribe",
|
|
91
|
+
"sns:Publish",
|
|
92
|
+
},
|
|
93
|
+
"LambdaInvokePolicy": {"lambda:InvokeFunction"},
|
|
94
|
+
"StepFunctionsExecutionPolicy": {"states:StartExecution"},
|
|
95
|
+
"StepFunctionsCallbackPolicy": {"states:SendTaskSuccess", "states:SendTaskFailure", "states:SendTaskHeartbeat"},
|
|
96
|
+
"AWSSecretsManagerGetSecretValuePolicy": {"secretsmanager:GetSecretValue"},
|
|
97
|
+
"SSMParameterReadPolicy": {
|
|
98
|
+
"ssm:DescribeParameters",
|
|
99
|
+
"ssm:GetParameters",
|
|
100
|
+
"ssm:GetParameter",
|
|
101
|
+
"ssm:GetParametersByPath",
|
|
102
|
+
},
|
|
103
|
+
"SSMParameterWithSlashPrefixReadPolicy": {
|
|
104
|
+
"ssm:DescribeParameters",
|
|
105
|
+
"ssm:GetParameters",
|
|
106
|
+
"ssm:GetParameter",
|
|
107
|
+
"ssm:GetParametersByPath",
|
|
108
|
+
},
|
|
109
|
+
"KMSDecryptPolicy": {"kms:Decrypt"},
|
|
110
|
+
"KMSEncryptPolicy": {"kms:Encrypt"},
|
|
111
|
+
"EventBridgePutEventsPolicy": {"events:PutEvents"},
|
|
112
|
+
"FirehoseWritePolicy": {"firehose:PutRecord", "firehose:PutRecordBatch"},
|
|
113
|
+
"SESCrudPolicy": {
|
|
114
|
+
"ses:GetIdentityVerificationAttributes",
|
|
115
|
+
"ses:SendEmail",
|
|
116
|
+
"ses:SendRawEmail",
|
|
117
|
+
"ses:VerifyEmailIdentity",
|
|
118
|
+
},
|
|
119
|
+
"SESSendBouncePolicy": {"ses:SendBounce"},
|
|
120
|
+
"SESBulkTemplatedCrudPolicy": {
|
|
121
|
+
"ses:GetIdentityVerificationAttributes",
|
|
122
|
+
"ses:SendEmail",
|
|
123
|
+
"ses:SendRawEmail",
|
|
124
|
+
"ses:SendTemplatedEmail",
|
|
125
|
+
"ses:SendBulkTemplatedEmail",
|
|
126
|
+
"ses:VerifyEmailIdentity",
|
|
127
|
+
},
|
|
128
|
+
"CloudWatchPutMetricPolicy": {"cloudwatch:PutMetricData"},
|
|
129
|
+
"RekognitionDetectOnlyPolicy": {
|
|
130
|
+
"rekognition:DetectFaces",
|
|
131
|
+
"rekognition:DetectLabels",
|
|
132
|
+
"rekognition:DetectModerationLabels",
|
|
133
|
+
"rekognition:DetectText",
|
|
134
|
+
},
|
|
135
|
+
"TextractDetectAnalyzePolicy": {
|
|
136
|
+
"textract:DetectDocumentText",
|
|
137
|
+
"textract:StartDocumentTextDetection",
|
|
138
|
+
"textract:StartDocumentAnalysis",
|
|
139
|
+
"textract:AnalyzeDocument",
|
|
140
|
+
},
|
|
141
|
+
"TextractGetResultPolicy": {"textract:GetDocumentTextDetection", "textract:GetDocumentAnalysis"},
|
|
142
|
+
"ComprehendBasicAccessPolicy": {
|
|
143
|
+
"comprehend:BatchDetectKeyPhrases",
|
|
144
|
+
"comprehend:DetectDominantLanguage",
|
|
145
|
+
"comprehend:DetectEntities",
|
|
146
|
+
"comprehend:BatchDetectEntities",
|
|
147
|
+
"comprehend:DetectKeyPhrases",
|
|
148
|
+
"comprehend:DetectSentiment",
|
|
149
|
+
"comprehend:BatchDetectDominantLanguage",
|
|
150
|
+
"comprehend:BatchDetectSentiment",
|
|
151
|
+
},
|
|
152
|
+
"EKSDescribePolicy": {"eks:DescribeCluster", "eks:ListClusters"},
|
|
153
|
+
"EC2DescribePolicy": {"ec2:DescribeRegions", "ec2:DescribeInstances"},
|
|
154
|
+
"AMIDescribePolicy": {"ec2:DescribeImages"},
|
|
155
|
+
"CloudFormationDescribeStacksPolicy": {"cloudformation:DescribeStacks"},
|
|
156
|
+
"ElasticsearchHttpPostPolicy": {"es:ESHttpPost", "es:ESHttpPut"},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_EVENT_GRANTS = { # an event source's type -> what SAM lets the function do to read it
|
|
160
|
+
"SQS": MANAGED_POLICIES["AWSLambdaSQSQueueExecutionRole"],
|
|
161
|
+
"Kinesis": MANAGED_POLICIES["AWSLambdaKinesisExecutionRole"],
|
|
162
|
+
"DynamoDB": MANAGED_POLICIES["AWSLambdaDynamoDBExecutionRole"],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_PYTHON_DEFAULT_TIMEOUT = 3 # seconds: CloudFormation's (and SAM's) Lambda timeout when none is set
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _managed(name: Any) -> set[str] | None:
|
|
169
|
+
if not isinstance(name, str):
|
|
170
|
+
return None
|
|
171
|
+
return MANAGED_POLICIES.get(name.rsplit("/", 1)[-1])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _sam_policies(policies: Any) -> tuple[set[str] | None, list[str]]:
|
|
175
|
+
"""
|
|
176
|
+
What a function's Policies allow, and how the template names each: ({'sns:Publish'}, ['SNSPublishMessagePolicy']).
|
|
177
|
+
None for the actions when one of them can't be told.
|
|
178
|
+
"""
|
|
179
|
+
actions: set[str] | None = set()
|
|
180
|
+
names: list[str] = []
|
|
181
|
+
for policy in as_list(policies):
|
|
182
|
+
found: set[str] | None = None
|
|
183
|
+
if isinstance(policy, str):
|
|
184
|
+
found = _managed(policy)
|
|
185
|
+
names.append(policy.rsplit("/", 1)[-1])
|
|
186
|
+
elif isinstance(policy, dict) and ("Statement" in policy or "PolicyDocument" in policy):
|
|
187
|
+
found = statement_actions(policy.get("PolicyDocument", policy))
|
|
188
|
+
names.append("an inline statement")
|
|
189
|
+
elif isinstance(policy, dict) and len(policy) == 1:
|
|
190
|
+
template = next(iter(policy))
|
|
191
|
+
found = POLICY_TEMPLATES.get(template)
|
|
192
|
+
names.append(template)
|
|
193
|
+
if found is None:
|
|
194
|
+
actions = None
|
|
195
|
+
elif actions is not None:
|
|
196
|
+
actions |= found
|
|
197
|
+
return actions, names
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _role_actions(role: dict[str, Any]) -> set[str] | None:
|
|
201
|
+
"""
|
|
202
|
+
What an AWS::IAM::Role in the template allows, from its inline policies and the managed ones it knows.
|
|
203
|
+
"""
|
|
204
|
+
props = role.get("Properties") or {}
|
|
205
|
+
actions: set[str] = set()
|
|
206
|
+
for policy in as_list(props.get("Policies")):
|
|
207
|
+
found = statement_actions((policy or {}).get("PolicyDocument")) if isinstance(policy, dict) else None
|
|
208
|
+
if found is None:
|
|
209
|
+
return None
|
|
210
|
+
actions |= found
|
|
211
|
+
for arn in as_list(props.get("ManagedPolicyArns")):
|
|
212
|
+
found = _managed(arn) if isinstance(arn, str) else None
|
|
213
|
+
if found is None:
|
|
214
|
+
return None
|
|
215
|
+
actions |= found
|
|
216
|
+
return actions
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _logical(ref: Any) -> str | None:
|
|
220
|
+
"""
|
|
221
|
+
The resource a !Ref or !GetAtt names: {'Fn::GetAtt': ['CartRole', 'Arn']} -> 'CartRole'.
|
|
222
|
+
"""
|
|
223
|
+
if isinstance(ref, dict) and "Ref" in ref and isinstance(ref["Ref"], str):
|
|
224
|
+
return ref["Ref"]
|
|
225
|
+
if isinstance(ref, dict) and "Fn::GetAtt" in ref:
|
|
226
|
+
value = ref["Fn::GetAtt"]
|
|
227
|
+
return value[0] if isinstance(value, list) and value and isinstance(value[0], str) else None
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class SamTemplate(Deploy):
|
|
232
|
+
"""
|
|
233
|
+
template.yaml (or .yml, or .json): SAM and plain CloudFormation.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
name = "sam"
|
|
237
|
+
title = "AWS SAM"
|
|
238
|
+
files = ("template.yaml", "template.yml", "template.json")
|
|
239
|
+
|
|
240
|
+
def deployments(self, files: dict[str, str], code: set[str]) -> list[Deployment]:
|
|
241
|
+
found: list[Deployment] = []
|
|
242
|
+
for path, text in files.items():
|
|
243
|
+
data = json.loads(text) if path.endswith(".json") else load_yaml(text)
|
|
244
|
+
if not isinstance(data, dict) or not isinstance(data.get("Resources"), dict):
|
|
245
|
+
continue
|
|
246
|
+
found += self._functions(path, text, data, code)
|
|
247
|
+
return found
|
|
248
|
+
|
|
249
|
+
def _functions(self, path: str, text: str, data: dict[str, Any], code: set[str]) -> list[Deployment]:
|
|
250
|
+
resources: dict[str, Any] = data["Resources"]
|
|
251
|
+
base = posixpath.dirname(path)
|
|
252
|
+
defaults = (data.get("Globals") or {}).get("Function") or {}
|
|
253
|
+
connected = any(
|
|
254
|
+
isinstance(r, dict) and r.get("Type") == "AWS::Serverless::Connector" for r in resources.values()
|
|
255
|
+
)
|
|
256
|
+
found = []
|
|
257
|
+
for logical_id, resource in resources.items():
|
|
258
|
+
if not isinstance(resource, dict):
|
|
259
|
+
continue
|
|
260
|
+
kind = resource.get("Type")
|
|
261
|
+
props = resource.get("Properties") or {}
|
|
262
|
+
if (
|
|
263
|
+
kind not in ("AWS::Serverless::Function", "AWS::Lambda::Function")
|
|
264
|
+
or props.get("PackageType") == "Image"
|
|
265
|
+
):
|
|
266
|
+
continue
|
|
267
|
+
merged = {**defaults, **props} if kind == "AWS::Serverless::Function" else dict(props)
|
|
268
|
+
runtime = merged.get("Runtime")
|
|
269
|
+
if isinstance(runtime, str) and not runtime.startswith("python"):
|
|
270
|
+
continue
|
|
271
|
+
code_dir = merged.get("CodeUri") if kind == "AWS::Serverless::Function" else merged.get("Code")
|
|
272
|
+
where = resolve_handler(
|
|
273
|
+
base, code_dir if isinstance(code_dir, str) else "", merged.get("Handler", ""), code
|
|
274
|
+
)
|
|
275
|
+
if where is None:
|
|
276
|
+
continue
|
|
277
|
+
env_names, env_error = set(), None
|
|
278
|
+
for source in ((defaults if kind == "AWS::Serverless::Function" else {}), props):
|
|
279
|
+
variables = ((source.get("Environment") or {}).get("Variables")) if isinstance(source, dict) else None
|
|
280
|
+
if isinstance(variables, dict):
|
|
281
|
+
env_names |= {str(name) for name in variables}
|
|
282
|
+
elif variables is not None:
|
|
283
|
+
env_error = "its Variables aren't written out"
|
|
284
|
+
actions, names = self._permissions(logical_id, props, kind, resources)
|
|
285
|
+
if connected: # a Connector grants what it joins, which this doesn't read
|
|
286
|
+
actions = None
|
|
287
|
+
layers = [str(_logical(layer) or layer) for layer in as_list(merged.get("Layers"))]
|
|
288
|
+
timeout = merged.get("Timeout", _PYTHON_DEFAULT_TIMEOUT)
|
|
289
|
+
memory = merged.get("MemorySize", 128)
|
|
290
|
+
found.append(
|
|
291
|
+
Deployment(
|
|
292
|
+
logical_id,
|
|
293
|
+
path,
|
|
294
|
+
self.name,
|
|
295
|
+
where[0],
|
|
296
|
+
where[1],
|
|
297
|
+
"lambda",
|
|
298
|
+
line_of(text, logical_id),
|
|
299
|
+
runtime if isinstance(runtime, str) else None,
|
|
300
|
+
timeout if isinstance(timeout, int) else None,
|
|
301
|
+
memory if isinstance(memory, int) else None,
|
|
302
|
+
env_names,
|
|
303
|
+
env_error,
|
|
304
|
+
layers,
|
|
305
|
+
actions,
|
|
306
|
+
names,
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
return found
|
|
310
|
+
|
|
311
|
+
def _permissions(
|
|
312
|
+
self, logical_id: str, props: dict[str, Any], kind: str, resources: dict[str, Any]
|
|
313
|
+
) -> tuple[set[str] | None, list[str]]:
|
|
314
|
+
"""
|
|
315
|
+
What a function's role allows: SAM's role from its Policies and event sources, or a role in the template,
|
|
316
|
+
plus any AWS::IAM::Policy attached to that role. None for the actions when it can't be told.
|
|
317
|
+
"""
|
|
318
|
+
role_id = None
|
|
319
|
+
names: list[str] = []
|
|
320
|
+
if "Role" in props:
|
|
321
|
+
role_id = _logical(props["Role"])
|
|
322
|
+
role = resources.get(role_id) if role_id else None
|
|
323
|
+
if not isinstance(role, dict) or role.get("Type") != "AWS::IAM::Role":
|
|
324
|
+
return None, []
|
|
325
|
+
actions = _role_actions(role)
|
|
326
|
+
names.append(role_id or "")
|
|
327
|
+
elif kind == "AWS::Serverless::Function":
|
|
328
|
+
actions, names = _sam_policies(props.get("Policies"))
|
|
329
|
+
role_id = f"{logical_id}Role"
|
|
330
|
+
if actions is not None:
|
|
331
|
+
actions |= MANAGED_POLICIES["AWSLambdaBasicExecutionRole"]
|
|
332
|
+
for event in (props.get("Events") or {}).values():
|
|
333
|
+
if isinstance(event, dict):
|
|
334
|
+
actions |= _EVENT_GRANTS.get(event.get("Type", ""), set())
|
|
335
|
+
else:
|
|
336
|
+
return None, []
|
|
337
|
+
for resource in resources.values():
|
|
338
|
+
if not isinstance(resource, dict) or resource.get("Type") not in (
|
|
339
|
+
"AWS::IAM::Policy",
|
|
340
|
+
"AWS::IAM::ManagedPolicy",
|
|
341
|
+
):
|
|
342
|
+
continue
|
|
343
|
+
policy = resource.get("Properties") or {}
|
|
344
|
+
if any(_logical(role) == role_id for role in as_list(policy.get("Roles"))):
|
|
345
|
+
more = statement_actions(policy.get("PolicyDocument"))
|
|
346
|
+
actions = None if more is None or actions is None else actions | more
|
|
347
|
+
return actions, names
|
|
348
|
+
|
|
349
|
+
def grant(self, deployment: Deployment, actions: list[str]) -> str:
|
|
350
|
+
"""
|
|
351
|
+
The smallest SAM policy template for each service's missing actions ('add DynamoDBWritePolicy and
|
|
352
|
+
SNSPublishMessagePolicy'), and a statement for what none covers.
|
|
353
|
+
"""
|
|
354
|
+
by_service: dict[str, list[str]] = {}
|
|
355
|
+
for action in actions:
|
|
356
|
+
by_service.setdefault(action.split(":")[0], []).append(action)
|
|
357
|
+
templates, rest = [], []
|
|
358
|
+
for wanted in by_service.values():
|
|
359
|
+
fits = [
|
|
360
|
+
(name, granted)
|
|
361
|
+
for name, granted in POLICY_TEMPLATES.items()
|
|
362
|
+
if all(allowed(action, granted) for action in wanted)
|
|
363
|
+
]
|
|
364
|
+
if fits:
|
|
365
|
+
templates.append(min(fits, key=lambda fit: (len(fit[1]), fit[0]))[0])
|
|
366
|
+
else:
|
|
367
|
+
rest += wanted
|
|
368
|
+
where = f"{deployment.name}'s Policies in {deployment.source}"
|
|
369
|
+
said = [" and ".join(templates)] if templates else []
|
|
370
|
+
said += [f"a statement that allows {', '.join(rest)}"] if rest else []
|
|
371
|
+
return f"add {' and '.join(said)} to {where}"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Serverless Framework's serverless.yml: each function on a Python runtime, with the provider's settings applied,
|
|
3
|
+
its environment variables (names only), and what its role's statements allow.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import posixpath
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..analysis.model import Deployment
|
|
12
|
+
from . import MANAGED_POLICIES, Deploy, as_list, line_of, load_yaml, resolve_handler, statement_actions
|
|
13
|
+
|
|
14
|
+
_DEFAULT_TIMEOUT = 6 # seconds: the Serverless Framework's default
|
|
15
|
+
|
|
16
|
+
_DEFAULT_MEMORY = 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _statements(provider: dict[str, Any]) -> tuple[Any, Any]:
|
|
20
|
+
"""
|
|
21
|
+
The provider's role statements and managed policies, from v3's iam.role or v2's iamRoleStatements. ('string',
|
|
22
|
+
None) when the role is one made elsewhere (an ARN or a name).
|
|
23
|
+
"""
|
|
24
|
+
iam = provider.get("iam") or {}
|
|
25
|
+
role = iam.get("role") if isinstance(iam, dict) else None
|
|
26
|
+
if isinstance(role, str):
|
|
27
|
+
return "elsewhere", None
|
|
28
|
+
if isinstance(role, dict):
|
|
29
|
+
return role.get("statements") or [], role.get("managedPolicies")
|
|
30
|
+
return provider.get("iamRoleStatements") or [], provider.get("iamManagedPolicies")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ServerlessFile(Deploy):
|
|
34
|
+
"""
|
|
35
|
+
serverless.yml (or .yaml) with provider.name aws.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
name = "serverless"
|
|
39
|
+
title = "Serverless Framework"
|
|
40
|
+
files = ("serverless.yml", "serverless.yaml")
|
|
41
|
+
|
|
42
|
+
def deployments(self, files: dict[str, str], code: set[str]) -> list[Deployment]:
|
|
43
|
+
found: list[Deployment] = []
|
|
44
|
+
for path, text in files.items():
|
|
45
|
+
data = load_yaml(text)
|
|
46
|
+
if not isinstance(data, dict) or not isinstance(data.get("provider"), dict):
|
|
47
|
+
continue
|
|
48
|
+
provider = data["provider"]
|
|
49
|
+
if provider.get("name", "aws") != "aws":
|
|
50
|
+
continue
|
|
51
|
+
functions = data.get("functions") or {}
|
|
52
|
+
if isinstance(functions, list): # an old file: a list of {name: function}
|
|
53
|
+
functions = {k: v for item in functions if isinstance(item, dict) for k, v in item.items()}
|
|
54
|
+
if not isinstance(functions, dict):
|
|
55
|
+
continue
|
|
56
|
+
statements, managed = _statements(provider)
|
|
57
|
+
for name, function in functions.items():
|
|
58
|
+
if not isinstance(function, dict) or "image" in function:
|
|
59
|
+
continue
|
|
60
|
+
runtime = function.get("runtime", provider.get("runtime"))
|
|
61
|
+
if isinstance(runtime, str) and not runtime.startswith("python"):
|
|
62
|
+
continue
|
|
63
|
+
where = resolve_handler(posixpath.dirname(path), "", function.get("handler", ""), code)
|
|
64
|
+
if where is None:
|
|
65
|
+
continue
|
|
66
|
+
env_names, env_error = set(), None
|
|
67
|
+
for source in (provider.get("environment"), function.get("environment")):
|
|
68
|
+
if isinstance(source, dict):
|
|
69
|
+
env_names |= {str(key) for key in source}
|
|
70
|
+
elif source is not None:
|
|
71
|
+
env_error = "its environment comes from a file or a variable"
|
|
72
|
+
actions = None
|
|
73
|
+
if statements != "elsewhere":
|
|
74
|
+
actions = statement_actions(as_list(statements) + as_list(function.get("iamRoleStatements")))
|
|
75
|
+
for policy in as_list(managed):
|
|
76
|
+
granted = (
|
|
77
|
+
MANAGED_POLICIES.get(str(policy).rsplit("/", 1)[-1]) if isinstance(policy, str) else None
|
|
78
|
+
)
|
|
79
|
+
actions = None if granted is None or actions is None else actions | granted
|
|
80
|
+
if actions is not None:
|
|
81
|
+
actions |= MANAGED_POLICIES["AWSLambdaBasicExecutionRole"]
|
|
82
|
+
for event in as_list(function.get("events")):
|
|
83
|
+
if isinstance(event, dict) and "sqs" in event:
|
|
84
|
+
actions |= MANAGED_POLICIES["AWSLambdaSQSQueueExecutionRole"]
|
|
85
|
+
if isinstance(event, dict) and "stream" in event:
|
|
86
|
+
actions |= (
|
|
87
|
+
MANAGED_POLICIES["AWSLambdaDynamoDBExecutionRole"]
|
|
88
|
+
| MANAGED_POLICIES["AWSLambdaKinesisExecutionRole"]
|
|
89
|
+
)
|
|
90
|
+
timeout = function.get("timeout", provider.get("timeout", _DEFAULT_TIMEOUT))
|
|
91
|
+
memory = function.get("memorySize", provider.get("memorySize", _DEFAULT_MEMORY))
|
|
92
|
+
layers = [str(layer) for layer in as_list(function.get("layers", provider.get("layers")))]
|
|
93
|
+
found.append(
|
|
94
|
+
Deployment(
|
|
95
|
+
str(name),
|
|
96
|
+
path,
|
|
97
|
+
self.name,
|
|
98
|
+
where[0],
|
|
99
|
+
where[1],
|
|
100
|
+
"lambda",
|
|
101
|
+
line_of(text, str(name), 2),
|
|
102
|
+
runtime if isinstance(runtime, str) else None,
|
|
103
|
+
timeout if isinstance(timeout, int) else None,
|
|
104
|
+
memory if isinstance(memory, int) else None,
|
|
105
|
+
env_names,
|
|
106
|
+
env_error,
|
|
107
|
+
layers,
|
|
108
|
+
actions,
|
|
109
|
+
["provider.iam.role.statements"],
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return found
|
|
113
|
+
|
|
114
|
+
def grant(self, deployment: Deployment, actions: list[str]) -> str:
|
|
115
|
+
return (
|
|
116
|
+
f"add a statement that allows {', '.join(actions)} to provider.iam.role.statements in {deployment.source}"
|
|
117
|
+
)
|
code_node/kit/blocks.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The blocks a report is made of, and what draws each kind of block.
|
|
3
|
+
|
|
4
|
+
A View command builds a list of blocks (a _Title, _Cards, a _Table, ...) and hands it to the renderer, which draws
|
|
5
|
+
each block by what's registered for its type: its HTML, or its lines of text. These are the blocks every service
|
|
6
|
+
uses (render.py draws them); a service's own blocks (_Passage, _Steps, _Wiring, ...) are defined in its module and
|
|
7
|
+
register how they're drawn with @_draws when it's imported.
|
|
8
|
+
|
|
9
|
+
The registries @_draws fills: _HTML (block type -> its HTML), _TEXT (block type -> its lines in text mode) and
|
|
10
|
+
_CELLS (table cell type -> its HTML, given the cell's text).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class _Title:
|
|
21
|
+
"""
|
|
22
|
+
The report's title, with a line under it (sub).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
text: str
|
|
26
|
+
sub: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class _Cards:
|
|
31
|
+
"""
|
|
32
|
+
The few numbers that matter, as cards. Each item is (label, value), or (label, value, tone) with tone 'warn',
|
|
33
|
+
'bad' or 'ok'.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
items: list[tuple[str, ...]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class _Table:
|
|
41
|
+
"""
|
|
42
|
+
A table of rows under its headers.
|
|
43
|
+
|
|
44
|
+
rows cells are text, or _Tone for a coloured status (or another type registered as a cell)
|
|
45
|
+
bars 0..1 per row, drawn as an extra column headed bar_label
|
|
46
|
+
tree the first column holds indented tree labels
|
|
47
|
+
max_rows None for the view's default, 0 for no cap
|
|
48
|
+
code_cols columns holding calls to copy, shown as code
|
|
49
|
+
prose_cols columns of sentences this tool wrote (findings): the calls in them shown as code
|
|
50
|
+
collapsed a secondary view: folded under its title in HTML
|
|
51
|
+
path_cols columns of keys and paths: the file name always shows, a long folder is shortened
|
|
52
|
+
sortable in HTML a click on a column's header sorts by it (tree tables never sort)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
headers: list[str]
|
|
56
|
+
rows: list[list[Any]]
|
|
57
|
+
title: str = ""
|
|
58
|
+
bars: list[float] | None = None
|
|
59
|
+
bar_label: str = "Share"
|
|
60
|
+
tree: bool = False
|
|
61
|
+
max_rows: int | None = None
|
|
62
|
+
code_cols: tuple[int, ...] = ()
|
|
63
|
+
prose_cols: tuple[int, ...] = ()
|
|
64
|
+
collapsed: bool = False
|
|
65
|
+
path_cols: tuple[int, ...] = ()
|
|
66
|
+
sortable: bool = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class _Note:
|
|
71
|
+
"""
|
|
72
|
+
A sentence on its own, at a level: 'info', 'warn' or 'ok'.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
text: str
|
|
76
|
+
level: str = "info"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class _Text:
|
|
81
|
+
"""
|
|
82
|
+
Text shown as it is.
|
|
83
|
+
|
|
84
|
+
wrap prose: wrap long lines instead of scrolling sideways
|
|
85
|
+
code a snippet to copy: in HTML one click selects all of it
|
|
86
|
+
collapsed a secondary view (raw JSON): folded under its title in HTML
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
text: str
|
|
90
|
+
title: str = ""
|
|
91
|
+
wrap: bool = False
|
|
92
|
+
code: bool = False
|
|
93
|
+
collapsed: bool = False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class _Findings:
|
|
98
|
+
"""
|
|
99
|
+
The (level, message) pairs from a *_findings function, and what to say (as an ok note) when there are none;
|
|
100
|
+
nothing when `empty` is blank.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
items: list[tuple[str, str]]
|
|
104
|
+
empty: str = ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class _Next:
|
|
109
|
+
"""
|
|
110
|
+
The commands worth running next, as (call, what it shows), with their arguments filled in.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
items: list[tuple[str, str]]
|
|
114
|
+
title: str = "Next"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class _Tone:
|
|
119
|
+
"""
|
|
120
|
+
A table cell with a status colour (tone: 'warn', 'bad' or 'ok'): a pill in HTML, plain text elsewhere.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
text: str
|
|
124
|
+
tone: str = "warn"
|
|
125
|
+
|
|
126
|
+
def __str__(self) -> str:
|
|
127
|
+
return self.text
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class _Report:
|
|
132
|
+
"""
|
|
133
|
+
One report being drawn: what each block's drawing function gets besides the block.
|
|
134
|
+
|
|
135
|
+
max_rows the view's cap on table rows; 0 for no cap
|
|
136
|
+
badge the chip before each title (HTML), so reports from different services are easy to tell apart
|
|
137
|
+
root the service's root class, which scopes the CSS rules
|
|
138
|
+
blocks the whole report, for a block that needs to know what else is in it
|
|
139
|
+
rules CSS rules the blocks need beyond the stylesheet (a table's filters and columns): a <style> after it
|
|
140
|
+
parts what blocks share while the report is drawn (see shared)
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
max_rows: int
|
|
144
|
+
badge: str = ""
|
|
145
|
+
root: str = ""
|
|
146
|
+
blocks: tuple[Any, ...] = ()
|
|
147
|
+
rules: set[str] = field(default_factory=set)
|
|
148
|
+
parts: dict[Any, Any] = field(default_factory=dict)
|
|
149
|
+
|
|
150
|
+
def shared(self, key: Any, make: Callable[[], Any]) -> Any:
|
|
151
|
+
"""
|
|
152
|
+
What the report's blocks share while it's drawn, made by make() the first time one asks for it: S3's PDF
|
|
153
|
+
pages share one full-size view, so its ‹ › step through every page in the report.
|
|
154
|
+
"""
|
|
155
|
+
if key not in self.parts:
|
|
156
|
+
self.parts[key] = make()
|
|
157
|
+
return self.parts[key]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_HTML: dict[type, Callable[[Any, _Report], str]] = {}
|
|
161
|
+
_TEXT: dict[type, Callable[[Any, _Report], list[str]]] = {}
|
|
162
|
+
_CELLS: dict[type, Callable[[Any, str], str]] = {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _draws(kind: type, mode: str) -> Callable[[Callable], Callable]:
|
|
166
|
+
"""
|
|
167
|
+
@_draws(_Passage, "html") makes the function under it the way a _Passage block is drawn in HTML:
|
|
168
|
+
f(block, report) -> str. "text" registers its lines in text mode, f(block, report) -> list[str], and "cell" how
|
|
169
|
+
a table cell of that type shows in HTML, f(cell, text) -> str (text mode shows str(cell)).
|
|
170
|
+
"""
|
|
171
|
+
registry: dict[type, Callable] = {"html": _HTML, "text": _TEXT, "cell": _CELLS}[mode]
|
|
172
|
+
|
|
173
|
+
def register(draw: Callable) -> Callable:
|
|
174
|
+
registry[kind] = draw
|
|
175
|
+
return draw
|
|
176
|
+
|
|
177
|
+
return register
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _drawer(registry: dict[type, Callable], value: Any) -> Callable | None:
|
|
181
|
+
"""
|
|
182
|
+
What draws value: the function registered for its type, or for the closest type it's made from.
|
|
183
|
+
"""
|
|
184
|
+
return next((registry[cls] for cls in type(value).__mro__ if cls in registry), None)
|