Add support for mapping types as custom root (#958)

* Add support for mapping types as custom root

* Incorporate feedback

* Add changes

* Incorporate feedback

* Add docs and tests

* Fix linting issue

* Incorporate more feedback

* Add more specific match
This commit is contained in:
dmontagu
2019-11-25 04:19:41 -08:00
committed by Samuel Colvin
parent 643266944c
commit 62bc930f57
5 changed files with 77 additions and 18 deletions
+1
View File
@@ -0,0 +1 @@
Add support for mapping types for custom root models
@@ -0,0 +1,17 @@
from typing import List, Dict
from pydantic import BaseModel, ValidationError
class Pets(BaseModel):
__root__: List[str]
print(Pets.parse_obj(['dog', 'cat']))
print(Pets.parse_obj({'__root__': ['dog', 'cat']})) # not recommended
class PetsByName(BaseModel):
__root__: Dict[str, str]
print(PetsByName.parse_obj({'Otis': 'dog', 'Milo': 'cat'}))
try:
PetsByName.parse_obj({'__root__': {'Otis': 'dog', 'Milo': 'cat'}})
except ValidationError as e:
print(e)
+22 -4
View File
@@ -324,17 +324,35 @@ extending a base model with extra fields.
## Custom Root Types
Pydantic models which do not represent a `dict` ("object" in JSON parlance) can have a custom
root type defined via the `__root__` field. The root type can be of any type: list, float, int, etc.
Pydantic models can be defined with a custom root type by declaring the `__root__` field.
The root type is defined via the type hint on the `__root__` field.
The root value can be passed to model `__init__` via the `__root__` keyword argument or as
The root type can be any type supported by pydantic, and is specified by the type hint on the `__root__` field.
The root value can be passed to the model `__init__` via the `__root__` keyword argument, or as
the first and only argument to `parse_obj`.
```py
{!.tmp_examples/models_custom_root_field.py!}
```
If you call the `parse_obj` method for a model with a custom root type with a *dict* as the first argument,
the following logic is used:
* If the custom root type is a mapping type (eg., `Dict` or `Mapping`),
the argument itself is always validated against the custom root type.
* For other custom root types, if the dict has precisely one key with the value `__root__`,
the corresponding value will be validated against the custom root type.
* Otherwise, the dict itself is validated against the custom root type.
This is demonstrated in the following example:
```py
{!.tmp_examples/models_custom_root_field_parse_obj.py!}
```
!!! warning
Calling the `parse_obj` method on a dict with the single key `"__root__"` for non-mapping custom root types
is currently supported for backwards compatibility, but is not recommended and may be dropped in a future version.
## Faux Immutability
Models can be configured to be immutable via `allow_mutation = False`. When this is set, attempting to change the
+10 -11
View File
@@ -136,8 +136,6 @@ def is_valid_field(name: str) -> bool:
def validate_custom_root_type(fields: Dict[str, ModelField]) -> None:
if len(fields) > 1:
raise ValueError('__root__ cannot be mixed with other fields')
if fields[ROOT_KEY].shape == SHAPE_MAPPING:
raise TypeError('custom root type cannot allow mapping')
UNTOUCHED_TYPES = FunctionType, property, type, classmethod, staticmethod
@@ -382,15 +380,16 @@ class BaseModel(metaclass=ModelMetaclass):
@classmethod
def parse_obj(cls: Type['Model'], obj: Any) -> 'Model':
if not isinstance(obj, dict):
if cls.__custom_root_type__:
obj = {ROOT_KEY: obj}
else:
try:
obj = dict(obj)
except (TypeError, ValueError) as e:
exc = TypeError(f'{cls.__name__} expected dict not {type(obj).__name__}')
raise ValidationError([ErrorWrapper(exc, loc=ROOT_KEY)], cls) from e
if cls.__custom_root_type__ and (
not (isinstance(obj, dict) and obj.keys() == {ROOT_KEY}) or cls.__fields__[ROOT_KEY].shape == SHAPE_MAPPING
):
obj = {ROOT_KEY: obj}
elif not isinstance(obj, dict):
try:
obj = dict(obj)
except (TypeError, ValueError) as e:
exc = TypeError(f'{cls.__name__} expected dict not {type(obj).__name__}')
raise ValidationError([ErrorWrapper(exc, loc=ROOT_KEY)], cls) from e
return cls(**obj)
@classmethod
+27 -3
View File
@@ -900,10 +900,34 @@ def test_root_undefined_failed():
def test_parse_root_as_mapping():
with pytest.raises(TypeError, match='custom root type cannot allow mapping'):
class MyModel(BaseModel):
__root__: Mapping[str, str]
class MyModel(BaseModel):
__root__: Mapping[str, str]
assert MyModel.parse_obj({1: 2}).__root__ == {'1': '2'}
with pytest.raises(ValidationError) as exc_info:
MyModel.parse_obj({'__root__': {'1': '2'}})
assert exc_info.value.errors() == [
{'loc': ('__root__', '__root__'), 'msg': 'str type expected', 'type': 'type_error.str'}
]
def test_parse_obj_non_mapping_root():
class MyModel(BaseModel):
__root__: List[str]
assert MyModel.parse_obj(['a']).__root__ == ['a']
assert MyModel.parse_obj({'__root__': ['a']}).__root__ == ['a']
with pytest.raises(ValidationError) as exc_info:
MyModel.parse_obj({'__not_root__': ['a']})
assert exc_info.value.errors() == [
{'loc': ('__root__',), 'msg': 'value is not a valid list', 'type': 'type_error.list'}
]
with pytest.raises(ValidationError):
MyModel.parse_obj({'__root__': ['a'], 'other': 1})
assert exc_info.value.errors() == [
{'loc': ('__root__',), 'msg': 'value is not a valid list', 'type': 'type_error.list'}
]
def test_untouched_types():