Skip to content
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

Merged
merged 29 commits into from Feb 13, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0cbe31f
feat: add support for `NamedTuple` and `TypedDict` types
PrettyWood Dec 23, 2020
c5e236a
support `total=False`
PrettyWood Dec 23, 2020
0f5cb59
tests: fix ci with python < 3.8 without typing-extensions
PrettyWood Dec 23, 2020
de54b65
chore: improve mypy
PrettyWood Dec 23, 2020
1c1b499
Merge branch 'master' into feat/support-named-tuple
PrettyWood Jan 2, 2021
986feda
chore: @samuelcolvin remarks
PrettyWood Jan 2, 2021
a7bf673
refactor: move tests in dedicated file
PrettyWood Jan 2, 2021
6764357
docs: add annotated types section with examples
PrettyWood Jan 2, 2021
62b4feb
feat: support properly required and optional fields
PrettyWood Jan 11, 2021
7b45015
chore(deps-dev): bump typing_extensions
PrettyWood Jan 11, 2021
7368bf9
docs: add a note for `typing_extensions`
PrettyWood Jan 11, 2021
63f4a1e
chore: update message to be more accurate
PrettyWood Jan 12, 2021
ebfd440
feat: pass down config to created models
PrettyWood Jan 12, 2021
4cb9dbd
feat: add util methods to create model from TypedDict or NamedTuple
PrettyWood Jan 17, 2021
58dbfd7
refactor: rename into typeddict and namedtuple
PrettyWood Jan 17, 2021
5663cd3
test: add utils tests
PrettyWood Jan 17, 2021
11bd960
chore: fix lint
PrettyWood Jan 17, 2021
8221ce2
chore: improve test
PrettyWood Jan 17, 2021
0a250e1
refactor: rename utils to match the rest
PrettyWood Jan 17, 2021
3361496
chore: update change
PrettyWood Jan 17, 2021
d737f07
docs: add section for create_model_from_{namedtuple,typeddict}
PrettyWood Jan 17, 2021
3fe8ff9
refactor: rename typed_dict/named_tuple
PrettyWood Jan 21, 2021
08868ab
feat: support schema with TypedDict
PrettyWood Jan 21, 2021
567bf4d
feat: support schema for NamedTuple
PrettyWood Jan 21, 2021
9481776
feat: add json support for NamedTuple
PrettyWood Jan 21, 2021
1c6f369
chore: rewording
PrettyWood Feb 11, 2021
5db83e9
refactor: use parse_obj
PrettyWood Feb 11, 2021
4da1475
fix: add check for max items in tuple
PrettyWood Feb 11, 2021
1e60f5a
docs: separate typing.NamedTuple and collections.namedtuple
PrettyWood Feb 12, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/2216-PrettyWood.md
@@ -0,0 +1 @@
add support for `NamedTuple` and `TypedDict` types
8 changes: 8 additions & 0 deletions docs/usage/types.md
Expand Up @@ -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)`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`subclass of typing.NamedTuple (or collections.namedtuple)`
`typing.NamedTuple` (or `collections.namedtuple`)

again I think the "subclass of..." is implicit

: 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`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`subclass of typing.TypedDict`
`typing.TypedDict`

I think the subclass is implicit

Copy link
Member Author

Choose a reason for hiding this comment

The 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 enum.Enum to have a valid enum instance. We could someday add typing.TypedDict just to have a valid TypedDict instance for example. WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still prefer to remove subclass of and instead be explicity when we mean the actual type, like Enum.

: 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

Expand Down
3 changes: 3 additions & 0 deletions pydantic/fields.py
Expand Up @@ -37,6 +37,7 @@
get_origin,
is_literal_type,
is_new_type,
is_typed_dict_type,
new_type_supertype,
)
from .utils import PyObjectStr, Representation, lenient_issubclass, sequence_like, smart_deepcopy
Expand Down Expand Up @@ -415,6 +416,8 @@ def _type_analysis(self) -> None: # noqa: C901 (ignore complexity)
return
elif is_literal_type(self.type_):
return
elif is_typed_dict_type(self.type_):
return

origin = get_origin(self.type_)
if origin is None:
Expand Down
14 changes: 14 additions & 0 deletions pydantic/typing.py
Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

@PrettyWood PrettyWood Jan 1, 2021

