diff --git a/changes/958-dmontagu.md b/changes/958-dmontagu.md new file mode 100644 index 0000000..b5af62c --- /dev/null +++ b/changes/958-dmontagu.md @@ -0,0 +1 @@ +Add support for mapping types for custom root models diff --git a/docs/examples/models_custom_root_field_parse_obj.py b/docs/examples/models_custom_root_field_parse_obj.py new file mode 100644 index 0000000..f5d3da2 --- /dev/null +++ b/docs/examples/models_custom_root_field_parse_obj.py @@ -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) diff --git a/docs/usage/models.md b/docs/usage/models.md index ad4c753..63aa95d 100644 --- a/docs/usage/models.md +++ b/docs/usage/models.md @@ -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 diff --git a/pydantic/main.py b/pydantic/main.py index ecfb92f..95fe04e 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -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 diff --git a/tests/test_main.py b/tests/test_main.py index 29c7f94..d093d52 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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():