amgi-cloudevents 0.40.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.
- amgi_cloudevents/__init__.py +5 -0
- amgi_cloudevents/_message_send.py +67 -0
- amgi_cloudevents/_run.py +27 -0
- amgi_cloudevents/_server.py +159 -0
- amgi_cloudevents/py.typed +0 -0
- amgi_cloudevents-0.40.0.dist-info/METADATA +98 -0
- amgi_cloudevents-0.40.0.dist-info/RECORD +9 -0
- amgi_cloudevents-0.40.0.dist-info/WHEEL +4 -0
- amgi_cloudevents-0.40.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from types import TracebackType
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from amgi_types import MessageSendEvent
|
|
7
|
+
from cloudevents.v1.conversion import to_binary
|
|
8
|
+
from cloudevents.v1.conversion import to_structured
|
|
9
|
+
from cloudevents.v1.http import CloudEvent
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
from typing import Self
|
|
13
|
+
else:
|
|
14
|
+
from typing_extensions import Self
|
|
15
|
+
|
|
16
|
+
ContentMode = Literal["binary", "structured"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _decode_headers(headers: list[tuple[bytes, bytes]]) -> dict[str, str]:
|
|
20
|
+
try:
|
|
21
|
+
return {key.decode(): value.decode() for key, value in headers}
|
|
22
|
+
except UnicodeDecodeError as exc:
|
|
23
|
+
raise ValueError("CloudEvent attributes must be UTF-8 encoded") from exc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MessageSend:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
event_endpoint: str,
|
|
30
|
+
*,
|
|
31
|
+
source: str = "/amgi-cloudevents",
|
|
32
|
+
content_mode: ContentMode = "structured",
|
|
33
|
+
client: httpx.AsyncClient | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._client = client or httpx.AsyncClient()
|
|
36
|
+
self._close_client = client is None
|
|
37
|
+
self._event_endpoint = event_endpoint
|
|
38
|
+
self._source = source
|
|
39
|
+
self._content_mode = content_mode
|
|
40
|
+
|
|
41
|
+
async def __aenter__(self) -> Self:
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
async def __aexit__(
|
|
45
|
+
self,
|
|
46
|
+
exc_type: type[BaseException] | None,
|
|
47
|
+
exc_val: BaseException | None,
|
|
48
|
+
exc_tb: TracebackType | None,
|
|
49
|
+
) -> None:
|
|
50
|
+
if self._close_client:
|
|
51
|
+
await self._client.aclose()
|
|
52
|
+
|
|
53
|
+
async def __call__(self, event: MessageSendEvent) -> None:
|
|
54
|
+
attributes = _decode_headers(list(event["headers"]))
|
|
55
|
+
attributes["source"] = attributes.get("source", self._source)
|
|
56
|
+
attributes["type"] = event["address"]
|
|
57
|
+
|
|
58
|
+
cloud_event = CloudEvent(attributes, event.get("payload"))
|
|
59
|
+
if self._content_mode == "binary":
|
|
60
|
+
headers, body = to_binary(cloud_event)
|
|
61
|
+
else:
|
|
62
|
+
headers, body = to_structured(cloud_event)
|
|
63
|
+
|
|
64
|
+
response = await self._client.post(
|
|
65
|
+
self._event_endpoint, headers=headers, content=body
|
|
66
|
+
)
|
|
67
|
+
response.raise_for_status()
|
amgi_cloudevents/_run.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from amgi_cloudevents._message_send import ContentMode
|
|
2
|
+
from amgi_cloudevents._message_send import MessageSend
|
|
3
|
+
from amgi_cloudevents._server import Server
|
|
4
|
+
from amgi_types import AMGIApplication
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def run(
|
|
8
|
+
app: AMGIApplication,
|
|
9
|
+
host: str = "0.0.0.0",
|
|
10
|
+
port: int = 8000,
|
|
11
|
+
path: str = "/event",
|
|
12
|
+
message_send_endpoint: str | None = None,
|
|
13
|
+
message_send_source: str = "/amgi-cloudevents",
|
|
14
|
+
message_send_content_mode: ContentMode = "structured",
|
|
15
|
+
) -> None:
|
|
16
|
+
import uvicorn
|
|
17
|
+
|
|
18
|
+
message_send = (
|
|
19
|
+
MessageSend(
|
|
20
|
+
message_send_endpoint,
|
|
21
|
+
source=message_send_source,
|
|
22
|
+
content_mode=message_send_content_mode,
|
|
23
|
+
)
|
|
24
|
+
if message_send_endpoint is not None
|
|
25
|
+
else None
|
|
26
|
+
)
|
|
27
|
+
uvicorn.run(Server(app, path=path, message_send=message_send), host=host, port=port)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from collections.abc import Awaitable
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any
|
|
8
|
+
from typing import AsyncContextManager
|
|
9
|
+
|
|
10
|
+
from amgi_common import Lifespan
|
|
11
|
+
from amgi_types import AMGIApplication
|
|
12
|
+
from amgi_types import AMGIReceiveEvent
|
|
13
|
+
from amgi_types import AMGISendEvent
|
|
14
|
+
from amgi_types import MessageScope
|
|
15
|
+
from amgi_types import MessageSendEvent
|
|
16
|
+
from cloudevents.v1.exceptions import GenericException as CloudEventException
|
|
17
|
+
from cloudevents.v1.http import from_http
|
|
18
|
+
from starlette.applications import Starlette
|
|
19
|
+
from starlette.requests import Request
|
|
20
|
+
from starlette.responses import Response
|
|
21
|
+
from starlette.routing import Route
|
|
22
|
+
from starlette.status import HTTP_204_NO_CONTENT
|
|
23
|
+
from starlette.status import HTTP_400_BAD_REQUEST
|
|
24
|
+
from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR
|
|
25
|
+
|
|
26
|
+
_MessageSendT = Callable[[MessageSendEvent], Awaitable[None]]
|
|
27
|
+
_MessageSendManagerT = AsyncContextManager[_MessageSendT]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MissingMessageSend:
|
|
31
|
+
async def __aenter__(self) -> _MessageSendT:
|
|
32
|
+
return self._send
|
|
33
|
+
|
|
34
|
+
async def _send(self, event: MessageSendEvent) -> None:
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
"CloudEvents message.send is not configured. Pass message_send to Server "
|
|
37
|
+
"to allow AMGI handlers to send follow-up messages."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def __aexit__(
|
|
41
|
+
self,
|
|
42
|
+
exc_type: type[BaseException] | None,
|
|
43
|
+
exc_val: BaseException | None,
|
|
44
|
+
exc_tb: TracebackType | None,
|
|
45
|
+
) -> None:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Send:
|
|
50
|
+
def __init__(self, message_send: _MessageSendT) -> None:
|
|
51
|
+
self._message_send = message_send
|
|
52
|
+
self.acknowledged = False
|
|
53
|
+
self.nack_message: str | None = None
|
|
54
|
+
|
|
55
|
+
async def __call__(self, event: AMGISendEvent) -> None:
|
|
56
|
+
if event["type"] == "message.ack":
|
|
57
|
+
self.acknowledged = True
|
|
58
|
+
elif event["type"] == "message.nack":
|
|
59
|
+
self.nack_message = event["message"]
|
|
60
|
+
elif event["type"] == "message.send":
|
|
61
|
+
await self._message_send(event)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def receive() -> AMGIReceiveEvent:
|
|
65
|
+
raise RuntimeError("Receive should not be called")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def bytes_unmarshaller(content: None | str | bytes) -> bytes | None:
|
|
69
|
+
if content is None:
|
|
70
|
+
return None
|
|
71
|
+
if isinstance(content, bytes):
|
|
72
|
+
return content
|
|
73
|
+
return content.encode()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _encode_header_value(value: Any) -> bytes:
|
|
77
|
+
if isinstance(value, bytes):
|
|
78
|
+
return value
|
|
79
|
+
return str(value).encode()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _encode_headers(attributes: Mapping[str, Any]) -> list[tuple[bytes, bytes]]:
|
|
83
|
+
return [
|
|
84
|
+
(name.encode(), _encode_header_value(value))
|
|
85
|
+
for name, value in attributes.items()
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Server(Starlette):
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
app: AMGIApplication,
|
|
93
|
+
path: str = "/event",
|
|
94
|
+
message_send: _MessageSendManagerT | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._app = app
|
|
97
|
+
self._state: dict[str, Any] = {}
|
|
98
|
+
self._message_send_context = message_send or MissingMessageSend()
|
|
99
|
+
self._message_send: _MessageSendT | None = None
|
|
100
|
+
super().__init__(
|
|
101
|
+
routes=[Route(path, self._route, methods=["POST"])], lifespan=self._lifespan
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@asynccontextmanager
|
|
105
|
+
async def _lifespan(self, _server: Starlette) -> AsyncIterator[None]:
|
|
106
|
+
async with self._message_send_context as message_send:
|
|
107
|
+
self._message_send = message_send
|
|
108
|
+
async with Lifespan(self._app, self._state):
|
|
109
|
+
yield
|
|
110
|
+
self._message_send = None
|
|
111
|
+
|
|
112
|
+
async def _call_app(self, scope: MessageScope) -> Send:
|
|
113
|
+
if self._message_send is None:
|
|
114
|
+
async with self._message_send_context as message_send:
|
|
115
|
+
send = Send(message_send)
|
|
116
|
+
await self._app(scope, receive, send)
|
|
117
|
+
return send
|
|
118
|
+
|
|
119
|
+
send = Send(self._message_send)
|
|
120
|
+
await self._app(scope, receive, send)
|
|
121
|
+
return send
|
|
122
|
+
|
|
123
|
+
def _response(self, send: Send) -> Response:
|
|
124
|
+
if send.nack_message is not None:
|
|
125
|
+
return Response(
|
|
126
|
+
send.nack_message, status_code=HTTP_500_INTERNAL_SERVER_ERROR
|
|
127
|
+
)
|
|
128
|
+
if send.acknowledged:
|
|
129
|
+
return Response(status_code=HTTP_204_NO_CONTENT)
|
|
130
|
+
return Response(status_code=HTTP_204_NO_CONTENT)
|
|
131
|
+
|
|
132
|
+
async def _route(self, request: Request) -> Response:
|
|
133
|
+
try:
|
|
134
|
+
cloud_event = from_http(
|
|
135
|
+
request.headers,
|
|
136
|
+
await request.body(),
|
|
137
|
+
data_unmarshaller=bytes_unmarshaller,
|
|
138
|
+
)
|
|
139
|
+
except CloudEventException as exc:
|
|
140
|
+
return Response(str(exc), status_code=HTTP_400_BAD_REQUEST)
|
|
141
|
+
|
|
142
|
+
attributes = cloud_event.get_attributes()
|
|
143
|
+
address = attributes["type"]
|
|
144
|
+
if not isinstance(address, str):
|
|
145
|
+
return Response(
|
|
146
|
+
"CloudEvent type attribute must be a string",
|
|
147
|
+
status_code=HTTP_400_BAD_REQUEST,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
scope: MessageScope = {
|
|
151
|
+
"type": "message",
|
|
152
|
+
"amgi": {"version": "2.0", "spec_version": "2.0"},
|
|
153
|
+
"address": address,
|
|
154
|
+
"headers": _encode_headers(attributes),
|
|
155
|
+
"payload": cloud_event.get_data(),
|
|
156
|
+
"state": self._state.copy(),
|
|
157
|
+
}
|
|
158
|
+
send = await self._call_app(scope)
|
|
159
|
+
return self._response(send)
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: amgi-cloudevents
|
|
3
|
+
Version: 0.40.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Requires-Dist: amgi-common==0.40.0
|
|
12
|
+
Requires-Dist: amgi-types==0.40.0
|
|
13
|
+
Requires-Dist: cloudevents>=2.1.0
|
|
14
|
+
Requires-Dist: httpx>=0.28.1
|
|
15
|
+
Requires-Dist: starlette>=1.2.1
|
|
16
|
+
Requires-Dist: typing-extensions>=4.15.0 ; python_full_version < '3.11'
|
|
17
|
+
Requires-Dist: uvicorn>=0.49.0 ; extra == 'uvicorn'
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Provides-Extra: uvicorn
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# amgi-cloudevents
|
|
23
|
+
|
|
24
|
+
CloudEvents HTTP adapter for AMGI applications.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```shell
|
|
29
|
+
pip install "amgi-cloudevents[uvicorn]==0.40.0"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Receiving CloudEvents
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from amgi_cloudevents import run
|
|
36
|
+
from asyncfast import AsyncFast
|
|
37
|
+
|
|
38
|
+
app = AsyncFast()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.channel("com.example.event")
|
|
42
|
+
async def handle_event(payload: bytes) -> None: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
run(app, host="0.0.0.0", port=8000)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
You can also run an app through the AsyncFast CLI entry point:
|
|
50
|
+
|
|
51
|
+
```shell
|
|
52
|
+
asyncfast run amgi-cloudevents-uvicorn main:app --host 0.0.0.0 --port 8000
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If the app sends follow-up messages, provide an outbound CloudEvents endpoint:
|
|
56
|
+
|
|
57
|
+
```shell
|
|
58
|
+
asyncfast run amgi-cloudevents-uvicorn main:app \
|
|
59
|
+
--message-send-endpoint https://example.com/events \
|
|
60
|
+
--message-send-source /orders \
|
|
61
|
+
--message-send-content-mode binary
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`Server` accepts binary or structured CloudEvents on `POST /event` by default.
|
|
65
|
+
CloudEvent attributes and extensions are exposed as AMGI headers. For example,
|
|
66
|
+
`ce-id: event-1` becomes `(b"id", b"event-1")`.
|
|
67
|
+
|
|
68
|
+
AMGI `message.ack` returns `204 No Content`. AMGI `message.nack` returns `500`
|
|
69
|
+
with the nack message as the response body. Invalid CloudEvents return `400`.
|
|
70
|
+
|
|
71
|
+
## Sending CloudEvents
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from amgi_cloudevents import MessageSend
|
|
75
|
+
from amgi_cloudevents import Server
|
|
76
|
+
|
|
77
|
+
message_send = MessageSend(
|
|
78
|
+
"https://example.com/events",
|
|
79
|
+
source="/orders",
|
|
80
|
+
content_mode="binary",
|
|
81
|
+
)
|
|
82
|
+
server = Server(app, message_send=message_send)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`MessageSend` maps AMGI `message.send` events to CloudEvents:
|
|
86
|
+
|
|
87
|
+
- `event["address"]` becomes the CloudEvent `type`
|
|
88
|
+
- `event["payload"]` becomes the CloudEvent data
|
|
89
|
+
- `event["headers"]` become CloudEvent attributes/extensions
|
|
90
|
+
- `source` defaults to `/amgi-cloudevents`, unless the AMGI headers include
|
|
91
|
+
`source`
|
|
92
|
+
|
|
93
|
+
`content_mode` can be `"structured"` or `"binary"`. Structured mode is the
|
|
94
|
+
default.
|
|
95
|
+
|
|
96
|
+
If `Server` receives an AMGI `message.send` event without a configured
|
|
97
|
+
`message_send`, it raises an error explaining that outbound sending must be
|
|
98
|
+
wired explicitly.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
amgi_cloudevents/__init__.py,sha256=ap4L_0rxQIzj6PhXp7DLYHGuNzATLLo5FYmi6C8LTVQ,181
|
|
2
|
+
amgi_cloudevents/_message_send.py,sha256=jAteGThHsfaVzAiA789Vy1hwuWPYhYmvBE5HoqMAbAc,2099
|
|
3
|
+
amgi_cloudevents/_run.py,sha256=hsSx-bahGpdnagRBas8aYRpHPMtVpGh2CYtGgG6X6_Y,830
|
|
4
|
+
amgi_cloudevents/_server.py,sha256=LUg-_6MRMsfATy4ruXL8ZjTAUuLKge9iUNLFmM3X24w,5410
|
|
5
|
+
amgi_cloudevents/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
amgi_cloudevents-0.40.0.dist-info/WHEEL,sha256=wXwAVsgVaOZ_pwDFqQm5Rd6PID-Fc74nkLc8X8gHiDo,81
|
|
7
|
+
amgi_cloudevents-0.40.0.dist-info/entry_points.txt,sha256=TMJi3iF8uIFjKtIx5RkltJ5-DidostbrlP9doIDmaqA,68
|
|
8
|
+
amgi_cloudevents-0.40.0.dist-info/METADATA,sha256=Y9Hrz0VGhbY_QKrnYvm7rqZDGUxMdfdGeFWEKv6baGk,2842
|
|
9
|
+
amgi_cloudevents-0.40.0.dist-info/RECORD,,
|