Choose a reason for hiding this comment

The 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)


Expand Down
54 changes: 53 additions & 1 deletion pydantic/validators.py
Expand Up @@ -15,6 +15,7 @@
FrozenSet,
Generator,
List,
NamedTuple,
Pattern,
Set,
Tuple,
Expand All @@ -34,19 +35,25 @@
get_class,
is_callable_type,
is_literal_type,
is_named_tuple_type,
is_typed_dict_type,
)
from .utils import almost_equal_floats, lenient_issubclass, sequence_like

if TYPE_CHECKING:
from .fields import ModelField
from .main import BaseConfig
from .main import BaseConfig, BaseModel
from .types import ConstrainedDecimal, ConstrainedFloat, ConstrainedInt

ConstrainedNumber = Union[ConstrainedDecimal, ConstrainedFloat, ConstrainedInt]
AnyOrderedDict = OrderedDict[Any, Any]
Number = Union[int, float, Decimal]
StrBytes = Union[str, bytes]

class TypedDict(Dict[str, Any]):
__annotations__: Dict[str, Type[Any]]
__total__: bool


def str_validator(v: Any) -> Union[str]:
if isinstance(v, str):
Expand Down Expand Up @@ -523,6 +530,44 @@ def pattern_validator(v: Any) -> Pattern[str]:
raise errors.PatternError()


NamedTupleT = TypeVar('NamedTupleT', bound=NamedTuple)


def make_named_tuple_validator(type_: Type[NamedTupleT]) -> Callable[[Tuple[Any, ...]], NamedTupleT]:
from .main import create_model

# A named tuple can be created with `typing,NamedTuple` with types
PrettyWood marked this conversation as resolved.
Show resolved Hide resolved
# but also with `collections.namedtuple` with just the fields
# in which case we consider the type to be `Any`
named_tuple_annotations: Dict[str, Type[Any]] = getattr(type_, '__annotations__', {k: Any for k in type_._fields})
field_definitions: Dict[str, Any] = {
field_name: (field_type, ...) for field_name, field_type in named_tuple_annotations.items()
}
NamedTupleModel: Type['BaseModel'] = create_model('NamedTupleModel', **field_definitions)

def named_tuple_validator(values: Tuple[Any, ...]) -> NamedTupleT:
dict_values: Dict[str, Any] = dict(zip(named_tuple_annotations, values))
validated_dict_values: Dict[str, Any] = dict(NamedTupleModel(**dict_values))
return type_(**validated_dict_values)

return named_tuple_validator


def make_typed_dict_validator(type_: Type['TypedDict']) -> Callable[[Any], Dict[str, Any]]:
from .main import create_model

field_definitions: Dict[str, Any] = {
field_name: (field_type, ... if type_.__total__ else None)
PrettyWood marked this conversation as resolved.
Show resolved Hide resolved
for field_name, field_type in type_.__annotations__.items()
}
TypedDictModel: Type['BaseModel'] = create_model('TypedDictModel', **field_definitions)

def typed_dict_validator(values: 'TypedDict') -> Dict[str, Any]:
return TypedDictModel(**values).dict(exclude_unset=True)
PrettyWood marked this conversation as resolved.
Show resolved Hide resolved

return typed_dict_validator


class IfConfig:
def __init__(self, validator: AnyCallable, *config_attr_names: str) -> None:
self.validator = validator
Expand Down Expand Up @@ -610,6 +655,13 @@ def find_validators( # noqa: C901 (ignore complexity)
if type_ is IntEnum:
yield int_enum_validator
return
if is_named_tuple_type(type_):
yield tuple_validator
yield make_named_tuple_validator(type_)
return
if is_typed_dict_type(type_):
yield make_typed_dict_validator(type_)
return

class_ = get_class(type_)
if class_ is not None:
Expand Down
98 changes: 97 additions & 1 deletion tests/test_main.py
@@ -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,
Expand Down Expand Up @@ -1425,3 +1434,90 @@ class M(BaseModel):
a: int

get_type_hints(M.__config__)


def test_named_tuple():
Copy link
Member

Choose a reason for hiding this comment

The 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 test_main.py for the most vanilla cases.

Copy link
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created a new dedicated file test_annotated_types. Suggestions welcome if you don't like it

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}