async-lambda-unstable 0.6.12__tar.gz → 0.6.13__tar.gz

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 (30) hide show
  1. async_lambda_unstable-0.6.12/README.md → async_lambda_unstable-0.6.13/PKG-INFO +31 -0
  2. async_lambda_unstable-0.6.12/PKG-INFO → async_lambda_unstable-0.6.13/README.md +19 -12
  3. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/__init__.py +2 -1
  4. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/config.py +18 -0
  5. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/controller.py +166 -31
  6. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/task.py +25 -14
  7. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/.gitignore +0 -0
  8. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/build_config.py +0 -0
  9. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/cli.py +0 -0
  10. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/client.py +0 -0
  11. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/defer.py +0 -0
  12. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/env.py +0 -0
  13. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/middleware.py +0 -0
  14. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/__init__.py +0 -0
  15. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/api_response.py +0 -0
  16. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/case_insensitive_dict.py +0 -0
  17. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/events/__init__.py +0 -0
  18. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/events/api_event.py +0 -0
  19. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/events/base_event.py +0 -0
  20. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/events/dynamodb_event.py +0 -0
  21. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/events/managed_sqs_batch_event.py +0 -0
  22. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/events/managed_sqs_event.py +0 -0
  23. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/events/scheduled_event.py +0 -0
  24. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/events/unmanaged_sqs_event.py +0 -0
  25. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/mock/mock_context.py +0 -0
  26. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/models/mock/mock_event.py +0 -0
  27. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/payload_encoder.py +0 -0
  28. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/py.typed +0 -0
  29. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/async_lambda/util.py +0 -0
  30. {async_lambda_unstable-0.6.12 → async_lambda_unstable-0.6.13}/pyproject.toml +0 -0
@@ -1,3 +1,15 @@
1
+ Metadata-Version: 2.5
2
+ Name: async-lambda-unstable
3
+ Version: 0.6.13
4
+ Summary: A framework for creating AWS Lambda Async Workflows. - Unstable Branch
5
+ Author-email: "Nuclei, Inc" <engineering@nuclei.ai>
6
+ Requires-Dist: click>=8.0.0
7
+ Requires-Dist: typing-extensions>=4.0.0
8
+ Provides-Extra: local
9
+ Requires-Dist: boto3; extra == 'local'
10
+ Requires-Dist: flask<3.0.0,>=2.3.0; extra == 'local'
11
+ Description-Content-Type: text/markdown
12
+
1
13
  # async-lambda
2
14
 
3
15
  Async-lambda is a Python framework for building scalable, event-driven AWS Lambda applications with first-class support for asynchronous invocation via SQS queues. It provides a high-level abstraction for orchestrating Lambda functions, managing event triggers, and handling complex workflows with minimal boilerplate.
@@ -406,6 +418,25 @@ def task(event: ManagedSQSEvent):
406
418
  ...
407
419
  ```
408
420
 
421
+ ### Shared Function Role
422
+
423
+ By default each task declares `Policies`, so SAM generates one `AWS::IAM::Role` per task.
424
+
425
+ `config_set_shared_function_role()` emits a single `AsyncLambdaSharedFunctionRole` and points every task's `Role` at it instead:
426
+
427
+ ```python
428
+ from async_lambda import config_set_shared_function_role
429
+
430
+ config_set_shared_function_role()
431
+ ```
432
+
433
+ The shared role carries everything the per-task roles carried, with one difference: SQS consume access is granted on the app's queue-name prefix (`{name}-*`) rather than each task's own queues. Unmanaged SQS queues are still listed individually, since they're named by their producer.
434
+
435
+ Two caveats:
436
+
437
+ - A task with task-specific `policies` keeps its own generated role, since the shared role is built from app-wide and stage-wide config only.
438
+ - Every app-wide policy must be either a managed policy ARN or a `{"Statement": [...]}` document. SAM policy templates can't be expressed in a plain IAM role and will fail the build.
439
+
409
440
  ## Known Limitations
410
441
 
411
442
  - Not all Lambda configuration options are supported (see code for extension points)
@@ -1,15 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: async-lambda-unstable
3
- Version: 0.6.12
4
- Summary: A framework for creating AWS Lambda Async Workflows. - Unstable Branch
5
- Author-email: "Nuclei, Inc" <engineering@nuclei.ai>
6
- Requires-Dist: click>=8.0.0
7
- Requires-Dist: typing-extensions>=4.0.0
8
- Provides-Extra: local
9
- Requires-Dist: boto3; extra == 'local'
10
- Requires-Dist: flask<3.0.0,>=2.3.0; extra == 'local'
11
- Description-Content-Type: text/markdown
12
-
13
1
  # async-lambda
14
2
 
15
3
  Async-lambda is a Python framework for building scalable, event-driven AWS Lambda applications with first-class support for asynchronous invocation via SQS queues. It provides a high-level abstraction for orchestrating Lambda functions, managing event triggers, and handling complex workflows with minimal boilerplate.
@@ -418,6 +406,25 @@ def task(event: ManagedSQSEvent):
418
406
  ...
419
407
  ```
