amgi-sqs-event-source-mapping 0.21.0__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.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.3
2
+ Name: amgi-sqs-event-source-mapping
3
+ Version: 0.21.0
4
+ Summary: Add your description here
5
+ Author: jack.burridge
6
+ Author-email: jack.burridge <jack.burridge@mail.com>
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: amgi-common==0.21.0
14
+ Requires-Dist: amgi-types==0.21.0
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+
18
+ # amgi-sqs-event-source-mapping
19
+
20
+ amgi-sqs-event-source-mapping is an adaptor for [AMGI](https://amgi.readthedocs.io/en/latest/) to run in a SQS event
21
+ source mapped Lambda.
22
+
23
+ ## Installation
24
+
25
+ ```
26
+ pip install amgi-sqs-event-source-mapping==0.21.0
27
+ ```
28
+
29
+ ## Example
30
+
31
+ ```python
32
+ from dataclasses import dataclass
33
+
34
+ from amgi_sqs_event_source_mapping import SqsHandler
35
+ from asyncfast import AsyncFast
36
+
37
+ app = AsyncFast()
38
+
39
+
40
+ @dataclass
41
+ class Order:
42
+ item_ids: list[str]
43
+
44
+
45
+ @app.channel("order-queue")
46
+ async def order_queue(order: Order) -> None:
47
+ # Makes an order
48
+ ...
49
+
50
+
51
+ handler = SqsHandler(app)
52
+ ```
53
+
54
+ ## Contact
55
+
56
+ For questions or suggestions, please contact [jack.burridge@mail.com](mailto:jack.burridge@mail.com).
57
+
58
+ ## License
59
+
60
+ Copyright 2025 AMGI
@@ -0,0 +1,43 @@
1
+ # amgi-sqs-event-source-mapping
2
+
3
+ amgi-sqs-event-source-mapping is an adaptor for [AMGI](https://amgi.readthedocs.io/en/latest/) to run in a SQS event
4
+ source mapped Lambda.
5
+
6
+ ## Installation
7
+
8
+ ```
9
+ pip install amgi-sqs-event-source-mapping==0.21.0
10
+ ```
11
+
12
+ ## Example
13
+
14
+ ```python
15
+ from dataclasses import dataclass
16
+
17
+ from amgi_sqs_event_source_mapping import SqsHandler
18
+ from asyncfast import AsyncFast
19
+
20
+ app = AsyncFast()
21
+
22
+
23
+ @dataclass
24
+ class Order:
25
+ item_ids: list[str]
26
+
27
+
28
+ @app.channel("order-queue")
29
+ async def order_queue(order: Order) -> None:
30
+ # Makes an order
31
+ ...
32
+
33
+
34
+ handler = SqsHandler(app)
35
+ ```
36
+
37
+ ## Contact
38
+
39
+ For questions or suggestions, please contact [jack.burridge@mail.com](mailto:jack.burridge@mail.com).
40
+
41
+ ## License
42
+
43
+ Copyright 2025 AMGI
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ build-backend = "uv_build"
3
+ requires = [ "uv-build>=0.8.14,<0.9.0" ]
4
+
5
+ [project]
6
+ name = "amgi-sqs-event-source-mapping"
7
+ version = "0.21.0"
8
+ description = "Add your description here"
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "jack.burridge", email = "jack.burridge@mail.com" },
12
+ ]
13
+ requires-python = ">=3.10"
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ ]
22
+ dependencies = [
23
+ "amgi-common==0.21.0",
24
+ "amgi-types==0.21.0",
25
+ ]
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "boto3>=1.40.70",
30
+ "test-utils",
31
+ "testcontainers[localstack]>=4.13.0",
32
+ ]
33
+
34
+ [tool.uv.sources]
35
+ test-utils = { workspace = true }
36
+ amgi-common = { workspace = true }
37
+ amgi-types = { workspace = true }
@@ -0,0 +1,203 @@
1
+ import asyncio
2
+ import base64
3
+ import hashlib
4
+ import itertools
5
+ import re
6
+ import signal
7
+ import sys
8
+ from collections import defaultdict
9
+ from collections import deque
10
+ from collections.abc import Iterable
11
+ from typing import Any
12
+ from typing import Literal
13
+ from typing import Optional
14
+ from typing import TypedDict
15
+
16
+ import boto3
17
+ from amgi_common import Lifespan
18
+ from amgi_types import AMGIApplication
19
+ from amgi_types import AMGISendEvent
20
+ from amgi_types import MessageReceiveEvent
21
+ from amgi_types import MessageScope
22
+
23
+ if sys.version_info >= (3, 11):
24
+ from typing import NotRequired
25
+ else:
26
+ from typing_extensions import NotRequired
27
+
28
+
29
+ class _AttributeValue(TypedDict):
30
+ stringValue: NotRequired[str]
31
+ binaryValue: NotRequired[str]
32
+ stringListValues: NotRequired[list[str]]
33
+ binaryListValues: NotRequired[list[str]]
34
+ dataType: str
35
+
36
+
37
+ class _Record(TypedDict):
38
+ messageId: str
39
+ receiptHandle: str
40
+ body: str
41
+ attributes: dict[str, str]
42
+ messageAttributes: dict[str, _AttributeValue]
43
+ md5OfBody: str
44
+ eventSource: Literal["aws:sqs"]
45
+ eventSourceARN: str
46
+ awsRegion: str
47
+
48
+
49
+ class _SqsEventSourceMapping(TypedDict):
50
+ Records: list[_Record]
51
+
52
+
53
+ class _ItemIdentifier(TypedDict):
54
+ itemIdentifier: str
55
+
56
+
57
+ class _BatchItemFailures(TypedDict):
58
+ batchItemFailures: list[_ItemIdentifier]
59
+
60
+
61
+ EVENT_SOURCE_ARN_PATTERN = re.compile(
62
+ r"^arn:aws:sqs:[A-Za-z0-9-]+:\d+:(?P<queue>[A-Za-z.\-_]+)$"
63
+ )
64
+
65
+
66
+ def _encode_message_attributes(
67
+ message_attributes: dict[str, Any],
68
+ ) -> Iterable[tuple[bytes, bytes]]:
69
+ for name, value in message_attributes.items():
70
+ encoded_value = (
71
+ base64.b64decode(value["binaryValue"])
72
+ if value["dataType"] == "Binary"
73
+ else value["stringValue"].encode()
74
+ )
75
+ yield name.encode(), encoded_value
76
+
77
+
78
+ class _Receive:
79
+ def __init__(self, records: Iterable[_Record]) -> None:
80
+ self._deque = deque(records)
81
+
82
+ async def __call__(self) -> MessageReceiveEvent:
83
+ message = self._deque.popleft()
84
+ encoded_headers = list(
85
+ _encode_message_attributes(message.get("messageAttributes", {}))
86
+ )
87
+ return {
88
+ "type": "message.receive",
89
+ "id": message["messageId"],
90
+ "headers": encoded_headers,
91
+ "payload": message["body"].encode(),
92
+ "more_messages": len(self._deque) != 0,
93
+ }
94
+
95
+
96
+ class _Send:
97
+ def __init__(self, sqs_client: Any, message_ids: Iterable[str]) -> None:
98
+ self._sqs_client = sqs_client
99
+ self.message_ids = set(message_ids)
100
+
101
+ async def __call__(self, event: AMGISendEvent) -> None:
102
+ if event["type"] == "message.ack":
103
+ self.message_ids.discard(event["id"])
104
+ if event["type"] == "message.send":
105
+ queue_url_response = await asyncio.to_thread(
106
+ self._sqs_client.get_queue_url, QueueName=event["address"]
107
+ )
108
+ await asyncio.to_thread(
109
+ self._sqs_client.send_message,
110
+ QueueUrl=queue_url_response["QueueUrl"],
111
+ MessageBody=(
112
+ "" if event["payload"] is None else event["payload"].decode()
113
+ ),
114
+ MessageAttributes={
115
+ name.decode(): {
116
+ "StringValue": value.decode(),
117
+ "DataType": "StringValue",
118
+ }
119
+ for name, value in event["headers"]
120
+ },
121
+ )
122
+
123
+
124
+ class SqsHandler:
125
+ def __init__(
126
+ self,
127
+ app: AMGIApplication,
128
+ region_name: Optional[str] = None,
129
+ endpoint_url: Optional[str] = None,
130
+ aws_access_key_id: Optional[str] = None,
131
+ aws_secret_access_key: Optional[str] = None,
132
+ lifespan: bool = True,
133
+ ) -> None:
134
+ self._app = app
135
+ self._loop = asyncio.get_event_loop()
136
+ self._sqs_client = boto3.client(
137
+ "sqs",
138
+ region_name=region_name,
139
+ endpoint_url=endpoint_url,
140
+ aws_access_key_id=aws_access_key_id,
141
+ aws_secret_access_key=aws_secret_access_key,
142
+ )
143
+ self._lifespan = lifespan
144
+
145
+ self._lifespan_context: Optional[Lifespan] = None
146
+ self._loop.add_signal_handler(signal.SIGTERM, self._sigterm_handler)
147
+
148
+ def __call__(
149
+ self, event: _SqsEventSourceMapping, context: Any
150
+ ) -> _BatchItemFailures:
151
+ return self._loop.run_until_complete(self._call(event))
152
+
153
+ async def _call(self, event: _SqsEventSourceMapping) -> _BatchItemFailures:
154
+ if not self._lifespan_context and self._lifespan:
155
+ self._lifespan_context = Lifespan(self._app)
156
+ await self._lifespan_context.__aenter__()
157
+ event_source_arn_records = defaultdict(list)
158
+ corrupted_message_ids = []
159
+ for record in event["Records"]:
160
+ if hashlib.md5(record["body"].encode()).hexdigest() == record["md5OfBody"]:
161
+ event_source_arn_records[record["eventSourceARN"]].append(record)
162
+ else:
163
+ corrupted_message_ids.append(record["messageId"])
164
+
165
+ unacked_message_ids = await asyncio.gather(
166
+ *(
167
+ self._call_source_batch(event_source_arn, records)
168
+ for event_source_arn, records in event_source_arn_records.items()
169
+ )
170
+ )
171
+
172
+ return {
173
+ "batchItemFailures": [
174
+ {"itemIdentifier": message_id}
175
+ for message_id in itertools.chain(
176
+ *unacked_message_ids, corrupted_message_ids
177
+ )
178
+ ]
179
+ }
180
+
181
+ async def _call_source_batch(
182
+ self, event_source_arn: str, records: Iterable[_Record]
183
+ ) -> Iterable[str]:
184
+ event_source_arn_match = EVENT_SOURCE_ARN_PATTERN.match(event_source_arn)
185
+ message_ids = [record["messageId"] for record in records]
186
+ if event_source_arn_match is None:
187
+ return message_ids
188
+ scope: MessageScope = {
189
+ "type": "message",
190
+ "amgi": {"version": "1.0", "spec_version": "1.0"},
191
+ "address": event_source_arn_match["queue"],
192
+ }
193
+
194
+ records_send = _Send(self._sqs_client, message_ids)
195
+ await self._app(scope, _Receive(records), records_send)
196
+ return records_send.message_ids
197
+
198
+ def _sigterm_handler(self) -> None:
199
+ self._loop.run_until_complete(self._shutdown())
200
+
201
+ async def _shutdown(self) -> None:
202
+ if self._lifespan_context:
203
+ await self._lifespan_context.__aexit__(None, None, None)