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.
- amgi_sqs_event_source_mapping-0.21.0/PKG-INFO +60 -0
- amgi_sqs_event_source_mapping-0.21.0/README.md +43 -0
- amgi_sqs_event_source_mapping-0.21.0/pyproject.toml +37 -0
- amgi_sqs_event_source_mapping-0.21.0/src/amgi_sqs_event_source_mapping/__init__.py +203 -0
- amgi_sqs_event_source_mapping-0.21.0/src/amgi_sqs_event_source_mapping/py.typed +0 -0
|
@@ -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)
|
|
File without changes
|