420
408
 
409
+ ### Shared Function Role
410
+
411
+ By default each task declares `Policies`, so SAM generates one `AWS::IAM::Role` per task.
412
+
413
+ `config_set_shared_function_role()` emits a single `AsyncLambdaSharedFunctionRole` and points every task's `Role` at it instead:
414
+
415
+ ```python
416
+ from async_lambda import config_set_shared_function_role
417
+
418
+ config_set_shared_function_role()
419
+ ```
420
+
421
+ The shared role carries everything the per-task roles carried, with one difference: SQS consume access is granted on the app's queue-name prefix (`{name}-*`) rather than each task's own queues. Unmanaged SQS queues are still listed individually, since they're named by their producer.
422
+
423
+ Two caveats:
424
+
425
+ - A task with task-specific `policies` keeps its own generated role, since the shared role is built from app-wide and stage-wide config only.
426
+ - Every app-wide policy must be either a managed policy ARN or a `{"Statement": [...]}` document. SAM policy templates can't be expressed in a plain IAM role and will fail the build.
427
+
421
428
  ## Known Limitations
422
429
 
423
430
  - Not all Lambda configuration options are supported (see code for extension points)
@@ -2,6 +2,7 @@ from .config import config_set_default_task_memory as config_set_default_task_me
2
2
  from .config import config_set_name as config_set_name
3
3
  from .config import config_set_runtime as config_set_runtime
4
4
  from .config import config_set_s3_payload_retention as config_set_s3_payload_retention
5
+ from .config import config_set_shared_function_role as config_set_shared_function_role
5
6
  from .controller import AsyncLambdaController as AsyncLambdaController
6
7
  from .controller import BatchInvokeException as BatchInvokeException
7
8
  from .defer import Defer as Defer
@@ -21,4 +22,4 @@ from .models.events.managed_sqs_event import ManagedSQSEvent as ManagedSQSEvent
21
22
  from .models.events.scheduled_event import ScheduledEvent as ScheduledEvent
22
23
  from .models.events.unmanaged_sqs_event import UnmanagedSQSEvent as UnmanagedSQSEvent
23
24
 
24
- __version__ = "0.6.12"
25
+ __version__ = "0.6.13"
@@ -11,6 +11,7 @@ class AsyncLambdaConfig:
11
11
  s3_payload_retention (int): Days to retain payloads in S3. Defaults to 30.
12
12
  default_task_memory (int): Default memory allocation (MB) for tasks. Defaults to 128.
13
13
  default_task_ephemeral_storage (int): Default disk allocation (MB) for tasks. Defaults to 512.
14
+ shared_function_role (bool): Whether tasks share a single execution role. Defaults to False.
14
15
  """
15
16
 
16
17
  name: str = "async-lambda"
@@ -18,6 +19,7 @@ class AsyncLambdaConfig:
18
19
  s3_payload_retention: Optional[int] = 180
19
20
  default_task_memory: int = 128
20
21
  default_task_ephemeral_storage: int = 512
22
+ shared_function_role: bool = False
21
23
 
22
24
 
23
25
  config = AsyncLambdaConfig()
@@ -71,3 +73,19 @@ def config_set_default_task_ephemeral_storage(ephemeral_storage: int = 512):
71
73
  ephemeral_storage (int, optional): Disk storage in megabytes to set as default for tasks. Defaults to 512.
72
74
  """
73
75
  config.default_task_ephemeral_storage = ephemeral_storage
