Repository: coverahealth/dataspec

Issue:
`s.any` specs do not conform values using matching conformer
Specs created with `s.any` will not conform using the conformer of the matching Spec (if any).

```python
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,
)
```

Will produce

```python
phone_type.conform("H")  # = H
```

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2479c78..f4beab9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Allow `s.str`'s `regex` kwarg to be a pre-existing pattern returned from
   `re.compile` (#36)
 
+### Fixed
+- `s.any` now conforms values using the first successful Spec's conformer (#35)
+
 ## [v0.2.1] - 2019-10-28
 ### Fixed
 - Allow regex repetition pattern (such as `\d{5}`) in error format string
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index d46785a..415a1d4 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -56,7 +56,11 @@ def _tag_maybe(
     return tag, (cast("Tuple[T, ...]", (maybe_tag, *args)) if tag is None else args)
 
 
-def any_spec(*preds: SpecPredicate, conformer: Optional[Conformer] = None) -> Spec:
+def any_spec(
+    *preds: SpecPredicate,
+    tag_conformed: bool = False,
+    conformer: Optional[Conformer] = None,
+) -> Spec:
     """
     Return a Spec which validates input values against any one of an arbitrary
     number of input Specs.
@@ -71,8 +75,20 @@ def any_spec(*preds: SpecPredicate, conformer: Optional[Conformer] = None) -> Sp
     no :py:class:`dataspec.base.ErrorDetails` will be emitted by the
     :py:meth:`dataspec.base.Spec.validate` method.
 
+    The conformer for the returned Spec will select the conformer for the first
+    contituent Spec which successfully validates the input value. If a ``conformer``
+    is specified for this Spec, that conformer will be applied after the successful
+    Spec's conformer. If ``tag_conformed`` is specified, the final conformed value
+    from both conformers will be wrapped in a tuple, where the first element is the
+    tag of the successful Spec and the second element is the final conformed value.
+    If ``tag_conformed`` is not specified (which is the default), the conformer will
+    emit the conformed value directly.
+
     :param tag: an optional tag for the resulting spec
     :param preds: one or more Specs or values which can be converted into a Spec
+    :param tag_conformed: if :py:obj:`True`, the conformed value will be wrapped in a
+        2-tuple where the first element is the successful spec and the second element
+        is the conformed value; if :py:obj:`False`, return only the conformed value
     :param conformer: an optional conformer for the value
     :return: a Spec
     """
@@ -90,7 +106,23 @@ def any_spec(*preds: SpecPredicate, conformer: Optional[Conformer] = None) -> Sp
 
         yield from errors
 
-    return ValidatorSpec(tag or "any", _any_valid, conformer=conformer)
+    def _conform_any(e):
+        for spec in specs:
+            spec_errors = [error for error in spec.validate(e)]
+            if spec_errors:
+                continue
+            else:
+                conformed = spec.conform_valid(e)
+                assert conformed is not INVALID
+                if conformer is not None:
+                    conformed = conformer(conformed)
+                if tag_conformed:
+                    conformed = (spec.tag, conformed)
+                return conformed
+
+        return INVALID
+
+    return ValidatorSpec(tag or "any", _any_valid, conformer=_conform_any)
 
 
 def all_spec(*preds: SpecPredicate, conformer: Optional[Conformer] = None) -> Spec:


Test patch:
diff --git a/tests/test_factories.py b/tests/test_factories.py
index 40e2efc..ee64c61 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -87,15 +87,109 @@ class TestAllSpecConformation:
         assert expected == all_spec.conform(v)
 
 
-def test_any():
-    spec = s.any(s.is_num, s.is_str)
-    assert spec.is_valid("5")
-    assert spec.is_valid(5)
-    assert spec.is_valid(3.14)
-    assert not spec.is_valid(None)
-    assert not spec.is_valid({})
-    assert not spec.is_valid(set())
-    assert not spec.is_valid([])
+class TestAnySpecValidation:
+    @pytest.fixture
+    def any_spec(self) -> Spec:
+        return s.any(s.is_num, s.is_str)
+
+    @pytest.mark.parametrize("v", ["5", 5, 3.14])
+    def test_any_validation(self, any_spec: Spec, v):
+        assert any_spec.is_valid(v)
+
+    @pytest.mark.parametrize("v", [None, {}, set(), []])
+    def test_any_validation_failure(self, any_spec: Spec, v):
+        assert not any_spec.is_valid(v)
+
+
+class TestAnySpecConformation:
+    @pytest.fixture
+    def spec(self) -> Spec:
+        return s.any(s.num("num"), s.str("numstr", regex=r"\d+", conformer=int))
+
+    @pytest.mark.parametrize("expected,v", [(5, "5"), (5, 5), (3.14, 3.14), (-10, -10)])
+    def test_conformation(self, spec: Spec, expected, v):
+        assert expected == spec.conform(v)
+
+    @pytest.mark.parametrize(
+        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
+    )
+    def test_conformation_failure(self, spec: Spec, v):
+        assert INVALID is spec.conform(v)
+
+    @pytest.fixture
+    def tag_spec(self) -> Spec:
+        return s.any(
+            s.num("num"),
+            s.str("numstr", regex=r"\d+", conformer=int),
+            tag_conformed=True,
+        )
+
+    @pytest.mark.parametrize(
+        "expected,v",
+        [
+            (("numstr", 5), "5"),
+            (("num", 5), 5),
+            (("num", 3.14), 3.14),
+            (("num", -10), -10),
+        ],
+    )
+    def test_tagged_conformation(self, tag_spec: Spec, expected, v):
+        assert expected == tag_spec.conform(v)
+
+    @pytest.mark.parametrize(
+        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
+    )
+    def test_tagged_conformation_failure(self, tag_spec: Spec, v):
+        assert INVALID is tag_spec.conform(v)
+
+
+class TestAnySpecWithOuterConformation:
+    @pytest.fixture
+    def spec(self) -> Spec:
+        return s.any(
+            s.num("num"),
+            s.str("numstr", regex=r"\d+", conformer=int),
+            conformer=lambda v: v + 5,
+        )
+
+    @pytest.mark.parametrize(
+        "expected,v", [(10, "5"), (10, 5), (8.14, 3.14), (-5, -10)]
+    )
+    def test_conformation(self, spec: Spec, expected, v):
+        assert expected == spec.conform(v)
+
+    @pytest.mark.parametrize(
+        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
+    )
+    def test_conformation_failure(self, spec: Spec, v):
+        assert INVALID is spec.conform(v)
+
+    @pytest.fixture
+    def tag_spec(self) -> Spec:
+        return s.any(
+            s.num("num"),
+            s.str("numstr", regex=r"\d+", conformer=int),
+            tag_conformed=True,
+            conformer=lambda v: v + 5,
+        )
+
+    @pytest.mark.parametrize(
+        "expected,v",
+        [
+            (("numstr", 10), "5"),
+            (("num", 10), 5),
+            (("num", 8.14), 3.14),
+            (("num", -5), -10),
+        ],
+    )
+    def test_tagged_conformation(self, tag_spec: Spec, expected, v):
+        assert expected == tag_spec.conform(v)
+
+    @pytest.mark.parametrize(
+        "v", [None, {}, set(), [], "500x", "Just a sentence", b"500", b"byteword"]
+    )
+    def test_tagged_conformation_failure(self, tag_spec: Spec, v):
+        assert INVALID is tag_spec.conform(v)
 
 
 @pytest.mark.parametrize(