Skip to content

Commit a35f607

Browse files
committed
Merge branch 'develop' into user_agent
2 parents 8ea3bf2 + 7ffb90c commit a35f607

File tree

34 files changed

+608
-140
lines changed

34 files changed

+608
-140
lines changed

codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/SchemaGenerator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ private void writeTraits(PythonWriter writer, Map<ShapeId, Optional<Node>> trait
122122
writer.putContext("traits", traits);
123123
writer.write("""
124124
${#traits}
125-
Trait(id=ShapeID(${key:S})${?value}, value=${value:N}${/value}),
125+
Trait.new(id=ShapeID(${key:S})${?value}, value=${value:N}${/value}),
126126
${/traits}""");
127127
writer.popState();
128128
}

codegen/core/src/main/java/software/amazon/smithy/python/codegen/writer/PythonWriter.java

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public PythonWriter(PythonSettings settings, String fullPackageName, boolean add
6767
trimBlankLines();
6868
trimTrailingSpaces();
6969
putFormatter('T', new PythonSymbolFormatter());
70-
putFormatter('N', new PythonNodeFormatter());
70+
putFormatter('N', new PythonNodeFormatter(this));
7171
this.addCodegenWarningHeader = addCodegenWarningHeader;
7272
}
7373

@@ -325,6 +325,12 @@ private Boolean isOperationSymbol(Symbol typeSymbol) {
325325
}
326326

