Skip to content

Commit a66363c

Browse files
Add typed properties
This adds a typed properties bag, which is essentially a dict with some extra type forwarding built in.
1 parent d36527f commit a66363c

File tree

5 files changed

+310
-8
lines changed

5 files changed

+310
-8
lines changed

packages/smithy-core/src/smithy_core/interfaces/__init__.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
33
from asyncio import iscoroutinefunction
4-
from typing import Protocol, runtime_checkable, Any, TypeGuard
4+
from typing import (
5+
Protocol,
6+
runtime_checkable,
7+
Any,
8+
TypeGuard,
9+
overload,
10+
Iterator,
11+
KeysView,
12+
ValuesView,
13+
ItemsView,
14+
)
515

616

717
class URI(Protocol):
@@ -99,3 +109,91 @@ class Endpoint(Protocol):
99109
For example, in some AWS use cases this might contain HTTP headers to add to each
100110
request.
101111
"""
112+
113+
114+
@runtime_checkable
115+
class PropertyKey[T](Protocol):
116+
"""A typed properties key.
117+
118+
Used with :py:class:`Context` to set and get typed values.
119+
120+
For a concrete implementation, see :py:class:`smithy_core.types.PropertyKey`.
121+
"""
122+
123+
key: str
124+
"""The string key used to access the value."""
125+
126+
value_type: type[T]
127+
"""The type of the associated value in the properties bag."""
128+
129+
def __str__(self) -> str:
130+
return self.key
131+
132+
133+
# This is currently strongly tied to being compatible with a dict[str, Any], but we
134+
# could remove that to allow for potentially more efficient maps. That might introduce
135+
# unacceptable usability penalties or footguns though.
136+
@runtime_checkable
137+
class TypedProperties(Protocol):
138+
"""A properties map with typed setters and getters.
139+
140+
Keys can be either a string or a :py:class:`PropertyKey`. Using a PropertyKey instead
141+
of a string enables type checkers to narrow to the associated value type rather
142+
than having to use Any.
143+
144+
Letting the value be either a string or PropertyKey allows consumers who care about
145+
typing to get it, and those who don't care about typing to not have to think about
146+
it.
147+
148+
..code-block:: python
149+
150+
foo = PropertyKey(key="foo", value_type=str)
151+
properties = TypedProperties()
152+
properties[foo] = "bar"
153+
154+
assert assert_type(properties[foo], str) == "bar
155+
assert assert_type(properties["foo"], Any) == "bar
156+
157+
158+
For a concrete implementation, see :py:class:`smithy_core.types.TypedProperties`.
159+
"""
160+
161+
@overload
162+
def __getitem__[T](self, key: PropertyKey[T]) -> T: ...
163+
@overload
164+
def __getitem__(self, key: str) -> Any: ...
165+
166+
@overload
167+
def __setitem__[T](self, key: PropertyKey[T], value: T) -> None: ...
168+
@overload
169+
def __setitem__(self, key: str, value: Any) -> None: ...
170+
171+
def __delitem__(self, key: str | PropertyKey[Any]) -> None: ...
172+
173+
@overload
174+
def get[T](self, key: PropertyKey[T], default: None = None) -> T | None: ...
175+
@overload
176+
def get[T](self, key: PropertyKey[T], default: T) -> T: ...
177+
@overload
178+
def get[T, DT](self, key: PropertyKey[T], default: DT) -> T | DT: ...
179+
@overload
180+
def get(self, key: str, default: None = None) -> Any | None: ...
181+
@overload
182+
def get[T](self, key: str, default: T) -> Any | T: ...
183+
184+
@overload
185+
def pop[T](self, key: PropertyKey[T], default: None = None) -> T | None: ...
186+
@overload
187+
def pop[T](self, key: PropertyKey[T], default: T) -> T: ...
188+
@overload
189+
def pop[T, DT](self, key: PropertyKey[T], default: DT) -> T | DT: ...
190+
@overload
191+
def pop(self, key: str, default: None = None) -> Any | None: ...
192+
@overload
193+
def pop[T](self, key: str, default: T) -> Any | T: ...
194+
195+
def __iter__(self) -> Iterator[str]: ...
196+
def items(self) -> ItemsView[str, Any]: ...
197+
def keys(self) -> KeysView[str]: ...
198+
def values(self) -> ValuesView[Any]: ...
199+
def __contains__(self, key: object) -> bool: ...

packages/smithy-core/src/smithy_core/types.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
# SPDX-License-Identifier: Apache-2.0
33
import json
44
import re
5+
import sys
6+
from collections import UserDict
57
from collections.abc import Mapping, Sequence
68
from datetime import datetime
79
from email.utils import format_datetime, parsedate_to_datetime
810
from enum import Enum
9-
from typing import Any
11+
from typing import Any, overload
1012
from dataclasses import dataclass
1113

1214
from .exceptions import ExpectationNotMetException
@@ -17,6 +19,8 @@
1719
serialize_epoch_seconds,
1820
serialize_rfc3339,
1921
)
22+
from .interfaces import PropertyKey as _PropertyKey
23+
from .interfaces import TypedProperties as _TypedProperties
2024

2125
_GREEDY_LABEL_RE = re.compile(r"\{(\w+)\+\}")
2226

@@ -153,3 +157,99 @@ def format(self, *args: object, **kwargs: str) -> str:
153157
f'Path must not contain empty segments, but was "{result}".'
154158
)
155159
return result
160+
161+
162+
@dataclass(kw_only=True, frozen=True, slots=True, init=False)
163+
class PropertyKey[T](_PropertyKey[T]):
164+
"""A typed property key."""
165+
166+
key: str
167+
"""The string key used to access the value."""
168+
169+
value_type: type[T]
170+
"""The type of the associated value in the property bag."""
171+
172+
def __init__(self, *, key: str, value_type: type[T]) -> None:
173+
# Intern the key to speed up dict access
174+
object.__setattr__(self, "key", sys.intern(key))
175+
object.__setattr__(self, "value_type", value_type)
176+
177+
178+
class TypedProperties(UserDict[str, Any], _TypedProperties):
179+
"""A map with typed setters and getters.
180+
181+
Keys can be either a string or a :py:class:`smithy_core.interfaces.PropertyKey`.
182+
Using a PropertyKey instead of a string enables type checkers to narrow to the
183+
associated value type rather than having to use Any.
184+
185+
Letting the value be either a string or PropertyKey allows consumers who care about
186+
typing to get it, and those who don't care about typing to not have to think about
187+
it.
188+
189+
..code-block:: python
190+
191+
foo = PropertyKey(key="foo", value_type=str)
192+
properties = TypedProperties()
193+
properties[foo] = "bar"
194+
195+
assert assert_type(properties[foo], str) == "bar
196+
assert assert_type(properties["foo"], Any) == "bar
197+
"""
198+
199+
@overload
200+
def __getitem__[T](self, key: _PropertyKey[T]) -> T: ...
201+
@overload
202+
def __getitem__(self, key: str) -> Any: ...
203+
def __getitem__(self, key: str | _PropertyKey[Any]) -> Any:
204+
return self.data[key if isinstance(key, str) else key.key]
205+
206+
@overload
207+
def __setitem__[T](self, key: _PropertyKey[T], value: T) -> None: ...
208+
@overload
209+
def __setitem__(self, key: str, value: Any) -> None: ...
210+
def __setitem__(self, key: str | _PropertyKey[Any], value: Any) -> None:
211+
if isinstance(key, _PropertyKey):
212+
if not isinstance(value, key.value_type):
213+
raise ValueError(
214+
f"Expected value type of {key.value_type}, but was {type(value)}"
215+
)
216+
key = key.key
217+
self.data[key] = value
218+
219+
def __delitem__(self, key: str | _PropertyKey[Any]) -> None:
220+
del self.data[key if isinstance(key, str) else key.key]
221+
222+
def __contains__(self, key: object) -> bool:
223+
return super().__contains__(key.key if isinstance(key, _PropertyKey) else key)
224+
225+
@overload
226+
def get[T](self, key: _PropertyKey[T], default: None = None) -> T | None: ...
227+
@overload
228+
def get[T](self, key: _PropertyKey[T], default: T) -> T: ...
229+
@overload
230+
def get[T, DT](self, key: _PropertyKey[T], default: DT) -> T | DT: ...
231+
@overload
232+
def get(self, key: str, default: None = None) -> Any | None: ...
233+
@overload
234+
def get[T](self, key: str, default: T) -> Any | T: ...
235+
236+
# pyright has trouble detecting compatible overrides when both the superclass
237+
# and subclass have overloads.
238+
def get(self, key: str | _PropertyKey[Any], default: Any = None) -> Any: # type: ignore
239+
return self.data.get(key if isinstance(key, str) else key.key, default)
240+
241+
@overload
242+
def pop[T](self, key: _PropertyKey[T], default: None = None) -> T | None: ...
243+
@overload
244+
def pop[T](self, key: _PropertyKey[T], default: T) -> T: ...
245+
@overload
246+
def pop[T, DT](self, key: _PropertyKey[T], default: DT) -> T | DT: ...
247+
@overload
248+
def pop(self, key: str, default: None = None) -> Any | None: ...
249+
@overload
250+
def pop[T](self, key: str, default: T) -> Any | T: ...
251+
252+
# pyright has trouble detecting compatible overrides when both the superclass
253+
# and subclass have overloads.
254+
def pop(self, key: str | _PropertyKey[Any], default: Any = None) -> Any: # type: ignore
255+
return self.data.pop(key if isinstance(key, str) else key.key, default)

packages/smithy-core/tests/unit/test_types.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@
33

44
# pyright: reportPrivateUsage=false
55
from datetime import UTC, datetime
6+
from typing import Any, assert_type
67

78
import pytest
89

910
from smithy_core.exceptions import ExpectationNotMetException
10-
from smithy_core.types import JsonBlob, JsonString, TimestampFormat, PathPattern
11+
from smithy_core.types import (
12+
JsonBlob,
13+
JsonString,
14+
TimestampFormat,
15+
PathPattern,
16+
PropertyKey,
17+
TypedProperties,
18+
)
1119

1220

1321
def test_json_string() -> None:
@@ -219,3 +227,99 @@ def test_path_pattern_disallows_empty_segments(greedy: bool, value: str):
219227
pattern = PathPattern("/{foo+}/" if greedy else "/{foo}/")
220228
with pytest.raises(ValueError):
221229
pattern.format(foo=value)
230+
231+
232+
def test_properties_typed_get() -> None:
233+
foo_key = PropertyKey(key="foo", value_type=str)
234+
properties = TypedProperties()
235+
properties[foo_key] = "bar"
236+
237+
assert assert_type(properties[foo_key], str) == "bar"
238+
assert assert_type(properties["foo"], Any) == "bar"
239+
240+
assert assert_type(properties.get(foo_key), str | None) == "bar"
241+
assert assert_type(properties.get(foo_key, "spam"), str) == "bar"
242+
assert assert_type(properties.get(foo_key, 1), str | int) == "bar"
243+
244+
assert assert_type(properties.get("foo"), Any | None) == "bar"
245+
assert assert_type(properties.get("foo", "spam"), Any | str) == "bar"
246+
assert assert_type(properties.get("foo", 1), Any | int) == "bar"
247+
248+
baz_key = PropertyKey(key="baz", value_type=str)
249+
assert assert_type(properties.get(baz_key), str | None) is None
250+
assert assert_type(properties.get(baz_key, "spam"), str) == "spam"
251+
assert assert_type(properties.get(baz_key, 1), str | int) == 1
252+
253+
assert assert_type(properties.get("baz"), Any | None) is None
254+
assert assert_type(properties.get("baz", "spam"), Any | str) == "spam"
255+
assert assert_type(properties.get("baz", 1), Any | int) == 1
256+
257+
258+
def test_properties_typed_set() -> None:
259+
foo_key = PropertyKey(key="foo", value_type=str)
260+
properties = TypedProperties()
261+
262+
properties[foo_key] = "foo"
263+
assert properties.data["foo"] == "foo"
264+
265+
with pytest.raises(ValueError):
266+
properties[foo_key] = b"foo" # type: ignore
267+
268+
269+
def test_properties_del() -> None:
270+
foo_key = PropertyKey(key="foo", value_type=str)
271+
properties = TypedProperties()
272+
properties[foo_key] = "bar"
273+
274+
assert "foo" in properties.data
275+
del properties[foo_key]
276+
assert "foo" not in properties.data
277+
278+
properties[foo_key] = "bar"
279+
280+
assert "foo" in properties.data
281+
del properties["foo"]
282+
assert "foo" not in properties.data
283+
284+
285+
def test_properties_contains() -> None:
286+
foo_key = PropertyKey(key="foo", value_type=str)
287+
bar_key = PropertyKey(key="bar", value_type=str)
288+
properties = TypedProperties()
289+
properties[foo_key] = "bar"
290+
291+
assert "foo" in properties
292+
assert foo_key in properties
293+
assert "bar" not in properties
294+
assert bar_key not in properties
295+
296+
297+
def test_properties_typed_pop() -> None:
298+
foo_key = PropertyKey(key="foo", value_type=str)
299+
properties = TypedProperties()
300+
301+
properties[foo_key] = "bar"
302+
assert assert_type(properties.pop(foo_key), str | None) == "bar"
303+
assert "foo" not in properties.data
304+
305+
properties[foo_key] = "bar"
306+
assert assert_type(properties.pop(foo_key, "foo"), str) == "bar"
307+
assert "foo" not in properties.data
308+
309+
properties[foo_key] = "bar"
310+
assert assert_type(properties.pop(foo_key, 1), str | int) == "bar"
311+
assert "foo" not in properties.data
312+
313+
properties[foo_key] = "bar"
314+
assert assert_type(properties.pop("foo"), Any | None) == "bar"
315+
assert "foo" not in properties.data
316+
317+
properties[foo_key] = "bar"
318+
assert assert_type(properties.pop("foo", "baz"), Any | str) == "bar"
319+
assert "foo" not in properties.data
320+
321+
properties[foo_key] = "bar"
322+
assert assert_type(properties.pop("foo", 1), Any | int) == "bar"
323+
assert "foo" not in properties.data
324+
325+
assert properties.pop(foo_key) is None

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ dependencies = []
1010
dev = [
1111
"black>=25.1.0",
1212
"docformatter>=1.7.5",
13-
"pyright>=1.1.394",
13+
"pyright>=1.1.396",
1414
"pytest>=8.3.4",
1515
"pytest-asyncio>=0.25.3",
1616
"pytest-cov>=6.0.0",

uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)