Repository: pydantic/pydantic

Issue:
Wrong assignement of values in `Union[Foo, List[Foo]]`
### Checks

* [x] I added a descriptive title to this issue
* [x] I have searched (google, github) for similar issues and couldn't find anything
* [x] I have read and followed [the docs](https://pydantic-docs.helpmanual.io/) and still think this is a bug

# Bug

When validating a model that allows either a single object `Foo` or a list of `Foo` objects, the wrong values are assigned. The key of the second parameter is assigned as value of the first parameter (which is a string).

Output of `python -c "import pydantic.utils; print(pydantic.utils.version_info())"`:
```
             pydantic version: 1.8.2
            pydantic compiled: True
                 install path: /home/anaconda3/envs/py38/lib/python3.8/site-packages/pydantic
               python version: 3.8.5 (default, Sep  4 2020, 07:30:14)  [GCC 7.3.0]
                     platform: Linux-5.11.0-38-generic-x86_64-with-glibc2.10
     optional deps. installed: ['typing-extensions']
```

The following minimal example repruduces the bug: 

```py
from pydantic import BaseModel, Field
from typing import List, Optional, Union


class Foo(BaseModel):
    a: str = Field(
        ...,
    )
    b: Optional[float]


class Bar(BaseModel):
    foo: Union[Foo, List[Foo]]


bar = {
    "foo": [
        {
            "a": "some string",
            "b": 1.0
        },
        {
            "a": "another string",
            "b": 2.0
        }
    ]
}

a = Bar(**bar)

print(a)
```

- Output: `foo=Foo(a='b', b=None)`
- Expected Output: `foo=[Foo(a='some string', b=1.0), Foo(a='another string', b=2.0)]`

I am not sure if this is related somehow to  #2092



Comments:

Patch:
diff --git a/docs/examples/model_config_smart_union_off.py b/docs/examples/model_config_smart_union_off.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/model_config_smart_union_off.py
@@ -0,0 +1,19 @@
+from typing import Union
+
+from pydantic import BaseModel
+
+
+class Foo(BaseModel):
+    pass
+
+
+class Bar(BaseModel):
+    pass
+
+
+class Model(BaseModel):
+    x: Union[str, int]
+    y: Union[Foo, Bar]
+
+
+print(Model(x=1, y=Bar()))
diff --git a/docs/examples/model_config_smart_union_on.py b/docs/examples/model_config_smart_union_on.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/model_config_smart_union_on.py
@@ -0,0 +1,22 @@
+from typing import Union
+
+from pydantic import BaseModel
+
+
+class Foo(BaseModel):
+    pass
+
+
+class Bar(BaseModel):
+    pass
+
+
+class Model(BaseModel):
+    x: Union[str, int]
+    y: Union[Foo, Bar]
+
+    class Config:
+        smart_union = True
+
+
+print(Model(x=1, y=Bar()))
diff --git a/docs/examples/model_config_smart_union_on_edge_case.py b/docs/examples/model_config_smart_union_on_edge_case.py
new file mode 100644
--- /dev/null
+++ b/docs/examples/model_config_smart_union_on_edge_case.py
@@ -0,0 +1,14 @@
+from typing import List, Union
+
+from pydantic import BaseModel
+
+
+class Model(BaseModel, smart_union=True):
+    x: Union[List[str], List[int]]
+
+
+# Expected coercion
+print(Model(x=[1, '2']))
+
+# Unexpected coercion
+print(Model(x=[1, 2]))
diff --git a/pydantic/config.py b/pydantic/config.py
--- a/pydantic/config.py
+++ b/pydantic/config.py
@@ -63,8 +63,10 @@ class BaseConfig:
     json_encoders: Dict[Type[Any], AnyCallable] = {}
     underscore_attrs_are_private: bool = False
 
-    # Whether or not inherited models as fields should be reconstructed as base model
+    # whether inherited models as fields should be reconstructed as base model
     copy_on_model_validation: bool = True
+    # whether `Union` should check all allowed types before even trying to coerce
+    smart_union: bool = False
 
     @classmethod
     def get_field_info(cls, name: str) -> Dict[str, Any]:
diff --git a/pydantic/env_settings.py b/pydantic/env_settings.py
--- a/pydantic/env_settings.py
+++ b/pydantic/env_settings.py
@@ -6,7 +6,7 @@
 from .config import BaseConfig, Extra
 from .fields import ModelField
 from .main import BaseModel
-from .typing import StrPath, display_as_type, get_origin, is_union_origin
+from .typing import StrPath, display_as_type, get_origin, is_union
 from .utils import deep_update, path_type, sequence_like
 
 env_file_sentinel = str(object())
@@ -175,9 +175,7 @@ def __call__(self, settings: BaseSettings) -> Dict[str, Any]:
                 except ValueError as e:
                     raise SettingsError(f'error parsing JSON for "{env_name}"') from e
             elif (
-                is_union_origin(get_origin(field.type_))
-                and field.sub_fields
-                and any(f.is_complex() for f in field.sub_fields)
+                is_union(get_origin(field.type_)) and field.sub_fields and any(f.is_complex() for f in field.sub_fields)
             ):
                 try:
                     env_val = settings.__config__.json_loads(env_val)
diff --git a/pydantic/fields.py b/pydantic/fields.py
--- a/pydantic/fields.py
+++ b/pydantic/fields.py
@@ -42,7 +42,7 @@
     is_new_type,
     is_none_type,
     is_typeddict,
-    is_union_origin,
+    is_union,
     new_type_supertype,
 )
 from .utils import PyObjectStr, Representation, ValueItems, lenient_issubclass, sequence_like, smart_deepcopy