327327
private final class PythonNodeFormatter implements BiFunction<Object, String, String> {
328+
private final PythonWriter writer;
329+
330+
PythonNodeFormatter(PythonWriter writer) {
331+
this.writer = writer;
332+
}
333+
328334
@Override
329335
public String apply(Object node, String indent) {
330336
if (node instanceof Optional<?>) {
@@ -334,16 +340,18 @@ public String apply(Object node, String indent) {
334340
throw new CodegenException(
335341
"Invalid type provided to $D. Expected a Node, but found `" + node + "`");
336342
}
337-
return ((Node) node).accept(new PythonNodeFormatVisitor(indent));
343+
return ((Node) node).accept(new PythonNodeFormatVisitor(indent, writer));
338344
}
339345
}
340346

341347
private final class PythonNodeFormatVisitor implements NodeVisitor<String> {
342348

343349
private String indent;
350+
private final PythonWriter writer;
344351

345-
PythonNodeFormatVisitor(String indent) {
352+
PythonNodeFormatVisitor(String indent, PythonWriter writer) {
346353
this.indent = indent;
354+
this.writer = writer;
347355
}
348356

349357
@Override
@@ -379,10 +387,10 @@ public String stringNode(StringNode node) {
379387
@Override
380388
public String arrayNode(ArrayNode node) {
381389
if (node.getElements().isEmpty()) {
382-
return "[]";
390+
return "()";
383391
}
384392

385-
StringBuilder builder = new StringBuilder("[\n");
393+
StringBuilder builder = new StringBuilder("(\n");
386394
var oldIndent = indent;
387395
indent += getIndentText();
388396
for (Node element : node.getElements()) {
@@ -392,17 +400,18 @@ public String arrayNode(ArrayNode node) {
392400
}
393401
indent = oldIndent;
394402
builder.append(indent);
395-
builder.append(']');
403+
builder.append(')');
396404
return builder.toString();
397405
}
398406

399407
@Override
400408
public String objectNode(ObjectNode node) {
409+
writer.addStdlibImport("types", "MappingProxyType");
401410
if (node.getMembers().isEmpty()) {
402-
return "{}";
411+
return "MappingProxyType({})";
403412
}
404413

405-
StringBuilder builder = new StringBuilder("{\n");
414+
StringBuilder builder = new StringBuilder("MappingProxyType({\n");
406415
var oldIndent = indent;
407416
indent += getIndentText();
408417
for (Map.Entry<StringNode, Node> member : node.getMembers().entrySet()) {
@@ -414,7 +423,7 @@ public String objectNode(ObjectNode node) {
414423
}
415424
indent = oldIndent;
416425
builder.append(indent);
417-
builder.append('}');
426+
builder.append("})");
418427
return builder.toString();
419428
}
420429
}

designs/serialization.md

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,27 +80,28 @@ implementation and/or additional helper methods.
8080
class Schema:
8181
id: ShapeID
8282
shape_type: ShapeType
83-
traits: dict[ShapeID, "Trait"] = field(default_factory=dict)
83+
traits: dict[ShapeID, "Trait | DynamicTrait"] = field(default_factory=dict)
8484
members: dict[str, "Schema"] = field(default_factory=dict)
8585
member_target: "Schema | None" = None
8686
member_index: int | None = None
8787

88+
@overload
89+
def get_trait[T: "Trait"](self, t: type[T]) -> T | None: ...
90+
@overload
91+
def get_trait(self, t: ShapeID) -> "Trait | DynamicTrait | None": ...
92+
def get_trait(self, t: "type[Trait] | ShapeID") -> "Trait | DynamicTrait | None":\
93+
return self.traits.get(t if isinstance(t, ShapeID) else t.id)
94+
8895
@classmethod
8996
def collection(
9097
cls,
9198
*,
9299
id: ShapeID,
93100
shape_type: ShapeType = ShapeType.STRUCTURE,
94-
traits: list["Trait"] | None = None,
101+
traits: list["Trait | DynamicTrait"] | None = None,
95102
members: Mapping[str, "MemberSchema"] | None = None,
96103
) -> Self:
97104
...
98-
99-
100-
@dataclass(kw_only=True, frozen=True)
101-
class Trait:
102-
id: "ShapeID"
103-
value: "DocumentValue" = field(default_factory=dict)
104105
```
105106

106107
Below is an example Smithy `structure` shape, followed by the `Schema` it would
@@ -122,13 +123,112 @@ EXAMPLE_STRUCTURE_SCHEMA = Schema.collection(
122123
"target": INTEGER,
123124
"index": 0,
124125
"traits": [
125-
Trait(id=ShapeID("smithy.api#default"), value=0),
126+
DefaultTrait(0),
126127
],
127128
},
128129
},
129130
)
130131
```
131132

133+
### Traits
134+
135+
Traits are model components that can be attached to shapes to describe
136+
additional information about the shape; shapes provide the structure and layout
137+
of an API, while traits provide refinement and style. Smithy provides a number
138+
of built-in traits, plus a number of additional traits that may be found in
139+
first-party dependencies. In addition to those first-party traits, traits may be
140+
defined externally.
141+
142+
In Python, there are two kinds of traits. The first is the `DynamicTrait`. This
143+
represents traits that have no known associated Python class. Traits not defined
144+
by Smithy itself may be unknown, for example, but still need representation.
145+
146+
The other kind of trait inherits from the `Trait` class. This represents known
147+
traits, such as those defined by Smithy itself or those defined externally but
148+
made available in Python. Since these are concrete classes, they may be more
149+
comfortable to use, providing better typed accessors to data or even relevant
150+
utility functions.
151+
152+
Both kinds of traits implement an inherent `Protocol` - they both have the `id`
153+
and `document_value` properties with identical type signatures. This allows them
154+
to be used interchangeably for those that don't care about the concrete types.
155+
It also allows concrete types to be introduced later without a breaking change.
156+
157+
158+
```python
159+
@dataclass(kw_only=True, frozen=True, slots=True)
160+
class DynamicTrait:
161+
id: ShapeID
162+
document_value: DocumentValue = None
163+
164+
165+
@dataclass(init=False, frozen=True)
166+
class Trait:
167+
168+
_REGISTRY: ClassVar[dict[ShapeID, type["Trait"]]] = {}
169+
170+
id: ClassVar[ShapeID]
171+
172+
document_value: DocumentValue = None
173+
174+
def __init_subclass__(cls, id: ShapeID) -> None:
175+
cls.id = id
176+
Trait._REGISTRY[id] = cls
177+
178+
def __init__(self, value: DocumentValue | DynamicTrait = None):
179+
if type(self) is Trait:
180+
raise TypeError(
181+
"Only subclasses of Trait may be directly instantiated. "
182+
"Use DynamicTrait for traits without a concrete class."
183+
)
184+
185+
if isinstance(value, DynamicTrait):
186+
if value.id != self.id:
187+
raise ValueError(
188+
f"Attempted to instantiate an instance of {type(self)} from an "
189+
f"invalid ID. Expected {self.id} but found {value.id}."
190+
)
191+
# Note that setattr is needed because it's a frozen (read-only) dataclass
192+
object.__setattr__(self, "document_value", value.document_value)
193+
else:
194+
object.__setattr__(self, "document_value", value)
195+
196+
# Dynamically creates a subclass instance based on the trait id
197+
@staticmethod
198+
def new(id: ShapeID, value: "DocumentValue" = None) -> "Trait | DynamicTrait":
199+
if (cls := Trait._REGISTRY.get(id, None)) is not None:
200+
return cls(value)
201+
return DynamicTrait(id=id, document_value=value)
202+
```
203+
204+
The `Trait` class implements a dynamic registry that allows it to know about
205+
trait implementations automatically. The base class maintains a mapping of trait
206+
ID to the trait class. Since implementations must all share the same constructor
207+
signature, it can then use that registry to dynamically construct concrete types
208+
it knows about in the `new` factory method with a fallback to `DynamicTrait`.
209+
210+
The `new` factory method will be used to construct traits when `Schema`s are
211+
generated, so any generated schemas will be able to take advantage of the
212+
registry.
213+
214+
Below is an example of a `Trait` implementation.
215+
216+
```python
217+
@dataclass(init=False, frozen=True)
218+
class TimestampFormatTrait(Trait, id=ShapeID("smithy.api#timestampFormat")):
219+
format: TimestampFormat
220+
221+
def __init__(self, value: "DocumentValue | DynamicTrait" = None):
222+
super().__init__(value)
223+
assert isinstance(self.document_value, str)
224+
object.__setattr__(self, "format", TimestampFormat(self.document_value))
225+
```
226+
227+
Data in traits is intended to be immutable, so both `DynamicTrait` and `Trait`
228+
are dataclasses with `frozen=True`, and all implementations of `Trait` must also
229+
use that argument. This can be worked around during `__init__` using
230+
`object.__setattr__` to set any additional properties the `Trait` defines.
231+
132232
## Shape Serializers and Serializeable Shapes
133233

134234
Serialization will function by the interaction of two interfaces:

packages/aws-event-stream/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "aws-event-stream"
3-
version = "0.1.0"
3+
version = "0.0.1"
44
description = "Core Smithy components for AWS services and protocols."
55
readme = "README.md"
66
requires-python = ">=3.12"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
3+
4+
import importlib.metadata
5+
6+
__version__: str = importlib.metadata.version("aws-event-stream")

packages/aws-event-stream/src/aws_event_stream/_private/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
# SPDX-License-Identifier: Apache-2.0
33
from smithy_core.schemas import Schema
44

5-
from .traits import EVENT_PAYLOAD_TRAIT
5+
from smithy_core.traits import EventPayloadTrait
66

77
INITIAL_REQUEST_EVENT_TYPE = "initial-request"
88
INITIAL_RESPONSE_EVENT_TYPE = "initial-response"
99

1010

1111
def get_payload_member(schema: Schema) -> Schema | None:
1212
for member in schema.members.values():
13-
if EVENT_PAYLOAD_TRAIT in member.traits:
13+
if EventPayloadTrait in member:
1414
return member
1515
return None

packages/aws-event-stream/src/aws_event_stream/_private/deserializers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
INITIAL_RESPONSE_EVENT_TYPE,
2424
get_payload_member,
2525
)
26-
from .traits import EVENT_HEADER_TRAIT
26+
from smithy_core.traits import EventHeaderTrait
2727

2828
INITIAL_MESSAGE_TYPES = (INITIAL_REQUEST_EVENT_TYPE, INITIAL_RESPONSE_EVENT_TYPE)
2929

@@ -158,7 +158,7 @@ def read_struct(
158158
headers_deserializer = EventHeaderDeserializer(self._headers)
159159
for key in self._headers.keys():
160160
member_schema = schema.members.get(key)
161-
if member_schema is not None and EVENT_HEADER_TRAIT in member_schema.traits:
161+
if member_schema is not None and EventHeaderTrait in member_schema:
162162
consumer(member_schema, headers_deserializer)
163163

164164
if self._payload_deserializer:

packages/aws-event-stream/src/aws_event_stream/_private/serializers.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
SpecificShapeSerializer,
1919
)
2020
from smithy_core.shapes import ShapeType
21-
from smithy_core.utils import expect_type
2221
from smithy_event_stream.aio.interfaces import AsyncEventPublisher
2322

2423
from ..events import EventHeaderEncoder, EventMessage
@@ -28,7 +27,7 @@
2827
INITIAL_RESPONSE_EVENT_TYPE,
2928
get_payload_member,
3029
)
31-
from .traits import ERROR_TRAIT, EVENT_HEADER_TRAIT, MEDIA_TYPE_TRAIT
30+
from smithy_core.traits import ErrorTrait, EventHeaderTrait, MediaTypeTrait
3231

3332
_DEFAULT_STRING_CONTENT_TYPE = "text/plain"
3433
_DEFAULT_BLOB_CONTENT_TYPE = "application/octet-stream"
@@ -103,7 +102,7 @@ def begin_struct(self, schema: "Schema") -> Iterator[ShapeSerializer]:
103102

104103
headers_encoder = EventHeaderEncoder()
105104

106-
if ERROR_TRAIT in schema.traits:
105+
if ErrorTrait in schema:
107106
headers_encoder.encode_string(":message-type", "exception")
108107
headers_encoder.encode_string(
109108
":exception-type", schema.expect_member_name()
@@ -146,8 +145,8 @@ def begin_struct(self, schema: "Schema") -> Iterator[ShapeSerializer]:
146145
)
147146

148147
def _get_payload_media_type(self, schema: Schema, default: str) -> str:
149-
if (media_type := schema.traits.get(MEDIA_TYPE_TRAIT)) is not None:
150-
return expect_type(str, media_type.value)
148+
if (media_type := schema.get_trait(MediaTypeTrait)) is not None:
149+
return media_type.value
151150

152151
match schema.shape_type:
153152
case ShapeType.STRING:
@@ -215,7 +214,7 @@ def __init__(
215214
self._payload_struct_serializer = payload_struct_serializer
216215

217216
def before(self, schema: "Schema") -> ShapeSerializer:
218-
if EVENT_HEADER_TRAIT in schema.traits:
217+
if EventHeaderTrait in schema:
219218
return self._header_serializer
220219
return self._payload_struct_serializer
221220

packages/aws-event-stream/src/aws_event_stream/_private/traits.py

Lines changed: 0 additions & 9 deletions
This file was deleted.

packages/aws-event-stream/tests/unit/_private/__init__.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,21 @@
1919
from smithy_core.schemas import Schema
2020
from smithy_core.serializers import ShapeSerializer
2121
from smithy_core.shapes import ShapeID, ShapeType
22-
from smithy_core.traits import Trait
22+
from smithy_core.traits import (
23+
EventHeaderTrait,
24+
EventPayloadTrait,
25+
ErrorTrait,
26+
RequiredTrait,
27+
StreamingTrait,
28+
)
2329

2430
from aws_event_stream.events import Byte, EventMessage, Long, Short
2531

26-
EVENT_HEADER_TRAIT = Trait(id=ShapeID("smithy.api#eventHeader"))
27-
EVENT_PAYLOAD_TRAIT = Trait(id=ShapeID("smithy.api#eventPayload"))
28-
ERROR_TRAIT = Trait(id=ShapeID("smithy.api#error"), value="client")
29-
REQUIRED_TRAIT = Trait(id=ShapeID("smithy.api#required"))
30-
STREAMING_TRAIT = Trait(id=ShapeID("smith.api#streaming"))
32+
EVENT_HEADER_TRAIT = EventHeaderTrait()
33+
EVENT_PAYLOAD_TRAIT = EventPayloadTrait()
34+
ERROR_TRAIT = ErrorTrait("client")
35+
REQUIRED_TRAIT = RequiredTrait()
36+
STREAMING_TRAIT = StreamingTrait()
3137

3238

3339
SCHEMA_MESSAGE_EVENT = Schema.collection(

0 commit comments

Comments
 (0)