Repository: lamenezes/simple-model

Issue:
Support fields with type typing.Union

Discussion:
eltonscm: I think this also solves #23 because _"Optional[X] is equivalent to Union[X, None]"_ [0]. So, the following test should pass:

```python
@pytest.mark.parametrize('default', [1, None])
def test_model_validate_optional(default):
    class Foo(Model):
        optional: typing.Optional[int] = default

    foo = Foo()
    foo.validate()

    assert foo.optional == default
```

[0] https://docs.python.org/3/library/typing.html#typing.Optional
lamenezes: @daneoshiga thanks for your PR! I'll review it as soon as I can.

Patch:
diff --git a/README.rst b/README.rst
index b61c4bd..39fd900 100644
--- a/README.rst
+++ b/README.rst
@@ -138,10 +138,14 @@ followed by the attribute name, e.g.
         def validate_age(self, age):
             if age < 0 or age > 150:
                 raise ValidationError('Invalid value for age {!r}'.format(age))
+            
+            return age
 
         def validate_height(self, height):
             if height <= 0:
                raise ValidationError('Invalid value for height {!r}'.format(age))
+            
+            return height
 
 
 Let's test it:
diff --git a/simple_model/fields.py b/simple_model/fields.py
index d189463..baf9720 100644
--- a/simple_model/fields.py
+++ b/simple_model/fields.py
@@ -1,4 +1,4 @@
-from typing import Any, List, Tuple, TypeVar
+from typing import Any, List, Union, Tuple, TypeVar
 
 from .exceptions import EmptyField
 
@@ -37,6 +37,13 @@ class ModelField:
         if not field_class or field_class is Any or value is None or self.is_property:
             return value
 
+        if field_class is Union:
+            assert issubclass(type(value), field_type.__args__), (
+                'Field of type {} received an object of invalid type {}').format(
+                    field_type.__args__, type(value))
+
+            return value
+
         if not issubclass(field_class, PARAMETRIZED_GENERICS) and type(value) is field_class:
             return value
 


Test patch:
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 9842f0b..0786759 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -128,6 +128,19 @@ def test_model_field_convert_to_type_unset(typeless_model_field):
     assert typeless_model_field.convert_to_type(None, value, field_class=typing.Any) is value
 
 
+@pytest.mark.parametrize('value', (1, '1'))
+def test_model_field_convert_to_type_union(typeless_model_field, value):
+    assert typeless_model_field.convert_to_type(None, value, field_class=typing.Union[int, str]) is value
+
+
+def test_model_field_convert_to_type_union_invalid(typeless_model_field):
+    value = dict()
+    with pytest.raises(AssertionError) as exc_info:
+        typeless_model_field.convert_to_type(None, value, field_class=typing.Union[int, str])
+
+    assert str(exc_info.value) == "Field of type (<class 'int'>, <class 'str'>) received an object of invalid type <class 'dict'>"
+
+
 def test_model_field_convert_to_type_value_has_correct_type(model_field):
     value = 'valuable'
 
diff --git a/tests/test_models.py b/tests/test_models.py
index 1ffe217..85d7f2c 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -427,6 +427,17 @@ def test_field_factory_model():
     assert model.string == 'foobar'
 
 
+@pytest.mark.parametrize('default', ('1', 1))
+def test_model_validate_union(default):
+    class UnionModel(Model):
+        union: typing.Union[int, str] = default
+
+    union_model = UnionModel()
+    union_model.validate()
+    assert union_model.union == default
+    assert type(union_model.union) == type(default)
+
+
 def test_model_validate_and_clean_invalid_mocked_model(model):
     model.validate = mock.Mock(side_effect=ValidationError)