amgi-cloudevents 0.40.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,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,77 @@
1
+ # amgi-cloudevents
2
+
3
+ CloudEvents HTTP adapter for AMGI applications.
4
+
5
+ ## Installation
6
+
7
+ ```shell
8
+ pip install "amgi-cloudevents[uvicorn]==0.40.0"
9
+ ```
10
+
11
+ ## Receiving CloudEvents
12
+
13
+ ```python
14
+ from amgi_cloudevents import run
15
+ from asyncfast import AsyncFast
16
+
17
+ app = AsyncFast()
18
+
19
+
20
+ @app.channel("com.example.event")
21
+ async def handle_event(payload: bytes) -> None: ...
22
+
23
+
24
+ if __name__ == "__main__":
25
+ run(app, host="0.0.0.0", port=8000)
26
+ ```
27
+
28
+ You can also run an app through the AsyncFast CLI entry point:
29
+
30
+ ```shell
31
+ asyncfast run amgi-cloudevents-uvicorn main:app --host 0.0.0.0 --port 8000
32
+ ```
33
+
34
+ If the app sends follow-up messages, provide an outbound CloudEvents endpoint:
35
+
36
+ ```shell
37
+ asyncfast run amgi-cloudevents-uvicorn main:app \
38
+ --message-send-endpoint https://example.com/events \
39
+ --message-send-source /orders \
40
+ --message-send-content-mode binary
41
+ ```
42
+
43
+ `Server` accepts binary or structured CloudEvents on `POST /event` by default.
44
+ CloudEvent attributes and extensions are exposed as AMGI headers. For example,
45
+ `ce-id: event-1` becomes `(b"id", b"event-1")`.
46
+
47
+ AMGI `message.ack` returns `204 No Content`. AMGI `message.nack` returns `500`
48
+ with the nack message as the response body. Invalid CloudEvents return `400`.
49
+
50
+ ## Sending CloudEvents
51
+
52
+ ```python
53
+ from amgi_cloudevents import MessageSend
54
+ from amgi_cloudevents import Server
55
+
56
+ message_send = MessageSend(
57
+ "https://example.com/events",
58
+ source="/orders",
59
+ content_mode="binary",
60
+ )
61
+ server = Server(app, message_send=message_send)
62
+ ```
63
+
64
+ `MessageSend` maps AMGI `message.send` events to CloudEvents:
65
+
66
+ - `event["address"]` becomes the CloudEvent `type`
67
+ - `event["payload"]` becomes the CloudEvent data
68
+ - `event["headers"]` become CloudEvent attributes/extensions
69
+ - `source` defaults to `/amgi-cloudevents`, unless the AMGI headers include
70
+ `source`
71
+
72
+ `content_mode` can be `"structured"` or `"binary"`. Structured mode is the
73
+ default.
74
+
75
+ If `Server` receives an AMGI `message.send` event without a configured
76
+ `message_send`, it raises an error explaining that outbound sending must be
77
+ wired explicitly.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ build-backend = "uv_build"
3
+ requires = [
4
+ "uv-build>=0.11.19,<0.12.0",
5
+ ]
6
+
7
+ [project]
8
+ name = "amgi-cloudevents"
9
+ version = "0.40.0"
10
+ description = "Add your description here"
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ ]
21
+ dependencies = [
22
+ "amgi-common==0.40.0",
23
+ "amgi-types==0.40.0",
24
+ "cloudevents>=2.1.0",
25
+ "httpx>=0.28.1",
26
+ "starlette>=1.2.1",
27
+ "typing-extensions>=4.15.0; python_full_version<'3.11'",
28
+ ]
29
+ optional-dependencies.uvicorn = [
30
+ "uvicorn>=0.49.0",
31
+ ]
32
+ entry-points.amgi_server.amgi-cloudevents-uvicorn = "amgi_cloudevents._run:run"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=8.4.1",
37
+ "pytest-asyncio>=1.3.0",
38
+ "pytest-cov>=7.0.0",
39
+ "pytest-timeout>=2.4.0",
40
+ "test-utils",
41
+ "uvicorn>=0.49.0",
42
+ ]
43
+
44
+ [tool.uv]
45
+ sources.amgi-common = { workspace = true }
46
+ sources.amgi-types = { workspace = true }
47
+ sources.test-utils = { workspace = true }
@@ -0,0 +1,5 @@
1
+ from amgi_cloudevents._message_send import MessageSend
2
+ from amgi_cloudevents._run import run
3
+ from amgi_cloudevents._server import Server
4
+
5
+ __all__ = ["MessageSend", "Server", "run"]
@@ -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()
@@ -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