smithy-aws-event-stream 0.0.1__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,36 @@
1
+ # Eclipse
2
+ .classpath
3
+ .project
4
+ .settings/
5
+
6
+ # Intellij
7
+ .idea/
8
+ *.iml
9
+ *.iws
10
+
11
+ # VS Code
12
+ .vscode/
13
+
14
+ # Mac
15
+ .DS_Store
16
+
17
+ # Maven
18
+ target/
19
+ **/dependency-reduced-pom.xml
20
+
21
+ # Gradle
22
+ **/.gradle
23
+ build/
24
+ */out/
25
+ **/*/out/
26
+
27
+ # Python
28
+ __pycache__
29
+ *.swp
30
+ .mypy_cache
31
+ .coverage
32
+ coverage.xml
33
+ htmlcov
34
+ *.egg-info
35
+ dist
36
+ .venv
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ * <Add new items here>
6
+
7
+ ## v0.0.1
8
+
9
+ ### Features
10
+ * Added basic support for the [Amazon Event Stream](https://smithy.io/2.0/aws/amazon-eventstream.html) specification.
11
+ * Added support for unidirectdional and bidirectional event streams.
@@ -0,0 +1 @@
1
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: smithy-aws-event-stream
3
+ Version: 0.0.1
4
+ Summary: Smithy components for Amazon Event Streams.
5
+ License-File: NOTICE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: smithy-core
File without changes
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "smithy-aws-event-stream"
3
+ version = "0.0.1"
4
+ description = "Smithy components for Amazon Event Streams."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "smithy-core",
9
+ ]
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [tool.hatch.build]
16
+ exclude = [
17
+ "tests",
18
+ ]
19
+
20
+ [tool.ruff]
21
+ src = ["src"]
@@ -0,0 +1,6 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import importlib.metadata
5
+
6
+ __version__: str = importlib.metadata.version("smithy-aws-event-stream")
@@ -0,0 +1,14 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from smithy_core.schemas import Schema
4
+ from smithy_core.traits import EventPayloadTrait
5
+
6
+ INITIAL_REQUEST_EVENT_TYPE = "initial-request"
7
+ INITIAL_RESPONSE_EVENT_TYPE = "initial-response"
8
+
9
+
10
+ def get_payload_member(schema: Schema) -> Schema | None:
11
+ for member in schema.members.values():
12
+ if EventPayloadTrait in member:
13
+ return member
14
+ return None
@@ -0,0 +1,166 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import datetime
4
+ import logging
5
+ from collections.abc import Callable
6
+
7
+ from smithy_core.codecs import Codec
8
+ from smithy_core.deserializers import (
9
+ ShapeDeserializer,
10
+ SpecificShapeDeserializer,
11
+ )
12
+ from smithy_core.schemas import Schema
13
+ from smithy_core.shapes import ShapeType
14
+ from smithy_core.traits import EventHeaderTrait
15
+ from smithy_core.utils import expect_type
16
+
17
+ from ..events import HEADERS_DICT, Event
18
+ from ..exceptions import EventError, UnmodeledEventError
19
+ from . import (
20
+ INITIAL_REQUEST_EVENT_TYPE,
21
+ INITIAL_RESPONSE_EVENT_TYPE,
22
+ get_payload_member,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ INITIAL_MESSAGE_TYPES = (INITIAL_REQUEST_EVENT_TYPE, INITIAL_RESPONSE_EVENT_TYPE)
28
+
29
+
30
+ class EventDeserializer(SpecificShapeDeserializer):
31
+ def __init__(
32
+ self, event: Event, payload_codec: Codec, is_client_mode: bool = True
33
+ ) -> None:
34
+ self._event = event
35
+ self._payload_codec = payload_codec
36
+ self._is_client_mode = is_client_mode
37
+
38
+ def read_struct(
39
+ self,
40
+ schema: Schema,
41
+ consumer: Callable[[Schema, ShapeDeserializer], None],
42
+ ) -> None:
43
+ headers = self._event.message.headers
44
+
45
+ match headers.get(":message-type"):
46
+ case "event":
47
+ member_name = expect_type(str, headers[":event-type"])
48
+ if member_name in INITIAL_MESSAGE_TYPES:
49
+ # If it's an initial message, skip straight to deserialization.
50
+ message_deserializer = self._create_deserializer(schema, headers)
51
+ message_deserializer.read_struct(schema, consumer)
52
+ else:
53
+ member_schema = schema.members[member_name]
54
+ message_deserializer = self._create_deserializer(
55
+ member_schema, headers
56
+ )
57
+ consumer(member_schema, message_deserializer)
58
+ case "exception":
59
+ member_name = expect_type(str, headers[":exception-type"])
60
+ member_schema = schema.members[member_name]
61
+ message_deserializer = self._create_deserializer(member_schema, headers)
62
+ consumer(member_schema, message_deserializer)
63
+ case "error":
64
+ # The `application/vnd.amazon.eventstream` format allows for explicitly
65
+ # unmodeled exceptions. These exceptions MUST have the `:error-code`
66
+ # and `:error-message` headers set, and they MUST be strings.
67
+ raise UnmodeledEventError(
68
+ expect_type(str, headers[":error-code"]),
69
+ expect_type(str, headers[":error-message"]),
70
+ )
71
+ case _:
72
+ raise EventError(f"Unknown event structure: {self._event}")
73
+
74
+ def _create_deserializer(
75
+ self, schema: Schema, headers: HEADERS_DICT
76
+ ) -> ShapeDeserializer:
77
+ payload_member = get_payload_member(schema)
78
+ payload_deserializer = self._create_payload_deserializer(payload_member)
79
+ return EventMessageDeserializer(headers, payload_deserializer, payload_member)
80
+
81
+ def _create_payload_deserializer(
82
+ self, payload_member: Schema | None
83
+ ) -> ShapeDeserializer | None:
84
+ if not self._event.message.payload:
85
+ return
86
+
87
+ if payload_member is not None and payload_member.shape_type in (
88
+ ShapeType.BLOB,
89
+ ShapeType.STRING,
90
+ ):
91
+ return RawPayloadDeserializer(self._event.message.payload)
92
+
93
+ return self._payload_codec.create_deserializer(self._event.message.payload)
94
+
95
+
96
+ class RawPayloadDeserializer(SpecificShapeDeserializer):
97
+ def __init__(self, payload: bytes) -> None:
98
+ self._payload = payload
99
+
100
+ def read_string(self, schema: Schema) -> str:
101
+ return self._payload.decode("utf-8")
102
+
103
+ def read_blob(self, schema: Schema) -> bytes:
104
+ return self._payload
105
+
106
+
107
+ class EventMessageDeserializer(SpecificShapeDeserializer):
108
+ def __init__(
109
+ self,
110
+ headers: HEADERS_DICT,
111
+ payload_deserializer: ShapeDeserializer | None,
112
+ payload_member: Schema | None,
113
+ ) -> None:
114
+ self._headers = headers
115
+ self._payload_deserializer = payload_deserializer
116
+ self._payload_member = payload_member
117
+
118
+ def read_struct(
119
+ self,
120
+ schema: Schema,
121
+ consumer: Callable[[Schema, ShapeDeserializer], None],
122
+ ) -> None:
123
+ headers_deserializer = EventHeaderDeserializer(self._headers)
124
+ for key in self._headers.keys():
125
+ member_schema = schema.members.get(key)
126
+ if member_schema is not None and EventHeaderTrait in member_schema:
127
+ consumer(member_schema, headers_deserializer)
128
+
129
+ if self._payload_deserializer:
130
+ if self._payload_member is not None:
131
+ consumer(self._payload_member, self._payload_deserializer)
132
+ else:
133
+ self._payload_deserializer.read_struct(schema, consumer)
134
+
135
+
136
+ class EventHeaderDeserializer(SpecificShapeDeserializer):
137
+ def __init__(self, headers: HEADERS_DICT) -> None:
138
+ self._headers = headers
139
+
140
+ def read_boolean(self, schema: "Schema") -> bool:
141
+ return expect_type(bool, self._headers[schema.expect_member_name()])
142
+
143
+ def read_blob(self, schema: "Schema") -> bytes:
144
+ return expect_type(bytes, self._headers[schema.expect_member_name()])
145
+
146
+ def read_byte(self, schema: "Schema") -> int:
147
+ return expect_type(int, self._headers[schema.expect_member_name()])
148
+
149
+ def read_short(self, schema: "Schema") -> int:
150
+ return expect_type(int, self._headers[schema.expect_member_name()])
151
+
152
+ def read_integer(self, schema: "Schema") -> int:
153
+ return expect_type(int, self._headers[schema.expect_member_name()])
154
+
155
+ def read_long(self, schema: "Schema") -> int:
156
+ return expect_type(int, self._headers[schema.expect_member_name()])
157
+
158
+ def read_string(self, schema: "Schema") -> str:
159
+ return expect_type(str, self._headers[schema.expect_member_name()])
160
+
161
+ def read_timestamp(self, schema: "Schema") -> datetime.datetime:
162
+ # TODO: do we support timestamp format here? One would assume not since the
163
+ # format has a specific timestamp type.
164
+ return expect_type(
165
+ datetime.datetime, self._headers[schema.expect_member_name()]
166
+ )
@@ -0,0 +1,181 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import datetime
4
+ import logging
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from io import BytesIO
8
+ from typing import Never
9
+
10
+ from smithy_core.codecs import Codec
11
+ from smithy_core.schemas import Schema
12
+ from smithy_core.serializers import (
13
+ InterceptingSerializer,
14
+ ShapeSerializer,
15
+ SpecificShapeSerializer,
16
+ )
17
+ from smithy_core.shapes import ShapeType
18
+ from smithy_core.traits import ErrorTrait, EventHeaderTrait, MediaTypeTrait
19
+
20
+ from ..events import HEADER_VALUE, Byte, EventHeaderEncoder, EventMessage, Long, Short
21
+ from ..exceptions import InvalidHeaderValue
22
+ from . import (
23
+ INITIAL_REQUEST_EVENT_TYPE,
24
+ INITIAL_RESPONSE_EVENT_TYPE,
25
+ get_payload_member,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _DEFAULT_STRING_CONTENT_TYPE = "text/plain"
31
+ _DEFAULT_BLOB_CONTENT_TYPE = "application/octet-stream"
32
+
33
+
34
+ class EventSerializer(SpecificShapeSerializer):
35
+ def __init__(
36
+ self,
37
+ payload_codec: Codec,
38
+ is_client_mode: bool = True,
39
+ ) -> None:
40
+ self._payload_codec = payload_codec
41
+ self._result: EventMessage | None = None
42
+ if is_client_mode:
43
+ self._initial_message_event_type = INITIAL_REQUEST_EVENT_TYPE
44
+ else:
45
+ self._initial_message_event_type = INITIAL_RESPONSE_EVENT_TYPE
46
+ self.event_header_encoder_cls = EventHeaderEncoder
47
+
48
+ def get_result(self) -> EventMessage | None:
49
+ return self._result
50
+
51
+ @contextmanager
52
+ def begin_struct(self, schema: "Schema") -> Iterator[ShapeSerializer]:
53
+ # Event stream definitions are unions. Nothing about the union shape actually
54
+ # matters for the purposes of event stream serialization though, we just care
55
+ # about the specific member we're serializing. So here we yield immediately,
56
+ # and the next time this method is called it'll be by the member that we
57
+ # actually care about.
58
+ #
59
+ # Note that if we're serializing an operation input or output, it won't be a
60
+ # union at all, so this won't get triggered. Thankfully, that's what we want.
61
+ if schema.shape_type is ShapeType.UNION:
62
+ try:
63
+ yield self
64
+ finally:
65
+ return
66
+
67
+ headers: dict[str, HEADER_VALUE] = {}
68
+
69
+ if ErrorTrait in schema:
70
+ headers[":message-type"] = "exception"
71
+ headers[":exception-type"] = schema.expect_member_name()
72
+ else:
73
+ headers[":message-type"] = "event"
74
+ if schema.member_name is None:
75
+ # If there's no member name, that must mean that the structure is
76
+ # either an input or output structure, and so this represents the
77
+ # initial message.
78
+ headers[":event-type"] = self._initial_message_event_type
79
+ else:
80
+ headers[":event-type"] = schema.member_name
81
+
82
+ payload = BytesIO()
83
+ payload_serializer: ShapeSerializer = self._payload_codec.create_serializer(
84
+ payload
85
+ )
86
+
87
+ header_serializer = EventHeaderSerializer(headers)
88
+
89
+ media_type = self._payload_codec.media_type
90
+
91
+ if (payload_member := get_payload_member(schema)) is not None:
92
+ media_type = self._get_payload_media_type(payload_member, media_type)
93
+ if payload_member.shape_type in (ShapeType.BLOB, ShapeType.STRING):
94
+ payload_serializer = RawPayloadSerializer(payload)
95
+ yield EventStreamBindingSerializer(header_serializer, payload_serializer)
96
+ else:
97
+ with payload_serializer.begin_struct(schema) as body_serializer:
98
+ yield EventStreamBindingSerializer(header_serializer, body_serializer)
99
+
100
+ payload_bytes = payload.getvalue()
101
+ if payload_bytes:
102
+ headers[":content-type"] = media_type
103
+
104
+ self._result = EventMessage(headers=headers, payload=payload_bytes)
105
+
106
+ def _get_payload_media_type(self, schema: Schema, default: str) -> str:
107
+ if (media_type := schema.get_trait(MediaTypeTrait)) is not None:
108
+ return media_type.value
109
+
110
+ match schema.shape_type:
111
+ case ShapeType.STRING:
112
+ return _DEFAULT_STRING_CONTENT_TYPE
113
+ case ShapeType.BLOB:
114
+ return _DEFAULT_BLOB_CONTENT_TYPE
115
+ case _:
116
+ return default
117
+
118
+
119
+ class EventHeaderSerializer(SpecificShapeSerializer):
120
+ def __init__(self, headers: dict[str, HEADER_VALUE]) -> None:
121
+ self._headers = headers
122
+
123
+ def _invalid_state(
124
+ self, schema: "Schema | None" = None, message: str | None = None
125
+ ) -> Never:
126
+ if message is None:
127
+ message = f"Invalid header value type: {schema}"
128
+ raise InvalidHeaderValue(message)
129
+
130
+ def write_boolean(self, schema: "Schema", value: bool) -> None:
131
+ self._headers[schema.expect_member_name()] = value
132
+
133
+ def write_byte(self, schema: "Schema", value: int) -> None:
134
+ self._headers[schema.expect_member_name()] = Byte(value)
135
+
136
+ def write_short(self, schema: "Schema", value: int) -> None:
137
+ self._headers[schema.expect_member_name()] = Short(value)
138
+
139
+ def write_integer(self, schema: "Schema", value: int) -> None:
140
+ self._headers[schema.expect_member_name()] = value
141
+
142
+ def write_long(self, schema: "Schema", value: int) -> None:
143
+ self._headers[schema.expect_member_name()] = Long(value)
144
+
145
+ def write_string(self, schema: "Schema", value: str) -> None:
146
+ self._headers[schema.expect_member_name()] = value
147
+
148
+ def write_blob(self, schema: "Schema", value: bytes) -> None:
149
+ self._headers[schema.expect_member_name()] = value
150
+
151
+ def write_timestamp(self, schema: "Schema", value: datetime.datetime) -> None:
152
+ self._headers[schema.expect_member_name()] = value
153
+
154
+
155
+ class RawPayloadSerializer(SpecificShapeSerializer):
156
+ def __init__(self, payload: BytesIO) -> None:
157
+ self._payload = payload
158
+
159
+ def write_string(self, schema: "Schema", value: str) -> None:
160
+ self._payload.write(value.encode("utf-8"))
161
+
162
+ def write_blob(self, schema: "Schema", value: bytes) -> None:
163
+ self._payload.write(value)
164
+
165
+
166
+ class EventStreamBindingSerializer(InterceptingSerializer):
167
+ def __init__(
168
+ self,
169
+ header_serializer: EventHeaderSerializer,
170
+ payload_struct_serializer: ShapeSerializer,
171
+ ) -> None:
172
+ self._header_serializer = header_serializer
173
+ self._payload_struct_serializer = payload_struct_serializer
174
+
175
+ def before(self, schema: "Schema") -> ShapeSerializer:
176
+ if EventHeaderTrait in schema:
177
+ return self._header_serializer
178
+ return self._payload_struct_serializer
179
+
180
+ def after(self, schema: "Schema") -> None:
181
+ pass
@@ -0,0 +1,141 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Callable
6
+ from typing import Protocol
7
+
8
+ from smithy_core.aio.interfaces import AsyncByteStream, AsyncWriter
9
+ from smithy_core.aio.interfaces.eventstream import EventPublisher, EventReceiver
10
+ from smithy_core.codecs import Codec
11
+ from smithy_core.deserializers import DeserializeableShape, ShapeDeserializer
12
+ from smithy_core.exceptions import ExpectationNotMetException
13
+ from smithy_core.serializers import SerializeableShape
14
+
15
+ from .._private.deserializers import EventDeserializer as _EventDeserializer
16
+ from .._private.serializers import EventSerializer as _EventSerializer
17
+ from ..events import Event, EventHeaderEncoder, EventMessage
18
+ from ..exceptions import EventError
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class EventSigner(Protocol):
24
+ """A signer to manage credentials and EventMessages for an Event Stream lifecyle."""
25
+
26
+ async def sign_event(
27
+ self,
28
+ *,
29
+ event_message: EventMessage,
30
+ event_encoder_cls: type[EventHeaderEncoder],
31
+ ) -> EventMessage: ...
32
+
33
+
34
+ class AWSEventPublisher[E: SerializeableShape](EventPublisher[E]):
35
+ def __init__(
36
+ self,
37
+ payload_codec: Codec,
38
+ async_writer: AsyncWriter,
39
+ signer: EventSigner | None = None,
40
+ is_client_mode: bool = True,
41
+ ):
42
+ self._writer = async_writer
43
+ self._signer = signer
44
+ self._serializer = _EventSerializer(
45
+ payload_codec=payload_codec, is_client_mode=is_client_mode
46
+ )
47
+ self._closed = False
48
+
49
+ async def send(self, event: E) -> None:
50
+ if self._closed:
51
+ raise OSError("Attempted to write to closed stream.")
52
+ logger.debug("Preparing to publish event: %s", event)
53
+ event.serialize(self._serializer)
54
+ result = self._serializer.get_result()
55
+ if result is None:
56
+ raise ExpectationNotMetException(
57
+ "Expected an event message to be serialized, but was None."
58
+ )
59
+ if self._signer is not None:
60
+ encoder = self._serializer.event_header_encoder_cls
61
+ result = await self._signer.sign_event(
62
+ event_message=result,
63
+ event_encoder_cls=encoder,
64
+ )
65
+
66
+ encoded_result = result.encode()
67
+ try:
68
+ logger.debug("Publishing serialized event: %s", result)
69
+ await self._writer.write(encoded_result)
70
+ except Exception as e:
71
+ await self.close()
72
+ raise OSError("Failed to write to stream.") from e
73
+
74
+ async def close(self) -> None:
75
+ if self._closed:
76
+ return
77
+ self._closed = True
78
+
79
+ if (close := getattr(self._writer, "close", None)) is not None:
80
+ if asyncio.iscoroutine(result := close()):
81
+ await result
82
+
83
+ @property
84
+ def closed(self) -> bool:
85
+ return self._closed
86
+
87
+
88
+ class AWSEventReceiver[E: DeserializeableShape](EventReceiver[E]):
89
+ def __init__(
90
+ self,
91
+ payload_codec: Codec,
92
+ source: AsyncByteStream,
93
+ deserializer: Callable[[ShapeDeserializer], E],
94
+ is_client_mode: bool = True,
95
+ ) -> None:
96
+ self._payload_codec = payload_codec
97
+ self._source = source
98
+ self._is_client_mode = is_client_mode
99
+ self._deserializer = deserializer
100
+ self._closed = False
101
+
102
+ async def receive(self) -> E | None:
103
+ if self._closed:
104
+ return None
105
+
106
+ try:
107
+ event = await Event.decode_async(self._source)
108
+ except Exception as e:
109
+ await self.close()
110
+ if not isinstance(e, EventError):
111
+ raise OSError("Failed to read from stream.") from e
112
+ raise
113
+
114
+ if event is None:
115
+ logger.debug("No event received from the source.")
116
+ return None
117
+ logger.debug("Received raw event: %s", event)
118
+
119
+ deserializer = _EventDeserializer(
120
+ event=event,
121
+ payload_codec=self._payload_codec,
122
+ is_client_mode=self._is_client_mode,
123
+ )
124
+ result = self._deserializer(deserializer)
125
+ logger.debug("Successfully deserialized event: %s", result)
126
+ if isinstance(getattr(result, "value"), Exception):
127
+ raise result.value # type: ignore
128
+ return result
129
+
130
+ async def close(self) -> None:
131
+ if self._closed:
132
+ return
133
+ self._closed = True
134
+
135
+ if (close := getattr(self._source, "close", None)) is not None:
136
+ if asyncio.iscoroutine(result := close()):
137
+ await result
138
+
139
+ @property
140
+ def closed(self) -> bool:
141
+ return self._closed