Repository: coverahealth/dataspec

Issue:
Enum conformers fail to conform valid Enum values
Using the example from #35:

```python
from enum import Enum
from dataspec import s

class PhoneType(Enum):
    HOME = "Home"
    MOBILE = "Mobile"
    OFFICE = "Office"


phone_type = s.any(
    "phone_type",
    s(
        {"H", "M", "O", ""},
        conformer=lambda v: {
            "H": PhoneType.HOME,
            "M": PhoneType.MOBILE,
            "O": PhoneType.OFFICE,
        }.get(v, ""),
    ),
    PhoneType,
)
```

```
>>> phone_type.is_valid("HOME")
True
>>> phone_type.conform("HOME")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/chris/Projects/dataspec/src/dataspec/base.py", line 161, in conform
    return self.conform_valid(v)
  File "/Users/chris/Projects/dataspec/src/dataspec/base.py", line 155, in conform_valid
    return self.conformer(v)
  File "/Users/chris/Projects/dataspec/src/dataspec/factories.py", line 115, in _conform_any
    conformed = spec.conform_valid(e)
  File "/Users/chris/Projects/dataspec/src/dataspec/base.py", line 155, in conform_valid
    return self.conformer(v)
  File "/Users/chris/.pyenv/versions/3.6.6/lib/python3.6/enum.py", line 291, in __call__
    return cls.__new__(cls, value)
  File "/Users/chris/.pyenv/versions/3.6.6/lib/python3.6/enum.py", line 533, in __new__
    return cls._missing_(value)
  File "/Users/chris/.pyenv/versions/3.6.6/lib/python3.6/enum.py", line 546, in _missing_
    raise ValueError("%r is not a valid %s" % (value, cls.__name__))
ValueError: 'HOME' is not a valid PhoneType
```

The problem is that we use the enum itself as the conformer, but Enums used as functions only accept the _values_ of the enum, not the _names_.

Discussion:

Patch:
diff --git a/src/dataspec/base.py b/src/dataspec/base.py
index e9364a9..7f07262 100644
--- a/src/dataspec/base.py
+++ b/src/dataspec/base.py
@@ -486,6 +486,22 @@ class ObjectSpec(DictSpec):
         raise TypeError("Cannot use a default conformer for an Object")
 
 
+def _enum_conformer(e: EnumMeta) -> Conformer:
+    """Create a conformer for Enum types which accepts Enum instances, Enum values,
+    and Enum names."""
+
+    def conform_enum(v) -> Union[EnumMeta, Invalid]:
+        try:
+            return e(v)
+        except ValueError:
+            try:
+                return e[v]
+            except KeyError:
+                return INVALID
+
+    return conform_enum
+
+
 @attr.s(auto_attribs=True, frozen=True, slots=True)
 class SetSpec(Spec):
     tag: Tag
@@ -668,7 +684,7 @@ def make_spec(  # pylint: disable=inconsistent-return-statements
         return SetSpec(
             tag or pred.__name__,
             frozenset(chain.from_iterable([mem, mem.name, mem.value] for mem in pred)),
-            conformer=conformer or pred,
+            conformer=conformer or _enum_conformer(pred),
         )
     elif isinstance(pred, tuple):
         return TupleSpec.from_val(tag, pred, conformer=conformer)


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index 7261475..f0a45c6 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -526,6 +526,8 @@ class TestEnumSetSpec:
         assert not enum_spec.is_valid(None)
 
     def test_enum_spec_conformation(self, enum_spec: Spec):
+        assert self.YesNo.YES == enum_spec.conform("YES")
+        assert self.YesNo.NO == enum_spec.conform("NO")
         assert self.YesNo.YES == enum_spec.conform("Yes")
         assert self.YesNo.NO == enum_spec.conform("No")
         assert self.YesNo.YES == enum_spec.conform(self.YesNo.YES)
@@ -533,6 +535,10 @@ class TestEnumSetSpec:
         assert INVALID is enum_spec.conform("Maybe")
         assert INVALID is enum_spec.conform(None)
 
+        # Testing the last branch of the conformer
+        assert INVALID is enum_spec.conform_valid("Maybe")
+        assert INVALID is enum_spec.conform_valid(None)
+
 
 class TestTupleSpecValidation:
     @pytest.fixture
diff --git a/tests/test_factories.py b/tests/test_factories.py
index ee64c61..0b271ee 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -115,6 +115,7 @@ class TestAnySpecConformation:
     )
     def test_conformation_failure(self, spec: Spec, v):
         assert INVALID is spec.conform(v)
+        assert INVALID is spec.conform_valid(v)
 
     @pytest.fixture
     def tag_spec(self) -> Spec:
@@ -141,6 +142,7 @@ class TestAnySpecConformation:
     )
     def test_tagged_conformation_failure(self, tag_spec: Spec, v):
         assert INVALID is tag_spec.conform(v)
+        assert INVALID is tag_spec.conform_valid(v)
 
 
 class TestAnySpecWithOuterConformation:
@@ -163,6 +165,7 @@ class TestAnySpecWithOuterConformation:
     )
     def test_conformation_failure(self, spec: Spec, v):
         assert INVALID is spec.conform(v)
+        assert INVALID is spec.conform_valid(v)
 
     @pytest.fixture
     def tag_spec(self) -> Spec:
@@ -190,6 +193,7 @@ class TestAnySpecWithOuterConformation:
     )
     def test_tagged_conformation_failure(self, tag_spec: Spec, v):
         assert INVALID is tag_spec.conform(v)
+        assert INVALID is tag_spec.conform_valid(v)
 
 
 @pytest.mark.parametrize(