New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add support for NamedTuple
and TypedDict
types
#2216
Changes from 4 commits
0cbe31f
c5e236a
0f5cb59
de54b65
1c1b499
986feda
a7bf673
6764357
62b4feb
7b45015
7368bf9
63f4a1e
ebfd440
4cb9dbd
58dbfd7
5663cd3
11bd960
8221ce2
0a250e1
3361496
d737f07
3fe8ff9
08868ab
567bf4d
9481776
1c6f369
5db83e9
4da1475
1e60f5a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
add support for `NamedTuple` and `TypedDict` types |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -85,9 +85,17 @@ with custom properties and validation. | |||||
`typing.Tuple` | ||||||
: see [Typing Iterables](#typing-iterables) below for more detail on parsing and validation | ||||||
|
||||||
`subclass of typing.NamedTuple (or collections.namedtuple)` | ||||||
: Same as `tuple` but instantiates with the given namedtuple. | ||||||
_pydantic_ will validate the tuple if you use `typing.NamedTuple` since fields are annotated. | ||||||
If you use `collections.namedtuple`, no validation will be done. | ||||||
|
||||||
`typing.Dict` | ||||||
: see [Typing Iterables](#typing-iterables) below for more detail on parsing and validation | ||||||
|
||||||
`subclass of typing.TypedDict` | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
I think the subclass is implicit There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It used to be the case for me too but I now tend to disagree since we added the support of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would still prefer to remove |
||||||
: Same as `dict` but _pydantic_ will validate the dictionary since keys are annotated | ||||||
|
||||||
`typing.Set` | ||||||
: see [Typing Iterables](#typing-iterables) below for more detail on parsing and validation | ||||||
|
||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -155,6 +155,8 @@ def get_args(tp: Type[Any]) -> Tuple[Any, ...]: | |
'is_literal_type', | ||
'literal_values', | ||
'Literal', | ||
'is_named_tuple_type', | ||
'is_typed_dict_type', | ||
'is_new_type', | ||
'new_type_supertype', | ||
'is_classvar', | ||
|
@@ -258,6 +260,18 @@ def all_literal_values(type_: Type[Any]) -> Tuple[Any, ...]: | |
return tuple(x for value in values for x in all_literal_values(value)) | ||
|
||
|
||
def is_named_tuple_type(type_: Type[Any]) -> bool: | ||
from .utils import lenient_issubclass | ||
|
||
return lenient_issubclass(type_, tuple) and hasattr(type_, '_fields') | ||
|
||
|
||
def is_typed_dict_type(type_: Type[Any]) -> bool: | ||
from .utils import lenient_issubclass | ||
|
||
return lenient_issubclass(type_, dict) and getattr(type_, '__annotations__', None) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Humm, I wonder if this is a water tight check. I can't think of a better solution though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently there is no easier way afaik. There will be a public method in 3.10 though |
||
|
||
|
||
test_type = NewType('test_type', str) | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,19 @@ | ||
import sys | ||
from collections import namedtuple | ||
from enum import Enum | ||
from typing import Any, Callable, ClassVar, Dict, List, Mapping, Optional, Type, get_type_hints | ||
from typing import Any, Callable, ClassVar, Dict, List, Mapping, NamedTuple, Optional, Type, get_type_hints | ||
from uuid import UUID, uuid4 | ||
|
||
import pytest | ||
|
||
if sys.version_info < (3, 8): | ||
try: | ||
from typing import TypedDict | ||
except ImportError: | ||
TypedDict = None | ||
else: | ||
from typing import TypedDict | ||
|
||
from pydantic import ( | ||
BaseModel, | ||
ConfigError, | ||
|
@@ -1425,3 +1434,90 @@ class M(BaseModel): | |
a: int | ||
|
||
get_type_hints(M.__config__) | ||
|
||
|
||
def test_named_tuple(): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know test files are a mess, but please can you move this to somewhere else. I try to keep There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure thing! TBH I didn't know it was for vanilla cases. Maybe a module docstring on top of the module could help. I'll try to find a better place for these bad boys There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I created a new dedicated file |
||
Position = namedtuple('Pos', 'x y') | ||
|
||
class Event(NamedTuple): | ||
a: int | ||
b: int | ||
c: int | ||
d: str | ||
|
||
class Model(BaseModel): | ||
pos: Position | ||
events: List[Event] | ||
|
||
model = Model(pos=('1', 2), events=[[b'1', '2', 3, 'qwe']]) | ||
assert isinstance(model.pos, Position) | ||
assert isinstance(model.events[0], Event) | ||
assert model.pos.x == '1' | ||
assert model.pos == Position('1', 2) | ||
assert model.events[0] == Event(1, 2, 3, 'qwe') | ||
assert repr(model) == "Model(pos=Pos(x='1', y=2), events=[Event(a=1, b=2, c=3, d='qwe')])" | ||
|
||
with pytest.raises(ValidationError) as exc_info: | ||
Model(pos=('1', 2), events=[['qwe', '2', 3, 'qwe']]) | ||
assert exc_info.value.errors() == [ | ||
{ | ||
'loc': ('events', 0, 'a'), | ||
'msg': 'value is not a valid integer', | ||
'type': 'type_error.integer', | ||
} | ||
] | ||
|
||
|
||
@pytest.mark.skipif(not TypedDict, reason='typing_extensions not installed') | ||
def test_typed_dict(): | ||
class TD(TypedDict): | ||
a: int | ||
b: int | ||
c: int | ||
d: str | ||
|
||
class Model(BaseModel): | ||
td: TD | ||
|
||
m = Model(td={'a': '3', 'b': b'1', 'c': 4, 'd': 'qwe'}) | ||
assert m.td == {'a': 3, 'b': 1, 'c': 4, 'd': 'qwe'} | ||
|
||
with pytest.raises(ValidationError) as exc_info: | ||
Model(td={'a': [1], 'b': 2, 'c': 3, 'd': 'qwe'}) | ||
assert exc_info.value.errors() == [ | ||
{ | ||
'loc': ('td', 'a'), | ||
'msg': 'value is not a valid integer', | ||
'type': 'type_error.integer', | ||
} | ||
] | ||
|
||
|
||
@pytest.mark.skipif(not TypedDict, reason='typing_extensions not installed') | ||
def test_typed_dict_non_total(): | ||
class FullMovie(TypedDict, total=True): | ||
name: str | ||
year: int | ||
|
||
class Model(BaseModel): | ||
movie: FullMovie | ||
|
||
with pytest.raises(ValidationError) as exc_info: | ||
Model(movie={'year': '2002'}) | ||
assert exc_info.value.errors() == [ | ||
{ | ||
'loc': ('movie', 'name'), | ||
'msg': 'field required', | ||
'type': 'value_error.missing', | ||
} | ||
] | ||
|
||
class PartialMovie(TypedDict, total=False): | ||
name: str | ||
year: int | ||
|
||
class Model(BaseModel): | ||
movie: PartialMovie | ||
|
||
m = Model(movie={'year': '2002'}) | ||
assert m.movie == {'year': 2002} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
again I think the "subclass of..." is implicit