@@ -560,7 +560,7 @@ def _type_analysis(self) -> None:  # noqa: C901 (ignore complexity)
             return
         if origin is Callable:
             return
-        if is_union_origin(origin):
+        if is_union(origin):
             types_ = []
             for type_ in get_args(self.type_):
                 if type_ is NoneType:
@@ -935,6 +935,35 @@ def _validate_singleton(
     ) -> 'ValidateReturn':
         if self.sub_fields:
             errors = []
+
+            if self.model_config.smart_union and is_union(get_origin(self.type_)):
+                # 1st pass: check if the value is an exact instance of one of the Union types
+                # (e.g. to avoid coercing a bool into an int)
+                for field in self.sub_fields:
+                    if v.__class__ is field.outer_type_:
+                        return v, None
+
+                # 2nd pass: check if the value is an instance of any subclass of the Union types
+                for field in self.sub_fields:
+                    # This whole logic will be improved later on to support more complex `isinstance` checks
+                    # It will probably be done once a strict mode is added and be something like:
+                    # ```
+                    #     value, error = field.validate(v, values, strict=True)
+                    #     if error is None:
+                    #         return value, None
+                    # ```
+                    try:
+                        if isinstance(v, field.outer_type_):
+                            return v, None
+                    except TypeError:
+                        # compound type
+                        if isinstance(v, get_origin(field.outer_type_)):
+                            value, error = field.validate(v, values, loc=loc, cls=cls)
+                            if not error:
+                                return value, None
+
+            # 1st pass by default or 3rd pass with `smart_union` enabled:
+            # check if the value can be coerced into one of the Union types
             for field in self.sub_fields:
                 value, error = field.validate(v, values, loc=loc, cls=cls)
                 if error:
diff --git a/pydantic/main.py b/pydantic/main.py
--- a/pydantic/main.py
+++ b/pydantic/main.py
@@ -39,7 +39,7 @@
     get_origin,
     is_classvar,
     is_namedtuple,
-    is_union_origin,
+    is_union,
     resolve_annotations,
     update_model_forward_refs,
 )
