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 all 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
3 changes: 3 additions & 0 deletions changes/2216-PrettyWood.md
@@ -0,0 +1,3 @@
Add support for `NamedTuple` and `TypedDict` types.
Those two types are now handled and validated when used inside `BaseModel` or _pydantic_ `dataclass`.
Two utils are also added `create_model_from_namedtuple` and `create_model_from_typeddict`.
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)
45 changes: 45 additions & 0 deletions docs/examples/annotated_types_typed_dict.py
@@ -0,0 +1,45 @@
from typing import TypedDict

from pydantic import BaseModel, Extra, 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

class Config:
extra = Extra.forbid


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)

try:
Model(
u={
'identity': {'name': 'Smith', 'surname': 'John'},
'age': '37',
'email': 'john.smith@me.com',
}
)
except ValidationError as e:
print(e)
21 changes: 21 additions & 0 deletions docs/examples/models_from_typeddict.py
@@ -0,0 +1,21 @@
from typing import TypedDict

from pydantic import ValidationError, create_model_from_typeddict


class User(TypedDict):
name: str
id: int


class Config:
extra = 'forbid'


UserM = create_model_from_typeddict(User, __config__=Config)
print(repr(UserM(name=123, id='3')))

try:
UserM(name=123, id='3', other='no')
except ValidationError as e:
print(e)
12 changes: 12 additions & 0 deletions docs/usage/models.md
Expand Up @@ -384,6 +384,18 @@ You can also add validators by passing a dict to the `__validators__` argument.
{!.tmp_examples/models_dynamic_validators.py!}
```

## Model creation from `NamedTuple` or `TypedDict`

Sometimes you already use in your application classes that inherit from `NamedTuple` or `TypedDict`
and you don't want to duplicate all your information to have a `BaseModel`.
For this _pydantic_ provides `create_model_from_namedtuple` and `create_model_from_typeddict` methods.
Those methods have the exact same keyword arguments as `create_model`.


```py
{!.tmp_examples/models_from_typeddict.py!}
```

## Custom Root Types

Pydantic models can be defined with a custom root type by declaring the `__root__` field.
Expand Down
34 changes: 34 additions & 0 deletions docs/usage/types.md
Expand Up @@ -88,9 +88,20 @@ with custom properties and validation.
`typing.Tuple`
: see [Typing Iterables](#typing-iterables) below for more detail on parsing and validation

`subclass of typing.NamedTuple`
: Same as `tuple` but instantiates with the given namedtuple and validates fields since they are annotated.
See [Annotated Types](#annotated-types) below for more detail on parsing and validation

`subclass of collections.namedtuple`
: Same as `subclass of typing.NamedTuple` but all fields will have type `Any` since they are not annotated

`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 +406,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.
We therefore recommend using [typing-extensions](https://pypi.org/project/typing-extensions/) with python 3.8 as well.


```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
4 changes: 4 additions & 0 deletions pydantic/__init__.py
@@ -1,5 +1,6 @@
# flake8: noqa
from . import dataclasses
from .annotated_types import create_model_from_namedtuple, create_model_from_typeddict
from .class_validators import root_validator, validator
from .decorator import validate_arguments
from .env_settings import BaseSettings
Expand All @@ -16,6 +17,9 @@
# WARNING __all__ from .errors is not included here, it will be removed as an export here in v2
# please use "from pydantic.errors import ..." instead
__all__ = [
# annotated types utils
'create_model_from_namedtuple',
Copy link
Member Author

Choose a reason for hiding this comment

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

@samuelcolvin I didn't do it directly in this PR to keep it scoped and to first have some feedback.
But if you like this approach, I reckon it could be nice to add a create_model_from_dataclass instead of the not-very-pretty solution pydantic.dataclasses.dataclass(StdDataclass).__pydantic_model__

Copy link
Member

Choose a reason for hiding this comment

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

yes agree, perhaps we might want create_model_from which takes any of the three types, but we definitely want these standalone methods for performance.

Or we could go even further and extend create_model to take a single dataclass, named tuple or typed dict instead of fields?

'create_model_from_typeddict',
# dataclasses
'dataclasses',
# class_validators
Expand Down
59 changes: 59 additions & 0 deletions pydantic/annotated_types.py
@@ -0,0 +1,59 @@
from typing import TYPE_CHECKING, Any, Dict, FrozenSet, NamedTuple, Type

from .fields import Required
from .main import BaseModel, create_model

if TYPE_CHECKING:

class TypedDict(Dict[str, Any]):
Copy link
Member Author

Choose a reason for hiding this comment

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

We could use typing_extensions.TypedDict for python < 3.9 or typing.TypedDict for 3.9+ but we would need to add typing_extensions in linting requirements.

__annotations__: Dict[str, Type[Any]]
__total__: bool
__required_keys__: FrozenSet[str]
__optional_keys__: FrozenSet[str]


def create_model_from_typeddict(typeddict_cls: Type['TypedDict'], **kwargs: Any) -> Type['BaseModel']:
"""
Create a `BaseModel` based on the fields of a `TypedDict`.
Since `typing.TypedDict` in Python 3.8 does not store runtime information about optional keys,
we warn the user if that's the case (see https://bugs.python.org/issue38834).
"""
field_definitions: Dict[str, Any]

# Best case scenario: with python 3.9+ or when `TypedDict` is imported from `typing_extensions`
if hasattr(typeddict_cls, '__required_keys__'):
field_definitions = {
field_name: (field_type, Required if field_name in typeddict_cls.__required_keys__ else None)
for field_name, field_type in typeddict_cls.__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 when subclassed. '
'Fields will therefore be considered all required or all optional depending on class totality.',
UserWarning,
)
default_value = Required if typeddict_cls.__total__ else None
field_definitions = {
field_name: (field_type, default_value) for field_name, field_type in typeddict_cls.__annotations__.items()
}

return create_model(typeddict_cls.__name__, **kwargs, **field_definitions)


def create_model_from_namedtuple(namedtuple_cls: Type['NamedTuple'], **kwargs: Any) -> Type['BaseModel']:
"""
Create a `BaseModel` based on the fields of a named tuple.
A named tuple can be created with `typing.NamedTuple` and declared annotations
but also with `collections.namedtuple`, in this case we consider all fields
to have type `Any`.
"""
namedtuple_annotations: Dict[str, Type[Any]] = getattr(
namedtuple_cls, '__annotations__', {k: Any for k in namedtuple_cls._fields}
)
field_definitions: Dict[str, Any] = {
field_name: (field_type, Required) for field_name, field_type in namedtuple_annotations.items()
}
return create_model(namedtuple_cls.__name__, **kwargs, **field_definitions)
3 changes: 3 additions & 0 deletions pydantic/fields.py
Expand Up @@ -38,6 +38,7 @@
get_origin,
is_literal_type,
is_new_type,
is_typeddict,
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_typeddict(self.type_):
return

origin = get_origin(self.type_)
if origin is None:
Expand Down
14 changes: 12 additions & 2 deletions pydantic/main.py
Expand Up @@ -34,7 +34,15 @@
from .parse import Protocol, load_file, load_str_bytes
from .schema import default_ref_template, model_schema
from .types import PyObject, StrBytes
from .typing import AnyCallable, get_args, get_origin, is_classvar, resolve_annotations, update_field_forward_refs
from .typing import (
AnyCallable,
get_args,
get_origin,
is_classvar,
is_namedtuple,
resolve_annotations,
update_field_forward_refs,
)
from .utils import (
ROOT_KEY,
ClassAttribute,
Expand Down Expand Up @@ -732,7 +740,7 @@ def _get_value(
}

elif sequence_like(v):
return v.__class__(
seq_args = (
cls._get_value(
v_,
to_dict=to_dict,
Expand All @@ -748,6 +756,8 @@ def _get_value(
and (not value_include or value_include.is_included(i))
)

return v.__class__(*seq_args) if is_namedtuple(v.__class__) else v.__class__(seq_args)

elif isinstance(v, Enum) and getattr(cls.Config, 'use_enum_values', False):
return v.value

Expand Down
13 changes: 12 additions & 1 deletion pydantic/schema.py
Expand Up @@ -65,6 +65,7 @@
get_origin,
is_callable_type,
is_literal_type,
is_namedtuple,
literal_values,
)
from .utils import ROOT_KEY, get_model, lenient_issubclass, sequence_like
Expand Down Expand Up @@ -795,7 +796,17 @@ def field_singleton_schema( # noqa: C901 (ignore complexity)
f_schema, schema_overrides = get_field_info_schema(field)
f_schema.update(get_schema_ref(enum_name, ref_prefix, ref_template, schema_overrides))
definitions[enum_name] = enum_process_schema(field_type)
else:
elif is_namedtuple(field_type):
sub_schema, *_ = model_process_schema(
field_type.__pydantic_model__,
by_alias=by_alias,
model_name_map=model_name_map,
ref_prefix=ref_prefix,
ref_template=ref_template,
known_models=known_models,
)
f_schema.update({'type': 'array', 'items': list(sub_schema['properties'].values())})
elif not hasattr(field_type, '__pydantic_model__'):
add_field_type_to_schema(field_type, f_schema)

modify_schema = getattr(field_type, '__modify_schema__', None)
Expand Down
22 changes: 22 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_namedtuple',
'is_typeddict',
'is_new_type',
'new_type_supertype',
'is_classvar',
Expand Down Expand Up @@ -298,6 +300,26 @@ 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_namedtuple(type_: Type[Any]) -> bool:
"""
Check if a given class is a named tuple.
It can be either a `typing.NamedTuple` or `collections.namedtuple`
"""
from .utils import lenient_issubclass

return lenient_issubclass(type_, tuple) and hasattr(type_, '_fields')


def is_typeddict(type_: Type[Any]) -> bool:
"""
Check if a given class is a typed dict (from `typing` or `typing_extensions`)
In 3.10, there will be a public method (https://docs.python.org/3.10/library/typing.html#typing.is_typeddict)
"""
from .utils import lenient_issubclass

return lenient_issubclass(type_, dict) and hasattr(type_, '__total__')


test_type = NewType('test_type', str)


Expand Down