76
+
77
+
78
+ def config_set_shared_function_role(enabled: bool = True):
79
+ """
80
+ Use a single execution role for every task instead of one role per task.
81
+
82
+ SAM generates an IAM role per function when a function declares `Policies`. Sharing one
83
+ role trades per-task SQS scoping (the shared role can consume any of the app's own queues)
84
+ for one CloudFormation resource instead of one per task.
85
+
86
+ Tasks with task-specific `policies` keep their own role.
87
+
88
+ Args:
89
+ enabled (bool, optional): Whether to share a single execution role. Defaults to True.
90
+ """
91
+ config.shared_function_role = enabled
@@ -20,7 +20,7 @@ from typing import (
20
20
  from uuid import uuid4
21
21
 
22
22
  from . import env
23
- from .build_config import get_build_config_for_stage
23
+ from .build_config import AsyncLambdaBuildConfig, get_build_config_for_stage
24
24
  from .client import get_s3_client, get_scheduler_client, get_sqs_client
25
25
  from .config import config
26
26
  from .middleware import MET, RT, MiddlewareFunction, MiddlewareRegistration
@@ -33,7 +33,12 @@ from .models.events.scheduled_event import ScheduledEvent
33
33
  from .models.events.unmanaged_sqs_event import UnmanagedSQSEvent
34
34
  from .models.mock.mock_context import MockLambdaContext
35
35
  from .models.mock.mock_event import MockSQSLambdaEvent
36
- from .models.task import MANAGED_SQS_TASK_TYPES, AsyncLambdaTask, TaskTriggerType
36
+ from .models.task import (
37
+ MANAGED_SQS_TASK_TYPES,
38
+ SHARED_FUNCTION_ROLE_LOGICAL_ID,
39
+ AsyncLambdaTask,
40
+ TaskTriggerType,
41
+ )
37
42
  from .payload_encoder import PayloadEncoder
38
43
  from .util import make_cf_tags
39
44
 
@@ -180,32 +185,26 @@ class AsyncLambdaController:
180
185
  self.delete_s3_payloads = delete_s3_payloads
181
186
  self.controller_name = controller_name
182
187
 
183
- # itertools.batched is much cleaner but only available in python 3.12+
184
- @staticmethod
185
- def _batched(items: Sequence[dict], batch_size: int) -> List[List[dict]]:
186
- if batch_size <= 0:
187
- raise ValueError("batch_size must be greater than 0")
188
- return [
189
- list(items[i : i + batch_size]) for i in range(0, len(items), batch_size)
190
- ]
191
-
192
188
  @classmethod
193
189
  def _build_send_to_all_async_lambda_queues_policies(
194
190
  cls,
195
- managed_tasks_resources: List[dict],
196
- policy_batch_size: int = 30,
191
+ queue_resources: List[dict],
197
192
  ) -> Dict[str, dict]:
198
- task_ref_policies: Dict[str, dict] = {}
199
- for chunk_index, resource_chunk in enumerate(
200
- cls._batched(items=managed_tasks_resources, batch_size=policy_batch_size),
201
- start=0,
202
- ):
203
- policy_id = f"SendToAllAsyncLambdaQueuesPolicy{chunk_index}"
204
- task_ref_policies[policy_id] = {
193
+ """
194
+ Builds the managed policy granting send access to every managed queue in this app.
195
+
196
+ This app's own queues are covered by a single name-prefix wildcard rather than an
197
+ enumeration, so the policy stays one resource and one document regardless of task
198
+ count. External tasks don't share the prefix, so their ARNs are listed explicitly.
199
+ """
200
+ if len(queue_resources) == 0:
201
+ return {}
202
+ return {
203
+ "SendToAllAsyncLambdaQueuesPolicy": {
205
204
  "Type": "AWS::IAM::ManagedPolicy",
206
205
  "Properties": {
207
206
  "ManagedPolicyName": {
208
- "Fn::Sub": f"${{AWS::StackName}}-send-to-all-queues-{chunk_index}"
207
+ "Fn::Sub": "${AWS::StackName}-send-to-all-queues"
209
208
  },
210
209
  "PolicyDocument": {
211
210
  "Version": "2012-10-17",
@@ -214,13 +213,142 @@ class AsyncLambdaController:
214
213
  "Sid": "SendToAllQueues",
215
214
  "Effect": "Allow",
216
215
  "Action": ["sqs:SendMessage"],
217
- "Resource": resource_chunk,
216
+ "Resource": queue_resources,
218
217
  },
219
218
  ],
220
219
  },
221
220
  },
222
221
  }
223
- return task_ref_policies
222
+ }
223
+
224
+ @staticmethod
225
+ def _managed_queue_arn_wildcard() -> dict:
226
+ """
227
+ An ARN pattern matching every managed queue this app creates.
228
+ """
229
+ return {
230
+ "Fn::Sub": "arn:aws:sqs:${AWS::Region}:${AWS::AccountId}:"
231
+ + f"{config.name}-*"
232
+ }
233
+
234
+ @classmethod
235
+ def _build_shared_function_role(
236
+ cls,
237
+ task_list: List[AsyncLambdaTask],
238
+ build_config: AsyncLambdaBuildConfig,
239
+ task_ref_policy_ids: List[str],
240
+ ) -> dict:
241
+ """
242
+ Builds a single execution role shared by every task.
243
+ """
244
+ sqs_resources: List[Union[str, dict]] = []
245
+ if any(task.trigger_type in MANAGED_SQS_TASK_TYPES for task in task_list):
246
+ sqs_resources.append(cls._managed_queue_arn_wildcard())
247
+ # Unmanaged queues are named by whoever produces to them, so they fall outside the
248
+ # app-name prefix and have to be listed.
249
+ sqs_resources.extend(
250
+ resource
251
+ for task in task_list
252
+ if task.trigger_type == TaskTriggerType.UNMANAGED_SQS
253
+ for resource in task.get_policy_sqs_resources()
254
+ )
255
+
256
+ statements: List[dict] = [
257
+ {
258
+ "Effect": "Allow",
259
+ "Action": ["s3:DeleteObject", "s3:PutObject", "s3:GetObject"],
260
+ "Resource": {
261
+ "Fn::Join": [
262
+ "",
263
+ [
264
+ "arn:aws:s3:::",
265
+ {"Ref": "AsyncLambdaPayloadBucket"},
266
+ "/*",
267
+ ],
268
+ ]
269
+ },
270
+ },
271
+ ]
272
+ if len(sqs_resources) > 0:
273
+ statements.append(
274
+ {
275
+ "Effect": "Allow",
276
+ "Action": [
277
+ "sqs:ChangeMessageVisibility",
278
+ "sqs:DeleteMessage",
279
+ "sqs:GetQueueAttributes",
280
+ "sqs:GetQueueUrl",
281
+ "sqs:ReceiveMessage",
282
+ ],
283
+ "Resource": sqs_resources,
284
+ }
285
+ )
286
+
287
+ inline_policies: List[dict] = [
288
+ {
289
+ "PolicyName": "AsyncLambdaTaskPolicy",
290
+ "PolicyDocument": {
291
+ "Version": "2012-10-17",
292
+ "Statement": statements,
293
+ },
294
+ }
295
+ ]
296
+ managed_policy_arns: List[Union[str, dict]] = [
297
+ "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
298
+ ]
299
+ if "VpcConfig" in build_config.function_properties:
300
+ managed_policy_arns.append(
301
+ "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
302
+ )
303
+ # SAM attaches this to any lambda function with an SQS event source. Without it, they
304
+ # lose their SQS polling permissions.
305
+ if len(sqs_resources) > 0:
306
+ managed_policy_arns.append(
307
+ "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole"
308
+ )
309
+
310
+ for index, policy in enumerate(build_config.policies):
311
+ if isinstance(policy, str):
312
+ managed_policy_arns.append(policy)
313
+ elif isinstance(policy, dict) and set(policy.keys()) == {"Statement"}:
314
+ inline_policies.append(
315
+ {
316
+ "PolicyName": f"AsyncLambdaBuildConfigPolicy{index}",
317
+ "PolicyDocument": {
318
+ "Version": "2012-10-17",
319
+ "Statement": policy["Statement"],
320
+ },
321
+ }
322
+ )
323
+ else:
324
+ raise Exception(
325
+ f"Unable to build a shared function role. The policy {policy!r} is "
326
+ "neither a managed policy ARN nor a single-key 'Statement' document."
327
+ "SAM policy templates cannot be expressed in a plain IAM role."
328
+ "Rewrite the policy in one of those two forms, or turn off config_set_shared_function_role."
329
+ )
330
+
331
+ managed_policy_arns.extend(
332
+ {"Ref": policy_id} for policy_id in task_ref_policy_ids
333
+ )
334
+
335
+ return {
336
+ "Type": "AWS::IAM::Role",
337
+ "Properties": {
338
+ "AssumeRolePolicyDocument": {
339
+ "Version": "2012-10-17",
340
+ "Statement": [
341
+ {
342
+ "Effect": "Allow",
343
+ "Principal": {"Service": ["lambda.amazonaws.com"]},
344
+ "Action": ["sts:AssumeRole"],
345
+ }
346
+ ],
347
+ },
348
+ "ManagedPolicyArns": managed_policy_arns,
349
+ "Policies": inline_policies,
350
+ },
351
+ }
224
352
 