@@ -191,7 +191,7 @@ def is_untouched(v: Any) -> bool:
                 elif is_valid_field(ann_name):
                     validate_field_name(bases, ann_name)
                     value = namespace.get(ann_name, Undefined)
-                    allowed_types = get_args(ann_type) if is_union_origin(get_origin(ann_type)) else (ann_type,)
+                    allowed_types = get_args(ann_type) if is_union(get_origin(ann_type)) else (ann_type,)
                     if (
                         is_untouched(value)
                         and ann_type != PyObject
diff --git a/pydantic/schema.py b/pydantic/schema.py
--- a/pydantic/schema.py
+++ b/pydantic/schema.py
@@ -72,7 +72,7 @@
     is_literal_type,
     is_namedtuple,
     is_none_type,
-    is_union_origin,
+    is_union,
 )
 from .utils import ROOT_KEY, get_model, lenient_issubclass, sequence_like
 
@@ -995,7 +995,7 @@ def go(type_: Any) -> Type[Any]:
 
             if origin is Annotated:
                 return go(args[0])
-            if is_union_origin(origin):
+            if is_union(origin):
                 return Union[tuple(go(a) for a in args)]  # type: ignore
 
             if issubclass(origin, List) and (field_info.min_items is not None or field_info.max_items is not None):
diff --git a/pydantic/typing.py b/pydantic/typing.py
--- a/pydantic/typing.py
+++ b/pydantic/typing.py
@@ -193,7 +193,7 @@ def get_args(tp: Type[Any]) -> Tuple[Any, ...]:
 
 if sys.version_info < (3, 10):
 
-    def is_union_origin(tp: Type[Any]) -> bool:
+    def is_union(tp: Type[Any]) -> bool:
         return tp is Union
 
     WithArgsTypes = (TypingGenericAlias,)
@@ -202,8 +202,8 @@ def is_union_origin(tp: Type[Any]) -> bool:
     import types
     import typing
 
-    def is_union_origin(origin: Type[Any]) -> bool:
-        return origin is Union or origin is types.UnionType  # noqa: E721
+    def is_union(tp: Type[Any]) -> bool:
+        return tp is Union or tp is types.UnionType  # noqa: E721
 
     WithArgsTypes = (typing._GenericAlias, types.GenericAlias, types.UnionType)
 
@@ -269,7 +269,7 @@ def is_union_origin(origin: Type[Any]) -> bool:
     'get_origin',
     'typing_base',
     'get_all_type_hints',
-    'is_union_origin',
+    'is_union',
     'StrPath',
 )
 


Test patch:
diff --git a/tests/test_types.py b/tests/test_types.py
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -22,6 +22,7 @@
     Sequence,
     Set,
     Tuple,
+    Union,
 )
 from uuid import UUID
 
@@ -2838,6 +2839,143 @@ class Model(BaseModel):
     ]
 
 
