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 11 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
20 changes: 20 additions & 0 deletions docs/examples/annotated_types_named_tuple.py
@@ -0,0 +1,20 @@
from typing import NamedTuple

from pydantic import BaseModel, ValidationError


class Point(NamedTuple):
x: int
y: int


class Model(BaseModel):
p: Point


print(Model(p=('1', '2')))

try:
Model(p=('1.3', '2'))
except ValidationError as e:
print(e)
31 changes: 31 additions & 0 deletions docs/examples/annotated_types_typed_dict.py
@@ -0,0 +1,31 @@
from typing import TypedDict

from pydantic import BaseModel, ValidationError


# `total=False` means keys are non-required
class UserIdentity(TypedDict, total=False):
name: str
surname: str


class User(TypedDict):
identity: UserIdentity
age: int


class Model(BaseModel):
u: User


print(Model(u={'identity': {'name': 'Smith', 'surname': 'John'}, 'age': '37'}))

print(Model(u={'identity': {'name': None, 'surname': 'John'}, 'age': '37'}))

print(Model(u={'identity': {}, 'age': '37'}))


try:
Model(u={'identity': {'name': ['Smith'], 'surname': 'John'}, 'age': '24'})
except ValidationError as e:
print(e)
33 changes: 33 additions & 0 deletions docs/usage/types.md
Expand Up @@ -88,9 +88,19 @@ 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.
PrettyWood marked this conversation as resolved.
Show resolved Hide resolved
See [Annotated Types](#annotated-types) below for more detail on parsing and validation

`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.
See [Annotated Types](#annotated-types) below for more detail on parsing and validation

`typing.Set`
: see [Typing Iterables](#typing-iterables) below for more detail on parsing and validation

Expand Down Expand Up @@ -395,6 +405,29 @@ With proper ordering in an annotated `Union`, you can use this to parse types of
```
_(This script is complete, it should run "as is")_

## Annotated Types

### NamedTuple

```py
{!.tmp_examples/annotated_types_named_tuple.py!}
```
_(This script is complete, it should run "as is")_

### TypedDict

!!! note
This is a new feature of the python standard library as of python 3.8.
Prior to python 3.8, it requires the [typing-extensions](https://pypi.org/project/typing-extensions/) package.
But required and optional fields are properly differentiated only since python 3.9.
It is hence recommanded using [typing-extensions](https://pypi.org/project/typing-extensions/) with python 3.8 as well.
PrettyWood marked this conversation as resolved.
Show resolved Hide resolved


```py
{!.tmp_examples/annotated_types_typed_dict.py!}
```
_(This script is complete, it should run "as is")_

## Pydantic Types

*pydantic* also provides a variety of other useful types:
Expand Down
3 changes: 3 additions & 0 deletions pydantic/fields.py
Expand Up @@ -38,6 +38,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 @@ -416,6 +417,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 @@ -188,6 +188,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 @@ -298,6 +300,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
74 changes: 73 additions & 1 deletion pydantic/validators.py
Expand Up @@ -15,6 +15,7 @@
FrozenSet,
Generator,
List,
NamedTuple,
Pattern,
Set,
Tuple,
Expand All @@ -36,19 +37,27 @@
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
__required_keys__: Set[str]
__optional_keys__: Set[str]


def str_validator(v: Any) -> Union[str]:
if isinstance(v, str):
Expand Down Expand Up @@ -536,6 +545,62 @@ 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
# 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]

# Best case scenario: with python 3.9+ or when used with typing_extensions
if hasattr(type_, '__required_keys__'):
field_definitions = {
field_name: (field_type, ... if field_name in type_.__required_keys__ else None)
for field_name, field_type in type_.__annotations__.items()
}
else:
import warnings

warnings.warn(
'You should use `typing_extensions.TypedDict` instead of `typing.TypedDict` for better support! '
'Without it, there is no way to differentiate required and optional fields. '
'All fields will therefore be considered required.',
PrettyWood marked this conversation as resolved.
Show resolved Hide resolved
UserWarning,
)
default_value = ... if type_.__total__ else None
field_definitions = {
field_name: (field_type, default_value) 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 @@ -626,6 +691,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
2 changes: 1 addition & 1 deletion requirements.txt
Expand Up @@ -4,5 +4,5 @@ Cython==0.29.21;sys_platform!='win32'
devtools==0.6.1
email-validator==1.1.2
dataclasses==0.6; python_version < '3.7'
typing-extensions==3.7.4.1; python_version < '3.8'
typing-extensions==3.7.4.3; python_version < '3.9'
Copy link
Member Author

Choose a reason for hiding this comment

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

Had to bump this to have the good version of TypedDict for python < 3.9

python-dotenv==0.15.0