225
353
  def add_middleware(
226
354
  self, event_types: List[Type[BaseEvent]], func: MiddlewareFunction[MET, RT]
@@ -525,22 +653,20 @@ class AsyncLambdaController:
525
653
  },
526
654
  }
527
655
  _task_list = list(self.tasks.values())
528
- managed_tasks_resources = [
529
- resource
530
- for task in _task_list
531
- if task.trigger_type in MANAGED_SQS_TASK_TYPES
532
- for resource in task.get_policy_sqs_resources()
533
- ] + [
656
+ send_all_queue_resources: List[dict] = []
657
+ if any(task.trigger_type in MANAGED_SQS_TASK_TYPES for task in _task_list):
658
+ send_all_queue_resources.append(self._managed_queue_arn_wildcard())
659
+ send_all_queue_resources.extend(
534
660
  resource
535
661
  for external_async_task_id in self.external_async_tasks
536
662
  for resource in AsyncLambdaTask.get_policy_external_task_resources(
537
663
  external_async_task_id
538
664
  )
539
- ]
665
+ )
540
666
 
541
667
  send_all_queues_task_ref_policies = (
542
668
  self._build_send_to_all_async_lambda_queues_policies(
543
- managed_tasks_resources
669
+ send_all_queue_resources
544
670
  )
545
671
  )
