Skip to content

Commit 0cbe31f

Browse files
committed
feat: add support for NamedTuple and TypedDict types
1 parent 3496a47 commit 0cbe31f

File tree

6 files changed

+135
-1
lines changed

6 files changed

+135
-1
lines changed

changes/2216-PrettyWood.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
add support for `NamedTuple` and `TypedDict` types

docs/usage/types.md

+8
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,17 @@ with custom properties and validation.
8585
`typing.Tuple`
8686
: see [Typing Iterables](#typing-iterables) below for more detail on parsing and validation
8787

88+
`subclass of typing.NamedTuple (or collections.namedtuple)`
89+
: Same as `tuple` but instantiates with the given namedtuple.
90+
_pydantic_ will validate the tuple if you use `typing.NamedTuple` since fields are annotated.
91+
If you use `collections.namedtuple`, no validation will be done.
92+
8893
`typing.Dict`
8994
: see [Typing Iterables](#typing-iterables) below for more detail on parsing and validation
9095

96+
`subclass of typing.TypedDict`
97+
: Same as `dict` but _pydantic_ will validate the dictionary since keys are annotated
98+
9199
`typing.Set`
92100
: see [Typing Iterables](#typing-iterables) below for more detail on parsing and validation
93101

pydantic/fields.py

+3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
get_origin,
3838
is_literal_type,
3939
is_new_type,
40+
is_typed_dict_type,
4041
new_type_supertype,
4142
)
4243
from .utils import PyObjectStr, Representation, lenient_issubclass, sequence_like, smart_deepcopy
@@ -415,6 +416,8 @@ def _type_analysis(self) -> None: # noqa: C901 (ignore complexity)
415416
return
416417
elif is_literal_type(self.type_):
417418
return
419+
elif is_typed_dict_type(self.type_):
420+
return
418421

419422
origin = get_origin(self.type_)
420423
if origin is None:

pydantic/typing.py

+14
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ def get_args(tp: Type[Any]) -> Tuple[Any, ...]:
155155
'is_literal_type',
156156
'literal_values',
157157
'Literal',
158+
'is_named_tuple_type',
159+
'is_typed_dict_type',
158160
'is_new_type',
159161
'new_type_supertype',
160162
'is_classvar',
@@ -258,6 +260,18 @@ def all_literal_values(type_: Type[Any]) -> Tuple[Any, ...]:
258260
return tuple(x for value in values for x in all_literal_values(value))
259261

260262

263+
def is_named_tuple_type(type_: Type[Any]) -> bool:
264+
from .utils import lenient_issubclass
265+
266+
return lenient_issubclass(type_, tuple) and hasattr(type_, '_fields')
267+
268+
269+
def is_typed_dict_type(type_: Type[Any]) -> bool:
270+
from .utils import lenient_issubclass
271+
272+
return lenient_issubclass(type_, dict) and getattr(type_, '__annotations__', None)
273+
274+
261275
test_type = NewType('test_type', str)
262276

263277

pydantic/validators.py

+48-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
FrozenSet,
1616
Generator,
1717
List,
18+
NamedTuple,
1819
Pattern,
1920
Set,
2021
Tuple,
@@ -34,12 +35,14 @@
3435
get_class,
3536
is_callable_type,
3637
is_literal_type,
38+
is_named_tuple_type,
39+
is_typed_dict_type,
3740
)
3841
from .utils import almost_equal_floats, lenient_issubclass, sequence_like
3942

4043
if TYPE_CHECKING:
4144
from .fields import ModelField
42-
from .main import BaseConfig
45+
from .main import BaseConfig, BaseModel
4346
from .types import ConstrainedDecimal, ConstrainedFloat, ConstrainedInt
4447

4548
ConstrainedNumber = Union[ConstrainedDecimal, ConstrainedFloat, ConstrainedInt]
@@ -523,6 +526,43 @@ def pattern_validator(v: Any) -> Pattern[str]:
523526
raise errors.PatternError()
524527

525528

529+
NamedTupleT = TypeVar('NamedTupleT', bound=NamedTuple)
530+
531+
532+
def make_named_tuple_validator(type_: Type[NamedTupleT]) -> Callable[[Tuple[Any, ...]], NamedTupleT]:
533+
from .main import create_model
534+
535+
# A named tuple can be created with `typing,NamedTuple` with types
536+
# but also with `collections.namedtuple` with just the fields
537+
# in which case we consider the type to be `Any`
538+
named_tuple_annotations: Dict[str, Type[Any]] = getattr(type_, '__annotations__', {k: Any for k in type_._fields})
539+
field_definitions: Dict[str, Any] = {
540+
field_name: (field_type, ...) for field_name, field_type in named_tuple_annotations.items()
541+
}
542+
NamedTupleModel: Type['BaseModel'] = create_model('NamedTupleModel', **field_definitions)
543+
544+
def named_tuple_validator(values: Tuple[Any, ...]) -> NamedTupleT:
545+
dict_values: Dict[str, Any] = dict(zip(named_tuple_annotations, values))
546+
validated_dict_values: Dict[str, Any] = dict(NamedTupleModel(**dict_values))
547+
return type_(**validated_dict_values)
548+
549+
return named_tuple_validator
550+
551+
552+
def make_typed_dict_validator(type_: Type[Dict[str, Any]]) -> Callable[[Any], Dict[str, Any]]:
553+
from .main import create_model
554+
555+
field_definitions: Dict[str, Any] = {
556+
field_name: (field_type, ...) for field_name, field_type in type_.__annotations__.items()
557+
}
558+
TypedDictModel: Type['BaseModel'] = create_model('TypedDictModel', **field_definitions)
559+
560+
def typed_dict_validator(values: Dict[str, Any]) -> Dict[str, Any]:
561+
return dict(TypedDictModel(**values))
562+
563+
return typed_dict_validator
564+
565+
526566
class IfConfig:
527567
def __init__(self, validator: AnyCallable, *config_attr_names: str) -> None:
528568
self.validator = validator
@@ -610,6 +650,13 @@ def find_validators( # noqa: C901 (ignore complexity)
610650
if type_ is IntEnum:
611651
yield int_enum_validator
612652
return
653+
if is_named_tuple_type(type_):
654+
yield tuple_validator
655+
yield make_named_tuple_validator(type_)
656+
return
657+
if is_typed_dict_type(type_):
658+
yield make_typed_dict_validator(type_)
659+
return
613660

614661
class_ = get_class(type_)
615662
if class_ is not None:

tests/test_main.py

+61
Original file line numberDiff line numberDiff line change
@@ -1425,3 +1425,64 @@ class M(BaseModel):
14251425
a: int
14261426

14271427
get_type_hints(M.__config__)
1428+
1429+
1430+
def test_named_tuple():
1431+
from collections import namedtuple
1432+
from typing import NamedTuple
1433+
1434+
Position = namedtuple('Pos', 'x y')
1435+
1436+
class Event(NamedTuple):
1437+
a: int
1438+
b: int
1439+
c: int
1440+
d: str
1441+
1442+
class Model(BaseModel):
1443+
pos: Position
1444+
events: List[Event]
1445+
1446+
model = Model(pos=('1', 2), events=[[b'1', '2', 3, 'qwe']])
1447+
assert isinstance(model.pos, Position)
1448+
assert isinstance(model.events[0], Event)
1449+
assert model.pos.x == '1'
1450+
assert model.pos == Position('1', 2)
1451+
assert model.events[0] == Event(1, 2, 3, 'qwe')
1452+
assert repr(model) == "Model(pos=Pos(x='1', y=2), events=[Event(a=1, b=2, c=3, d='qwe')])"
1453+
1454+
with pytest.raises(ValidationError) as exc_info:
1455+
Model(pos=('1', 2), events=[['qwe', '2', 3, 'qwe']])
1456+
assert exc_info.value.errors() == [
1457+
{
1458+
'loc': ('events', 0, 'a'),
1459+
'msg': 'value is not a valid integer',
1460+
'type': 'type_error.integer',
1461+
}
1462+
]
1463+
1464+
1465+
def test_typed_dict():
1466+
from typing import TypedDict
1467+
1468+
class TD(TypedDict):
1469+
a: int
1470+
b: int
1471+
c: int
1472+
d: str
1473+
1474+
class Model(BaseModel):
1475+
td: TD
1476+
1477+
m = Model(td={'a': '3', 'b': b'1', 'c': 4, 'd': 'qwe'})
1478+
assert m.td == {'a': 3, 'b': 1, 'c': 4, 'd': 'qwe'}
1479+
1480+
with pytest.raises(ValidationError) as exc_info:
1481+
Model(td={'a': [1], 'b': 2, 'c': 3, 'd': 'qwe'})
1482+
assert exc_info.value.errors() == [
1483+
{
1484+
'loc': ('td', 'a'),
1485+
'msg': 'value is not a valid integer',
1486+
'type': 'type_error.integer',
1487+
}
1488+
]

0 commit comments

Comments
 (0)