+def test_default_union_types():
+    class DefaultModel(BaseModel):
+        v: Union[int, bool, str]
+
+    assert DefaultModel(v=True).dict() == {'v': 1}
+    assert DefaultModel(v=1).dict() == {'v': 1}
+    assert DefaultModel(v='1').dict() == {'v': 1}
+
+    # In 3.6, Union[int, bool, str] == Union[int, str]
+    allowed_json_types = ('integer', 'string') if sys.version_info[:2] == (3, 6) else ('integer', 'boolean', 'string')
+
+    assert DefaultModel.schema() == {
+        'title': 'DefaultModel',
+        'type': 'object',
+        'properties': {'v': {'title': 'V', 'anyOf': [{'type': t} for t in allowed_json_types]}},
+        'required': ['v'],
+    }
+
+
+def test_smart_union_types():
+    class SmartModel(BaseModel):
+        v: Union[int, bool, str]
+
+        class Config:
+            smart_union = True
+
+    assert SmartModel(v=1).dict() == {'v': 1}
+    assert SmartModel(v=True).dict() == {'v': True}
+    assert SmartModel(v='1').dict() == {'v': '1'}
+
+    # In 3.6, Union[int, bool, str] == Union[int, str]
+    allowed_json_types = ('integer', 'string') if sys.version_info[:2] == (3, 6) else ('integer', 'boolean', 'string')
+
+    assert SmartModel.schema() == {
+        'title': 'SmartModel',
+        'type': 'object',
+        'properties': {'v': {'title': 'V', 'anyOf': [{'type': t} for t in allowed_json_types]}},
+        'required': ['v'],
+    }
+
+
+def test_default_union_class():
+    class A(BaseModel):
+        x: str
+
+    class B(BaseModel):
+        x: str
+
+    class Model(BaseModel):
+        y: Union[A, B]
+
+    assert isinstance(Model(y=A(x='a')).y, A)
+    # `B` instance is coerced to `A`
+    assert isinstance(Model(y=B(x='b')).y, A)
+
+
+def test_smart_union_class():
+    class A(BaseModel):
+        x: str
+
+    class B(BaseModel):
+        x: str
+
+    class Model(BaseModel):
+        y: Union[A, B]
+
+        class Config:
+            smart_union = True
+
+    assert isinstance(Model(y=A(x='a')).y, A)
+    assert isinstance(Model(y=B(x='b')).y, B)
+
+
+def test_default_union_subclass():
+    class MyStr(str):
+        ...
+
+    class Model(BaseModel):
+        x: Union[int, str]
+
+    assert Model(x=MyStr('1')).x == 1
+
+
+def test_smart_union_subclass():
+    class MyStr(str):
+        ...
+
+    class Model(BaseModel):
+        x: Union[int, str]
+
+        class Config:
+            smart_union = True
+
+    assert Model(x=MyStr('1')).x == '1'
+
+
+def test_default_union_compound_types():
+    class Model(BaseModel):
+        values: Union[Dict[str, str], List[str]]
+
+    assert Model(values={'L': '1'}).dict() == {'values': {'L': '1'}}
+    assert Model(values=['L1']).dict() == {'values': {'L': '1'}}  # dict(['L1']) == {'L': '1'}
+
+
+def test_smart_union_compound_types():
+    class Model(BaseModel):
+        values: Union[Dict[str, str], List[str], Dict[str, List[str]]]
+
+        class Config:
+            smart_union = True
+
+    assert Model(values={'L': '1'}).dict() == {'values': {'L': '1'}}
+    assert Model(values=['L1']).dict() == {'values': ['L1']}
+    assert Model(values=('L1',)).dict() == {'values': {'L': '1'}}  # expected coercion into first dict if not a list
+    assert Model(values={'x': ['pika']}) == {'values': {'x': ['pika']}}
+    assert Model(values={'x': ('pika',)}).dict() == {'values': {'x': ['pika']}}
+    with pytest.raises(ValidationError) as e:
+        Model(values={'x': {'a': 'b'}})
+    assert e.value.errors() == [
+        {'loc': ('values', 'x'), 'msg': 'str type expected', 'type': 'type_error.str'},
+        {'loc': ('values',), 'msg': 'value is not a valid list', 'type': 'type_error.list'},
+        {'loc': ('values', 'x'), 'msg': 'value is not a valid list', 'type': 'type_error.list'},
+    ]
+
+
+def test_smart_union_compouned_types_edge_case():
+    """For now, `smart_union` does not support well compound types"""
+
+    class Model(BaseModel, smart_union=True):
+        x: Union[List[str], List[int]]
+
+    # should consider [1, 2] valid and not coerce once `smart_union` is improved
+    assert Model(x=[1, 2]).x == ['1', '2']
+    # still coerce if needed
+    assert Model(x=[1, '2']).x == ['1', '2']
+
+
 @pytest.mark.parametrize(
     'value,result',
     (