lambda-api-decorators-cdk 0.2.3__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.
@@ -0,0 +1,5 @@
1
+ from .api_type import ApiType
2
+ from .lambda_api import LambdaApi
3
+ from .lambda_api_config import LambdaApiConfig
4
+ from .resource_builder import ResourceBuilder
5
+ from .source_layout import SourceLayout
@@ -0,0 +1,8 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ApiType(Enum):
5
+ """The API Gateway family managed by :class:`LambdaApi`."""
6
+
7
+ REST = "rest"
8
+ HTTP = "http"
@@ -0,0 +1,361 @@
1
+ import ast
2
+ import os
3
+ from dataclasses import dataclass
4
+ from typing import Any, Tuple
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class DecoratorInvocation:
9
+ """A single decorator call captured without interpreting its purpose."""
10
+
11
+ name: str
12
+ args: Tuple[Any, ...]
13
+ kwargs: Tuple[Tuple[str, Any], ...]
14
+
15
+ class Method:
16
+ ALLOWED_METHODS = {'PUT', 'POST', 'GET', 'DELETE', 'ANY'}
17
+
18
+ def __init__(self, path_to_file:str, file: str, handler: str, method: str, decorators):
19
+ '''One http method is always associated with a file:handler entrypoint in a one-to-one relationship and each handler has decorators in a one-to-many relationship'''
20
+ if method not in self.ALLOWED_METHODS:
21
+ raise ValueError(f"Invalid method: {method}. Allowed methods are {', '.join(self.ALLOWED_METHODS)}")
22
+ self.method = method
23
+ self.file = file.replace(os.sep, '/')
24
+ self.path_to_file = path_to_file.replace(os.sep, '/')
25
+ self.handler = handler
26
+ if isinstance(decorators, dict):
27
+ decorators = tuple(
28
+ DecoratorInvocation(name, (value,), ())
29
+ for name, value in decorators.items()
30
+ )
31
+ self._decorator_invocations = tuple(decorators)
32
+
33
+ def __str__(self):
34
+ return (self.get_method(), self.get_file(), self.get_handler())
35
+
36
+ def get_method(self):
37
+ return self.method
38
+
39
+ def get_logical_id(self):
40
+ return self.get_file().replace('.','dot').replace('/','-') + '-' + self.get_handler()
41
+
42
+ def get_file(self):
43
+ return self.file
44
+
45
+ def get_handler(self):
46
+ return self.handler
47
+
48
+ def get_path_to_file(self):
49
+ return self.path_to_file
50
+
51
+ def get_method(self):
52
+ return self.method
53
+
54
+ def get_decorator_invocations(self):
55
+ return self._decorator_invocations
56
+
57
+ def __eq__(self, other):
58
+ try:
59
+ check = self.get_method() == other.get_method and self.get_handler() == other.get_handler()
60
+ if check:
61
+ raise ValueError(f'{self.file()} and {other.get_file()} define the same method and handler, implementation may vary')
62
+ return isinstance(other, Method) and check
63
+ except TypeError:
64
+ raise TypeError
65
+
66
+
67
+ class Resource:
68
+ def __init__(self, path = '/'):
69
+ self.path = path
70
+ self.methods = []
71
+ self.connections = []
72
+
73
+ def get_path(self) -> str:
74
+ return self.path
75
+
76
+ def get_methods(self) -> list[Method]:
77
+ return self.methods
78
+
79
+ def get_connections(self) -> list['Resource']:
80
+ return self.connections
81
+
82
+ def __eq__(self, other):
83
+ return isinstance(other, Resource) and self.get_path() == other.get_path()
84
+
85
+ def __str__(self):
86
+ methods_str = ' '
87
+ for method in self.get_methods():
88
+ methods_str = '(' + str(method.__str__()) + ') '
89
+ #methods_str = methods_str + '(' + method.get_method() + ' at ' + method.get_logical_id() + ') '
90
+ return self.get_path() + methods_str
91
+
92
+ def add_method(self, method: Method) -> bool:
93
+ '''Aggregate Method to present Resource's endpoint'''
94
+ if method not in self.methods:
95
+ self.methods.append(method)
96
+ return True
97
+ else:
98
+ return False
99
+
100
+ def add_methods(self, method: list[Method]) -> bool:
101
+ '''Aggregate Methods to present Resource's endpoint'''
102
+ for method in self.methods:
103
+ if method not in self.methods:
104
+ self.methods.append(method)
105
+ return True
106
+
107
+ def includes_path(self, sub_path) -> bool:
108
+ '''Will indicate if another's Resource path is a ramification of the present Resource'''
109
+ return self.path == '/' or sub_path == self.path or sub_path.startswith(self.path.rstrip('/') + '/')
110
+
111
+ def connect(self, resource: 'Resource') -> bool:
112
+ if self.includes_path(resource.get_path()) and resource not in self.get_connections():
113
+ self.connections.append(resource)
114
+ return True
115
+ return False
116
+
117
+ def get_matching_prefix_index(self, ext_path: str) -> int:
118
+ current_path = self.get_path()
119
+ matching_prefix_index = ext_path.index(current_path) + len(current_path) if current_path in ext_path else -1
120
+ return matching_prefix_index
121
+ #We should always have a longest prefix match index at 1 because '/'
122
+
123
+ def clone(self) -> 'Resource':
124
+ clone = Resource(self.get_path())
125
+ clone.connections = self.get_connections()
126
+ clone.methods = self.get_methods()
127
+ return clone
128
+
129
+ def switch_nodes(self, resource:'Resource'):
130
+ aux = self.clone()
131
+ self.methods = resource.get_methods()
132
+ self.connections = resource.get_connections()
133
+ self.path = resource.get_path()
134
+ resource.connect(aux)
135
+ return True
136
+
137
+ def insert_node(self, resource: 'Resource') -> bool:
138
+ resource_path = resource.get_path()
139
+
140
+ #Edge cases: Resource refers to same endpoint / Resource comes before / Resource goes deeper or next to current node as bifurcation
141
+ if resource_path == self.get_path(): #They have the same path, new resource comes from a different function/file so we merge
142
+ for method in resource.get_methods():
143
+ self.add_method(method)
144
+ for connection in resource.get_connections():
145
+ self.connect(connection)
146
+ return True
147
+
148
+ if len(resource_path) < len(self.get_path()) and resource.includes_path(self.get_path()): #Given resource comes before current, so we have to switch them
149
+ # aux = self.clone()
150
+ # self.methods = resource.get_methods()
151
+ # self.connections = resource.get_connections()
152
+ # self.path = resource.get_path()
153
+ # resource.connect(aux)
154
+ # return True
155
+ return self.switch_nodes(resource)
156
+
157
+ if len(resource_path) >= len(self.get_path()) and self.includes_path(resource_path): #Resource goes deeper or bifurcation
158
+ if len(self.get_connections()) < 1:
159
+ self.connect(resource)
160
+ return True
161
+ else:
162
+ matching_node = self
163
+ matching_prefix_index = self.get_matching_prefix_index(resource_path)
164
+ for node in self.get_connections(): # Check if it goes deeper or may come in between two nodes
165
+ if node.includes_path(resource_path): #deeper candidate
166
+ node_matching_index = node.get_matching_prefix_index(resource_path)
167
+ if node_matching_index > matching_prefix_index:
168
+ matching_prefix_index = node_matching_index
169
+ matching_node = node
170
+ elif resource.includes_path(node.get_path()): #It comes in between, so we have to switch them or guess if it goes deeper
171
+ return node.insert_node(resource)
172
+ if matching_node.includes_path(resource_path) and matching_node.get_path() != self.get_path(): #Goes deeper/recursion
173
+ return matching_node.insert_node(resource)
174
+ else: #Bifurcation
175
+ self.connect(resource)
176
+ return True
177
+ else: #We should never get to this case because the root path would be '/' so we always have a startswith match in 3rd case for bifurcation
178
+ self.connect(resource)
179
+ return True
180
+
181
+ def parse_file(file_path):
182
+ with open(file_path, 'r') as file:
183
+ source_code = file.read()
184
+ return ast.parse(source_code, filename=file_path)
185
+
186
+
187
+ def _literal_value(node, decorator_name):
188
+ try:
189
+ return ast.literal_eval(node)
190
+ except (ValueError, TypeError) as error:
191
+ raise ValueError(
192
+ f"{decorator_name} only supports literal decorator values"
193
+ ) from error
194
+
195
+
196
+ def _require_non_empty_string(value, decorator_name, field):
197
+ if not isinstance(value, str) or not value.strip():
198
+ raise ValueError(f"{decorator_name} {field} must be a non-empty string")
199
+
200
+
201
+ def _validate_grant(invocation, physical_field):
202
+ name = invocation.name
203
+ kwargs = dict(invocation.kwargs)
204
+ allowed_fields = {"resource_key", physical_field, "access"}
205
+ unexpected = next((key for key in kwargs if key not in allowed_fields), None)
206
+ if unexpected is not None:
207
+ raise ValueError(f"{name} received unexpected field {unexpected}")
208
+
209
+ if invocation.args:
210
+ if invocation.kwargs or len(invocation.args) != 2:
211
+ raise ValueError(
212
+ f"{name} positional form requires resource_key and access"
213
+ )
214
+ resource_key, access = invocation.args
215
+ _require_non_empty_string(resource_key, name, "resource_key")
216
+ else:
217
+ has_resource_key = "resource_key" in kwargs
218
+ has_physical_name = physical_field in kwargs
219
+ if has_resource_key == has_physical_name:
220
+ raise ValueError(
221
+ f"{name} requires exactly one of resource_key or {physical_field}"
222
+ )
223
+ address_field = "resource_key" if has_resource_key else physical_field
224
+ _require_non_empty_string(kwargs[address_field], name, address_field)
225
+ if "access" not in kwargs:
226
+ raise ValueError(f"{name} requires access")
227
+ access = kwargs["access"]
228
+
229
+ if access not in ("read", "write"):
230
+ raise ValueError(f"{name} access must be read or write, not {access!r}")
231
+
232
+
233
+ def _validate_permission(invocation):
234
+ if invocation.args:
235
+ raise ValueError("permission only accepts keyword arguments")
236
+ kwargs = dict(invocation.kwargs)
237
+ for key in kwargs:
238
+ if key not in ("actions", "resources"):
239
+ raise ValueError(f"permission received unexpected field {key}")
240
+ for field in ("actions", "resources"):
241
+ if field not in kwargs:
242
+ raise ValueError(f"permission requires {field}")
243
+ values = kwargs[field]
244
+ if not isinstance(values, (list, tuple)) or not values:
245
+ raise ValueError(f"permission {field} must be a non-empty sequence")
246
+ if any(not isinstance(value, str) or not value.strip() for value in values):
247
+ raise ValueError(
248
+ f"permission {field} must contain only non-empty strings"
249
+ )
250
+
251
+ normalized = tuple(
252
+ (key, tuple(value) if key in ("actions", "resources") else value)
253
+ for key, value in invocation.kwargs
254
+ )
255
+ return DecoratorInvocation(invocation.name, invocation.args, normalized)
256
+
257
+
258
+ def _parse_invocation(decorator):
259
+ if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Name):
260
+ return None
261
+ name = decorator.func.id
262
+ if any(keyword.arg is None for keyword in decorator.keywords):
263
+ raise ValueError(f"{name} does not support expanded keyword arguments")
264
+ invocation = DecoratorInvocation(
265
+ name=name,
266
+ args=tuple(_literal_value(arg, name) for arg in decorator.args),
267
+ kwargs=tuple(
268
+ (keyword.arg, _literal_value(keyword.value, name))
269
+ for keyword in decorator.keywords
270
+ ),
271
+ )
272
+ if name == "grant_dynamodb":
273
+ _validate_grant(invocation, "table_name")
274
+ elif name == "grant_s3":
275
+ _validate_grant(invocation, "bucket_name")
276
+ elif name == "permission":
277
+ invocation = _validate_permission(invocation)
278
+ return invocation
279
+
280
+
281
+ def get_file_nodes(parsed_tree, id, directory):
282
+ node_list = []
283
+ for node in parsed_tree.body:
284
+ if isinstance(node, ast.FunctionDef):
285
+ is_lambda_http = False
286
+ decorator_invocations = []
287
+ func_name = node.name
288
+ paths = [] #A handler could have multiple paths with multiple HTTP methods
289
+ for decorator in node.decorator_list:
290
+ invocation = _parse_invocation(decorator)
291
+ if invocation is None:
292
+ continue
293
+ decorator_invocations.append(invocation)
294
+ if invocation.name in Method.ALLOWED_METHODS:
295
+ if len(invocation.args) != 1 or invocation.kwargs:
296
+ raise ValueError(
297
+ f"{invocation.name} requires one positional path"
298
+ )
299
+ path = invocation.args[0]
300
+ _require_non_empty_string(path, invocation.name, "path")
301
+ is_lambda_http = True
302
+ paths.append((path, invocation.name))
303
+
304
+ #ast.FunctionDef ends. If the Function had an HTTP Decorator it means it's a lambda function
305
+ if is_lambda_http:
306
+ for key,value in paths:
307
+ #If id (file) is at the root of directory, no change needed. Else we need to only get the file
308
+ filepath = id[id.rindex(os.sep)+1:] if id.count(os.sep) > 0 else id
309
+ #Concatenate directory to id (file) for lambda entry point / separate "index" file from path
310
+ full_path = os.path.join(directory,id)
311
+
312
+ method = Method(path_to_file=full_path[:full_path.rindex(os.sep)], file= filepath, handler=func_name, method=value, decorators=decorator_invocations)
313
+ node_exists = False
314
+ if len(node_list) > 0:
315
+ for node in node_list:
316
+ if node.get_path() == key:
317
+ node.add_method(method)
318
+ node_exists = True
319
+ if not node_exists:
320
+ new_resource = Resource(path=key)
321
+ new_resource.add_method(method)
322
+ node_list.append(new_resource)
323
+
324
+ return node_list
325
+
326
+ def dump_tree(node, level=0):
327
+ if node is None:
328
+ return
329
+
330
+ # Print current node with indentation
331
+ print(" " * level, node)
332
+
333
+ # Recursively print connections
334
+ for child in node.get_connections():
335
+ dump_tree(child, level + 1)
336
+
337
+ def has_decorators(parsed_tree):
338
+ for node in ast.walk(parsed_tree):
339
+ if isinstance(node, ast.FunctionDef) and node.decorator_list: #Es necesario fijarme si tiene si o si un decorador de tipo http? o con que tenga alcanza
340
+ return True
341
+ return False
342
+
343
+
344
+ def get_lambda_graph(directory):
345
+ python_files = []
346
+ graph = Resource('/')
347
+ for root, dirs, files in os.walk(directory):
348
+ for file in files:
349
+ if file.endswith(".py"):
350
+ python_files.append(os.path.join(root, file))
351
+
352
+ for file in python_files:
353
+ parsed_tree = parse_file(file)
354
+ file_id = file[len(directory)+len(os.sep):]
355
+ if has_decorators(parsed_tree):
356
+ new_nodes = get_file_nodes(parsed_tree, file_id, directory)
357
+ for node in new_nodes:
358
+ graph.insert_node(node)
359
+ else:
360
+ print("Skipped " + file + ' due to it not having decorators')
361
+ return graph
@@ -0,0 +1,85 @@
1
+ from typing import Optional, Union
2
+
3
+ from aws_cdk import aws_apigateway as apigateway
4
+ from aws_cdk import aws_apigatewayv2 as apigatewayv2
5
+ from constructs import Construct
6
+
7
+ from .api_type import ApiType
8
+ from .lambda_api_config import LambdaApiConfig
9
+ from .source_layout import SourceLayout
10
+
11
+
12
+ class LambdaApi(Construct):
13
+ """Build decorated Lambda handlers behind an API Gateway."""
14
+
15
+ def __init__(
16
+ self,
17
+ scope: Construct,
18
+ construct_id: str,
19
+ *,
20
+ lambda_path: str,
21
+ source_layout: SourceLayout = SourceLayout.ROOT,
22
+ layers_path: Optional[str] = None,
23
+ api: Optional[
24
+ Union[apigateway.IRestApi, apigatewayv2.HttpApi]
25
+ ] = None,
26
+ api_type: Optional[ApiType] = None,
27
+ config: Optional[LambdaApiConfig] = None,
28
+ ) -> None:
29
+ super().__init__(scope, construct_id)
30
+
31
+ if not isinstance(source_layout, SourceLayout):
32
+ raise TypeError("source_layout must be a SourceLayout")
33
+ if config is not None and not isinstance(config, LambdaApiConfig):
34
+ raise TypeError("config must be a LambdaApiConfig or None")
35
+ if api_type is not None and not isinstance(api_type, ApiType):
36
+ raise TypeError("api_type must be an ApiType or None")
37
+
38
+ inferred_type = self._infer_api_type(api) if api is not None else None
39
+ if api_type is not None and inferred_type is not None and api_type is not inferred_type:
40
+ raise ValueError(
41
+ f"api_type {api_type!r} conflicts with supplied {inferred_type.value} API"
42
+ )
43
+
44
+ resolved_type = inferred_type or api_type or ApiType.REST
45
+ resolved_config = config if config is not None else LambdaApiConfig()
46
+
47
+ if api is None:
48
+ if resolved_type is ApiType.REST:
49
+ api = apigateway.RestApi(self, "RestApi")
50
+ else:
51
+ api = apigatewayv2.HttpApi(self, "HttpApi")
52
+
53
+ self._api = api
54
+ self._api_type = resolved_type
55
+ resource_builder = resolved_config._create_resource_builder()
56
+
57
+ if resolved_type is ApiType.REST:
58
+ resource_builder.build(
59
+ self, api.root, lambda_path, source_layout=source_layout,
60
+ layers_path=layers_path)
61
+ else:
62
+ resource_builder.build_http(
63
+ self, api, lambda_path, source_layout=source_layout,
64
+ layers_path=layers_path)
65
+
66
+ @staticmethod
67
+ def _infer_api_type(api: object) -> ApiType:
68
+ # IRestApi is a non-runtime-checkable Protocol in CDK Python. Both
69
+ # created and imported REST APIs derive from its concrete RestApiBase.
70
+ if isinstance(api, apigateway.RestApiBase):
71
+ return ApiType.REST
72
+ if isinstance(api, apigatewayv2.HttpApi):
73
+ return ApiType.HTTP
74
+ raise TypeError(
75
+ "api must be an apigateway IRestApi implementation or "
76
+ "apigatewayv2.HttpApi"
77
+ )
78
+
79
+ @property
80
+ def api(self) -> Union[apigateway.IRestApi, apigatewayv2.HttpApi]:
81
+ return self._api
82
+
83
+ @property
84
+ def api_type(self) -> ApiType:
85
+ return self._api_type
@@ -0,0 +1,151 @@
1
+ from typing import Mapping, Optional, Sequence
2
+
3
+ from aws_cdk import Duration
4
+ from aws_cdk import aws_ec2 as ec2
5
+ from aws_cdk import aws_dynamodb as dynamodb
6
+ from aws_cdk import aws_iam as iam
7
+ from aws_cdk import aws_lambda as lambda_
8
+ from aws_cdk import aws_s3 as s3
9
+
10
+ from .resource_builder import ResourceBuilder
11
+
12
+
13
+ class LambdaApiConfig:
14
+ """Reusable defaults and named resources for a :class:`LambdaApi` build."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ runtime: Optional[lambda_.Runtime] = None,
20
+ timeout: Optional[Duration] = None,
21
+ memory_size: Optional[int] = None,
22
+ vpc: Optional[ec2.IVpc] = None,
23
+ vpc_subnets: Optional[ec2.SubnetSelection] = None,
24
+ role: Optional[iam.IRole] = None,
25
+ layers: Optional[Sequence[lambda_.ILayerVersion]] = None,
26
+ security_groups: Optional[Sequence[ec2.ISecurityGroup]] = None,
27
+ environment: Optional[Mapping[str, str]] = None,
28
+ dynamodb_tables: Optional[Mapping[str, dynamodb.ITable]] = None,
29
+ s3_buckets: Optional[Mapping[str, s3.IBucket]] = None,
30
+ ) -> None:
31
+ self._default_runtime = runtime
32
+ self._default_timeout = timeout
33
+ self._default_memory_size = memory_size
34
+ self._default_vpc = (vpc, vpc_subnets) if vpc is not None else None
35
+ self._default_role = role
36
+
37
+ self._common_layers = list(layers) if layers is not None else []
38
+ self._common_security_groups = (
39
+ list(security_groups) if security_groups is not None else []
40
+ )
41
+ self._common_environments = dict(environment) if environment is not None else {}
42
+
43
+ self._custom_runtimes = {}
44
+ self._custom_roles = {}
45
+ self._custom_layers = {}
46
+ self._custom_environments = {}
47
+ self._custom_security_groups = {}
48
+ self._custom_vpcs = {}
49
+
50
+ self._dynamodb_tables = {}
51
+ self._s3_buckets = {}
52
+ for key, table in (dynamodb_tables or {}).items():
53
+ self.add_dynamodb_table(key, table)
54
+ for key, bucket in (s3_buckets or {}).items():
55
+ self.add_s3_bucket(key, bucket)
56
+
57
+ def set_default_runtime(self, runtime: Optional[lambda_.Runtime]) -> None:
58
+ self._default_runtime = runtime
59
+
60
+ def set_default_timeout(self, timeout: Optional[Duration]) -> None:
61
+ self._default_timeout = timeout
62
+
63
+ def set_default_memory_size(self, memory_size: Optional[int]) -> None:
64
+ self._default_memory_size = memory_size
65
+
66
+ def set_default_vpc(
67
+ self,
68
+ vpc: Optional[ec2.IVpc],
69
+ vpc_subnets: Optional[ec2.SubnetSelection] = None,
70
+ ) -> None:
71
+ self._default_vpc = (vpc, vpc_subnets) if vpc is not None else None
72
+
73
+ def set_default_role(self, role: Optional[iam.IRole]) -> None:
74
+ self._default_role = role
75
+
76
+ def add_common_layer(self, layer: lambda_.ILayerVersion) -> None:
77
+ if layer not in self._common_layers:
78
+ self._common_layers.append(layer)
79
+
80
+ def add_common_security_group(
81
+ self, security_group: ec2.ISecurityGroup
82
+ ) -> None:
83
+ if security_group not in self._common_security_groups:
84
+ self._common_security_groups.append(security_group)
85
+
86
+ def add_common_environment(self, key: str, value: str) -> None:
87
+ self._common_environments[key] = value
88
+
89
+ def add_custom_runtime(self, key: str, runtime: lambda_.Runtime) -> None:
90
+ self._custom_runtimes[key] = runtime
91
+
92
+ def add_custom_role(self, key: str, role: iam.IRole) -> None:
93
+ self._custom_roles[key] = role
94
+
95
+ def add_custom_layer(self, key: str, layer: lambda_.ILayerVersion) -> None:
96
+ self._custom_layers[key] = layer
97
+
98
+ def add_custom_environment(self, key: str, value: str) -> None:
99
+ self._custom_environments[key] = value
100
+
101
+ def add_custom_security_group(
102
+ self, key: str, security_group: ec2.ISecurityGroup
103
+ ) -> None:
104
+ self._custom_security_groups[key] = security_group
105
+
106
+ def add_custom_vpc(
107
+ self,
108
+ key: str,
109
+ vpc: ec2.IVpc,
110
+ vpc_subnets: Optional[ec2.SubnetSelection] = None,
111
+ ) -> None:
112
+ self._custom_vpcs[key] = (vpc, vpc_subnets)
113
+
114
+ def add_dynamodb_table(self, key: str, table: dynamodb.ITable) -> None:
115
+ self._add_resource(key, table, self._dynamodb_tables, "DynamoDB table")
116
+
117
+ def add_s3_bucket(self, key: str, bucket: s3.IBucket) -> None:
118
+ self._add_resource(key, bucket, self._s3_buckets, "S3 bucket")
119
+
120
+ @staticmethod
121
+ def _add_resource(key: str, resource, registry: dict, resource_type: str) -> None:
122
+ if not isinstance(key, str):
123
+ raise TypeError("Resource registry keys must be strings")
124
+ if not key.strip():
125
+ raise ValueError("Resource registry keys must not be empty or whitespace")
126
+ if resource is None or isinstance(resource, (str, int)):
127
+ raise TypeError(f"{resource_type} must be a CDK resource object")
128
+ if key in registry:
129
+ raise ValueError(f"Resource key {key!r} is already registered")
130
+ registry[key] = resource
131
+
132
+ def _create_resource_builder(self) -> ResourceBuilder:
133
+ """Create an isolated builder snapshot without copying CDK resources."""
134
+ return ResourceBuilder(
135
+ default_runtime=self._default_runtime,
136
+ default_timeout=self._default_timeout,
137
+ default_memory_size=self._default_memory_size,
138
+ default_vpc=self._default_vpc,
139
+ default_role=self._default_role,
140
+ common_layers=list(self._common_layers),
141
+ common_security_groups=list(self._common_security_groups),
142
+ common_environments=dict(self._common_environments),
143
+ custom_runtimes=dict(self._custom_runtimes),
144
+ custom_roles=dict(self._custom_roles),
145
+ custom_layers=dict(self._custom_layers),
146
+ custom_environments=dict(self._custom_environments),
147
+ custom_security_groups=dict(self._custom_security_groups),
148
+ custom_vpcs=dict(self._custom_vpcs),
149
+ dynamodb_tables=dict(self._dynamodb_tables),
150
+ s3_buckets=dict(self._s3_buckets),
151
+ )