Skip to content

Commit d77909f

Browse files
author
desert
committed
Add support for Tuple[int, ...].
1 parent 073a40e commit d77909f

File tree

3 files changed

+60
-4
lines changed

3 files changed

+60
-4
lines changed

changelog.d/16.change

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for Tuple[int, ...] per https://docs.python.org/3/library/typing.html#typing.Tuple

src/desert/_make.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class User:
6060
import datetime
6161
import decimal
6262
import inspect
63+
import typing as t
6364
import typing
6465
import uuid
6566
from enum import Enum
@@ -75,7 +76,6 @@ class User:
7576
from typing import Type
7677
from typing import Union
7778
from typing import cast
78-
7979
import attr
8080
import marshmallow
8181
import typing_inspect
@@ -86,6 +86,7 @@ class User:
8686
__all__ = ["dataclass", "add_schema", "class_schema", "field_for_schema"]
8787

8888
NoneType = type(None)
89+
T = t.TypeVar("T")
8990

9091

9192
def class_schema(clazz: type, meta: Dict[str, Any] = {}) -> Type[marshmallow.Schema]:
@@ -157,6 +158,19 @@ def class_schema(clazz: type, meta: Dict[str, Any] = {}) -> Type[marshmallow.Sch
157158
}
158159

159160

161+
class VariadicTuple(marshmallow.fields.List):
162+
"""Homogenous tuple with variable number of entries."""
163+
164+
def _deserialize(self, *args, **kwargs):
165+
return tuple(super()._deserialize(*args, **kwargs))
166+
167+
168+
def only(items: t.Iterable[T]) -> T:
169+
"""Return the only item in an iterable or raise ValueError."""
170+
[x] = items
171+
return x
172+
173+
160174
def field_for_schema(
161175
typ: type, default=marshmallow.missing, metadata: Mapping[str, Any] = None
162176
) -> marshmallow.fields.Field:
@@ -201,7 +215,7 @@ def field_for_schema(
201215

202216
if default is not marshmallow.missing:
203217
desert_metadata.setdefault("default", default)
204-
desert_metadata.setdefault('allow_none', True)
218+
desert_metadata.setdefault("allow_none", True)
205219
if not desert_metadata.get(
206220
"required"
207221
): # 'missing' must not be set for required fields.
@@ -223,14 +237,22 @@ def field_for_schema(
223237

224238
# Generic types
225239
origin = typing_inspect.get_origin(typ)
240+
226241
if origin:
227242
arguments = typing_inspect.get_args(typ, True)
243+
228244
if origin in (list, List):
229245
field = marshmallow.fields.List(field_for_schema(arguments[0]))
230-
if origin in (tuple, Tuple):
246+
247+
if origin in (tuple, t.Tuple) and Ellipsis not in arguments:
231248
field = marshmallow.fields.Tuple(
232249
tuple(field_for_schema(arg) for arg in arguments)
233250
)
251+
elif origin in (tuple, t.Tuple) and Ellipsis in arguments:
252+
253+
field = VariadicTuple(
254+
field_for_schema(only(arg for arg in arguments if arg != Ellipsis))
255+
)
234256
elif origin in (dict, Dict):
235257
field = marshmallow.fields.Dict(
236258
keys=field_for_schema(arguments[0]),
@@ -243,7 +265,6 @@ def field_for_schema(
243265
metadata[_DESERT_SENTINEL]["missing"] = metadata.get("missing", None)
244266
metadata[_DESERT_SENTINEL]["required"] = False
245267

246-
247268
field = field_for_schema(subtyp, metadata=metadata, default=None)
248269
field.default = None
249270
field.missing = None

tests/test_make.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ class A:
137137
data = desert.schema_class(A)().load({})
138138
assert data == A(None)
139139

140+
140141
def test_optional_present(module):
141142
"""Setting an optional type allows passing None."""
142143

@@ -380,3 +381,36 @@ class A:
380381

381382
with pytest.raises(desert.exceptions.UnknownType):
382383
desert.schema_class(A)
384+
385+
386+
def test_tuple_ellipsis(module):
387+
"""Tuple with ellipsis allows variable length tuple.
388+
389+
See :class:`typing.Tuple`.
390+
"""
391+
392+
@module.dataclass
393+
class A:
394+
x: t.Tuple[int, ...]
395+
396+
schema = desert.schema_class(A)()
397+
dumped = {"x": (1, 2, 3)}
398+
loaded = A(x=(1, 2, 3))
399+
400+
assert schema.load(dumped) == loaded
401+
assert schema.dump(loaded) == {"x": [1, 2, 3]}
402+
assert schema.loads(schema.dumps(loaded)) == loaded
403+
404+
405+
def test_only():
406+
"""only() extracts the only item in an iterable."""
407+
assert desert._make.only([1]) == 1
408+
409+
410+
def test_only_raises():
411+
"""only() raises if the iterable has an unexpected number of entries.'"""
412+
with pytest.raises(ValueError):
413+
desert._make.only([])
414+
415+
with pytest.raises(ValueError):
416+
desert._make.only([1, 2])

0 commit comments

Comments
 (0)