Repository: coverahealth/dataspec

Issue:
Alternate conformers replace default conformers
In lots of builtin Spec factories, alternate conformers (passed via `conformer=...` keyword argument to the Spec factory) will overwrite the existing default conformers, which often apply useful logic (in the case of `s.date(format_="...")` the conformer will convert the value into a date, which is a desirable value for custom conformers).

In an example case, suppose you want to create a Spec which conforms a date from a string and then creates a datetime with a default time. You might think you could do this:

```python
s.date(
    format_="%m%d%Y",
    conformer=lambda d: datetime(d.year, d.month, d.day, 20)
)
```

But in fact, `d` will be a string since the default conformer for `s.date` was overwritten when a new conformer was supplied. So users will be forced to come up with a concoction like this:

```python
s.all(
    s.date(format_="%m%d%Y"),
    s.every(
        conformer=lambda d: datetime(d.year, d.month, d.day, 20)
    ),
)
```

...or perhaps include the logic in their custom conformer.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26d754a..19f8470 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Fixed
 - Predicates decorated by `pred_to_validator` will now properly be converted into
   validator specs (#54)
+- Apply conformers for `s.inst`, `s.date`, `s.time`, `s.inst_str`, and `s.phone` after
+  the default conformer, so conformers have access to the conformed object (#50)
 
 ## [v0.2.4] - 2019-12-19
 ### Added
diff --git a/src/dataspec/base.py b/src/dataspec/base.py
index 5c9915e..877b918 100644
--- a/src/dataspec/base.py
+++ b/src/dataspec/base.py
@@ -223,7 +223,7 @@ class ValidatorSpec(Spec):
                 yield from spec.validate(v)
 
         return cls(
-            tag, do_validate, compose_conformers(*specs, conform_final=conformer)
+            tag, do_validate, compose_spec_conformers(*specs, conform_final=conformer)
         )
 
 
@@ -590,7 +590,28 @@ def _enrich_errors(
         yield error.with_details(tag, loc=loc)
 
 
-def compose_conformers(
+def compose_conformers(*conformers: Conformer) -> Conformer:
+    """
+    Return a single conformer which is the composition of the input conformers.
+
+    If a single conformer is given, return the conformer.
+    """
+
+    if len(conformers) == 1:
+        return conformers[0]
+
+    def do_conform(v):
+        conformed_v = v
+        for conform in conformers:
+            conformed_v = conform(conformed_v)
+            if conformed_v is INVALID:
+                break
+        return conformed_v
+
+    return do_conform
+
+
+def compose_spec_conformers(
     *specs: Spec, conform_final: Optional[Conformer] = None
 ) -> Conformer:
     """
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index 149a497..5c6a6b9 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -40,6 +40,7 @@ from dataspec.base import (
     ValidatorFn,
     ValidatorSpec,
     compose_conformers,
+    compose_spec_conformers,
     make_spec,
     pred_to_validator,
 )
@@ -167,7 +168,7 @@ def all_spec(*preds: SpecPredicate, conformer: Optional[Conformer] = None) -> Sp
     return ValidatorSpec(
         tag or "all",
         _all_valid,
-        conformer=compose_conformers(*specs, conform_final=conformer),
+        conformer=compose_spec_conformers(*specs, conform_final=conformer),
     )
 
 
@@ -561,7 +562,9 @@ def _make_datetime_spec_factory(  # noqa: MC0001
             return ValidatorSpec(
                 tag or type.__name__,
                 validate_datetime_str,
-                conformer=conformer or conform_datetime_str,
+                conformer=compose_conformers(
+                    conform_datetime_str, *filter(None, (conformer,))
+                ),
             )
         else:
             return ValidatorSpec.from_validators(
@@ -648,7 +651,12 @@ else:
                 yield from dt_spec.validate(parsed_dt)
 
         return ValidatorSpec.from_validators(
-            tag, is_str, str_contains_datetime, conformer=conformer or parse_date
+            tag,
+            is_str,
+            str_contains_datetime,
+            conformer=compose_conformers(
+                cast(Conformer, parse_date_str), *filter(None, (conformer,))
+            ),
         )
 
 
@@ -982,7 +990,7 @@ else:
         region: Optional[str] = None,
         is_possible: bool = True,
         is_valid: bool = True,
-        conformer: Optional[Conformer] = conform_phonenumber,
+        conformer: Optional[Conformer] = None,
     ) -> Spec:
         """
         Return a Spec that validates strings containing telephone number in most
@@ -1026,6 +1034,7 @@ else:
         """
 
         tag = tag or "phonenumber_str"
+        default_conformer = conform_phonenumber
 
         @pred_to_validator(f"Value '{{value}}' is not type 'str'", complement=True)
         def is_str(x: Any) -> bool:
@@ -1054,8 +1063,7 @@ else:
 
             validators.append(validate_phonenumber_region)
 
-            if conformer is conform_phonenumber:
-                conformer = partial(conform_phonenumber, region=region)
+            default_conformer = partial(default_conformer, region=region)
 
         if is_possible:
 
@@ -1094,7 +1102,12 @@ else:
                 yield from validate_phonenumber(p)
 
         return ValidatorSpec.from_validators(
-            tag, is_str, str_contains_phonenumber, conformer=conformer
+            tag,
+            is_str,
+            str_contains_phonenumber,
+            conformer=compose_conformers(
+                default_conformer, *filter(None, (conformer,))
+            ),
         )
 
 


Test patch:
diff --git a/tests/test_factories.py b/tests/test_factories.py
index 8523c44..efd8288 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -656,6 +656,18 @@ class TestInstSpecValidation:
             assert not aware_spec.is_valid(datetime.utcnow())
 
 
+class TestInstSpecConformation:
+    @pytest.fixture
+    def year_spec(self) -> Spec:
+        return s.inst(format_="%Y-%m-%d %I:%M:%S", conformer=lambda inst: inst.year)
+
+    @pytest.mark.parametrize(
+        "inst,year", [("2003-01-14 01:41:16", 2003), ("0994-12-31 08:00:00", 994),],
+    )
+    def test_conformer(self, year_spec: Spec, inst: datetime, year: int):
+        assert year == year_spec.conform(inst)
+
+
 class TestDateSpecValidation:
     @pytest.mark.parametrize(
         "v", [date(year=2000, month=1, day=1), datetime(year=2000, month=1, day=1)]
@@ -771,6 +783,27 @@ class TestDateSpecValidation:
                 s.date(is_aware=True)
 
 
+class TestDateSpecConformation:
+    @pytest.fixture
+    def year_spec(self) -> Spec:
+        return s.date(format_="%Y-%m-%d", conformer=lambda dt: dt.year)
+
+    @pytest.mark.parametrize(
+        "dtstr, year",
+        [
+            ("1980-01-15", 1980),
+            ("1980-1-15", 1980),
+            ("1999-12-31", 1999),
+            ("2000-1-1", 2000),
+            ("2000-1-01", 2000),
+            ("2000-01-1", 2000),
+            ("2000-01-01", 2000),
+        ],
+    )
+    def test_conformer(self, year_spec: Spec, dtstr: str, year: int):
+        assert year == year_spec.conform(dtstr)
+
+
 class TestTimeSpecValidation:
     @pytest.mark.parametrize("v", [time()])
     def test_is_time(self, v):
@@ -899,6 +932,18 @@ class TestTimeSpecValidation:
             assert not aware_spec.is_valid(time())
 
 
+class TestTimeSpecConformation:
+    @pytest.fixture
+    def hour_spec(self) -> Spec:
+        return s.time(format_="%H:%M:%S", conformer=lambda tm: tm.hour)
+
+    @pytest.mark.parametrize(
+        "tmstr,hour", [("23:18:22", 23), ("11:40:22", 11), ("06:43:13", 6)],
+    )
+    def test_conformer(self, hour_spec: Spec, tmstr: str, hour: int):
+        assert hour == hour_spec.conform(tmstr)
+
+
 @pytest.mark.skipif(parse_date is None, reason="python-dateutil must be installed")
 class TestInstStringSpecValidation:
     ISO_DATETIMES = [
@@ -916,7 +961,9 @@ class TestInstStringSpecValidation:
         ("0031-01-01T00:00:00", datetime(31, 1, 1, 0, 0)),
         ("20080227T21:26:01.123456789", datetime(2008, 2, 27, 21, 26, 1, 123456)),
         ("0003-03-04", datetime(3, 3, 4)),
-        ("950404 122212", datetime(1995, 4, 4, 12, 22, 12)),
+    ]
+    ISO_ONLY_DATETIMES = [
+        ("950404 122212", datetime(9504, 4, 1, 22, 12)),
     ]
     NON_ISO_DATETIMES = [
         ("Thu Sep 25 10:36:28 2003", datetime(2003, 9, 25, 10, 36, 28)),
@@ -964,13 +1011,18 @@ class TestInstStringSpecValidation:
         ("13NOV2017", datetime(2017, 11, 13)),
         ("December.0031.30", datetime(31, 12, 30)),
     ]
+    NON_ISO_ONLY_DATETIMES = [
+        ("950404 122212", datetime(1995, 4, 4, 12, 22, 12)),
+    ]
     ALL_DATETIMES = ISO_DATETIMES + NON_ISO_DATETIMES
 
     @pytest.fixture
     def inst_str_spec(self) -> Spec:
         return s.inst_str()
 
-    @pytest.mark.parametrize("date_str,datetime_obj", ALL_DATETIMES)
+    @pytest.mark.parametrize(
+        "date_str,datetime_obj", ALL_DATETIMES + NON_ISO_ONLY_DATETIMES
+    )
     def test_inst_str_validation(self, inst_str_spec: Spec, date_str, datetime_obj):
         assert inst_str_spec.is_valid(date_str)
         assert datetime_obj == inst_str_spec.conform(date_str)
@@ -986,7 +1038,9 @@ class TestInstStringSpecValidation:
     def iso_inst_str_spec(self) -> Spec:
         return s.inst_str(iso_only=True)
 
-    @pytest.mark.parametrize("date_str,datetime_obj", ISO_DATETIMES)
+    @pytest.mark.parametrize(
+        "date_str,datetime_obj", ISO_DATETIMES + ISO_ONLY_DATETIMES
+    )
     def test_iso_inst_str_validation(
         self, iso_inst_str_spec: Spec, date_str, datetime_obj
     ):