546
672
  for key in send_all_queues_task_ref_policies.keys():
@@ -593,6 +719,15 @@ class AsyncLambdaController:
593
719
  ],
594
720
  }
595
721
 
722
+ if config.shared_function_role:
723
+ template["Resources"][SHARED_FUNCTION_ROLE_LOGICAL_ID] = (
724
+ self._build_shared_function_role(
725
+ task_list=_task_list,
726
+ build_config=build_config,
727
+ task_ref_policy_ids=list(task_ref_policies.keys()),
728
+ )
729
+ )
730
+
596
731
  has_api_tasks = False
597
732
  for task in _task_list:
598
733
  if task.trigger_type == TaskTriggerType.API_EVENT:
@@ -57,6 +57,8 @@ MANAGED_SQS_TASK_TYPES = {
57
57
  TaskTriggerType.MANAGED_SQS_BATCH,
58
58
  }
59
59
 
60
+ SHARED_FUNCTION_ROLE_LOGICAL_ID = "AsyncLambdaSharedFunctionRole"
61
+
60
62
  EventType = TypeVar(
61
63
  "EventType",
62
64
  bound=Union[
@@ -610,23 +612,32 @@ class AsyncLambdaTask(Generic[EventType, RT]):
610
612
  *[{"Ref": policy_id} for policy_id in task_ref_policy_ids],
611
613
  ]
612
614
 
615
+ function_properties = {
616
+ "Environment": {
617
+ "Variables": {
618
+ "ASYNC_LAMBDA_TASK_ID": self.task_id,
619
+ **override_config.environment_variables,
620
+ }
621
+ },
622
+ "FunctionName": self.get_function_name(),
623
+ "Timeout": self.timeout,
624
+ "Events": events,
625
+ "CodeUri": ".async_lambda/build/deployment.zip",
626
+ }
627
+
628
+ # cannot use shared role if the task has its own policies
629
+ if config.shared_function_role and len(override_config.policies) == 0:
630
+ function_properties["Role"] = {
631
+ "Fn::GetAtt": [SHARED_FUNCTION_ROLE_LOGICAL_ID, "Arn"]
632
+ }
633
+ else:
634
+ function_properties["Policies"] = policies
635
+ function_properties.update(override_config.function_properties)
636
+
613
637
  template = {
614
638
  self.get_function_logical_id(): {
615
639
  "Type": "AWS::Serverless::Function",
616
- "Properties": {
617
- "Environment": {
618
- "Variables": {
619
- "ASYNC_LAMBDA_TASK_ID": self.task_id,
620
- **override_config.environment_variables,
621
- }
622
- },
623
- "FunctionName": self.get_function_name(),
624
- "Timeout": self.timeout,
625
- "Events": events,
626
- "Policies": policies,
627
- "CodeUri": ".async_lambda/build/deployment.zip",
628
- **override_config.function_properties,
629
- },
640
+ "Properties": function_properties,
630
641
  }
631
642
  }
632
643
  if self.memory is not None: