Repository: coverahealth/dataspec

Issue:
`s.blankable` for optional string values
For optional object-type values, `dataspec` includes `s.nilable`. These values may be _either_ a value conforming to their input Spec, _or_ `None`. However, stringly-typed Specs don't have an equivalent utility factory.

`dataspec` should have `s.blankable` which is allows values either matching the input Spec or the empty string.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f497a74..7699e9c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+### Added
+- Add `s.blankable` Spec for specifying optional string values (#45)
+- Add `s.default` Spec for specifying default values on failing Specs (#46)
 
 
 ## [v0.2.3] - 2019-11-27
diff --git a/src/dataspec/api.py b/src/dataspec/api.py
index 902f7ef..bc594ca 100644
--- a/src/dataspec/api.py
+++ b/src/dataspec/api.py
@@ -12,10 +12,12 @@ from dataspec.base import (
 from dataspec.factories import (
     all_spec,
     any_spec,
+    blankable_spec,
     bool_spec,
     bytes_spec,
     date_spec,
     datetime_spec,
+    default_spec,
     email_spec,
     every_spec,
     nilable_spec,
@@ -100,9 +102,11 @@ class SpecAPI:
     # Spec factories
     any = staticmethod(any_spec)
     all = staticmethod(all_spec)
+    blankable = staticmethod(blankable_spec)
     bool = staticmethod(bool_spec)
     bytes = staticmethod(bytes_spec)
     date = staticmethod(date_spec)
+    default = staticmethod(default_spec)
     email = staticmethod(email_spec)
     every = staticmethod(every_spec)
     inst = staticmethod(datetime_spec)
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index 415a1d4..6189171 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -98,7 +98,7 @@ def any_spec(
     def _any_valid(e) -> Iterator[ErrorDetails]:
         errors = []
         for spec in specs:
-            spec_errors = [error for error in spec.validate(e)]
+            spec_errors = list(spec.validate(e))
             if spec_errors:
                 errors.extend(spec_errors)
             else:
@@ -108,17 +108,17 @@ def any_spec(
 
     def _conform_any(e):
         for spec in specs:
-            spec_errors = [error for error in spec.validate(e)]
+            spec_errors = list(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
+
+            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
 
@@ -171,6 +171,27 @@ def all_spec(*preds: SpecPredicate, conformer: Optional[Conformer] = None) -> Sp
     )
 
 
+def blankable_spec(
+    *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
+):
+    """
+    Return a Spec which will validate values either by the input Spec or allow the
+    empty string.
+
+    The returned Spec is equivalent to `s.any(spec, {""})`.
+
+    :param tag: an optional tag for the resulting spec
+    :param pred: a Spec or value which can be converted into a Spec
+    :param conformer: an optional conformer for the value
+    :return: a Spec which validates either according to ``pred`` or the empty string
+    """
+    tag, preds = _tag_maybe(*args)  # pylint: disable=no-value-for-parameter
+    assert len(preds) == 1, "Only one predicate allowed"
+    return any_spec(
+        tag or "blankable", preds[0], {""}, conformer=conformer  # type: ignore
+    )
+
+
 def bool_spec(
     tag: Optional[Tag] = None,
     allowed_values: Optional[Set[bool]] = None,
@@ -314,6 +335,38 @@ def bytes_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
     )
 
 
+def default_spec(
+    *args: Union[Tag, SpecPredicate],
+    default: Any = None,
+    conformer: Optional[Conformer] = None,
+) -> Spec:
+    """
+    Return a Spec which will validate every value, but which will conform values not
+    meeting the Spec to a default value.
+
+    The returned Spec is equivalent to the following Spec:
+
+        `s.any(spec, s.every(conformer=lambda _: default)`
+
+    This Spec **will allow any value to pass**, but will conform to the given default
+    if the data does not satisfy the input Spec.
+
+    :param tag: an optional tag for the resulting spec
+    :param pred: a Spec or value which can be converted into a Spec
+    :param default: the default value to apply if the Spec does not validate a value
+    :param conformer: an optional conformer for the value
+    :return: a Spec which validates every value, but which conforms values to a default
+    """
+    tag, preds = _tag_maybe(*args)  # pylint: disable=no-value-for-parameter
+    assert len(preds) == 1, "Only one predicate allowed"
+    return any_spec(
+        tag or "default",  # type: ignore
+        preds[0],  # type: ignore
+        every_spec(conformer=lambda _: default),
+        conformer=conformer,
+    )
+
+
 def every_spec(
     tag: Optional[Tag] = None, conformer: Optional[Conformer] = None
 ) -> Spec:
diff --git a/tox.ini b/tox.ini
index 55d62b2..b7dec87 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,7 +3,7 @@ envlist = {py36,py37,py38}{-dateutil,-phonenumbers,},coverage,format,mypy,lint,s
 
 [testenv]
 deps =
-    coverage
+    coverage==4.5.4
     dateutil: python-dateutil
     phonenumbers: phonenumbers
     ;; PyTest 5.2.3 created a situation where coverage.py collected no results
@@ -22,7 +22,7 @@ commands =
 depends = py36,py37,py38
 deps =
     coveralls
-    coverage
+    coverage==4.5.4
 passenv = COVERALLS_REPO_TOKEN
 setenv =
     ; Disable PEP 517 behavior since we're using usedevelop = true
@@ -66,7 +66,7 @@ commands =
 [testenv:mypy]
 deps = mypy
 commands =
-    mypy --config-file={toxinidir}/mypy.ini {toxinidir}/src/
+    mypy --show-error-codes --config-file={toxinidir}/mypy.ini {toxinidir}/src/
 
 [testenv:lint]
 deps =


Test patch:
diff --git a/tests/test_factories.py b/tests/test_factories.py
index 0b271ee..8523c44 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -203,6 +203,22 @@ def test_is_any(v):
     assert s.is_any.is_valid(v)
 
 
+class TestBlankableSpecValidation:
+    @pytest.fixture
+    def blankable_spec(self) -> Spec:
+        return s.blankable(s.str(regex=r"\d{5}"))
+
+    @pytest.mark.parametrize("v", ["", "11111", "12345"])
+    def test_blankable_validation(self, blankable_spec: Spec, v):
+        assert blankable_spec.is_valid(v)
+
+    @pytest.mark.parametrize(
+        "v", ["    ", "1234", "1234D", "   12345", None, {}, set(), []]
+    )
+    def test_blankable_validation_failure(self, blankable_spec: Spec, v):
+        assert not blankable_spec.is_valid(v)
+
+
 class TestBoolValidation:
     @pytest.mark.parametrize("v", [True, False])
     def test_bool(self, v):
@@ -338,6 +354,59 @@ class TestBytesSpecValidation:
             s.bytes(minlength=10, maxlength=8)
 
 
+class TestDefaultSpecValidation:
+    @pytest.fixture
+    def default_spec(self) -> Spec:
+        return s.default(s.str(regex=r"\d{5}"))
+
+    @pytest.mark.parametrize(
+        "v",
+        [
+            "    ",
+            "1234",
+            "1234D",
+            "   12345",
+            None,
+            {},
+            set(),
+            [],
+            "",
+            "11111",
+            "12345",
+        ],
+    )
+    def test_default_validation(self, default_spec: Spec, v):
+        assert default_spec.is_valid(v)
+
+
+class TestDefaultSpecConformation:
+    @pytest.fixture
+    def default_spec(self) -> Spec:
+        return s.default(
+            s.str(regex=r"\d{5}", conformer=lambda v: v[:2] + "-" + v[2:]), default=""
+        )
+
+    @pytest.mark.parametrize(
+        "v,conformed",
+        [
+            ("    ", ""),
+            ("1234", ""),
+            ("1234D", ""),
+            ("   12345", ""),
+            (None, ""),
+            ({}, ""),
+            (set(), ""),
+            ([], ""),
+            ("", ""),
+            ("11111", "11-111"),
+            ("12345", "12-345"),
+        ],
+    )
+    def test_default_conformation(self, default_spec: Spec, v, conformed):
+        assert default_spec.is_valid(v)
+        assert conformed == default_spec.conform(v)
+
+
 class TestEmailSpecValidation:
     @pytest.mark.parametrize(
         "spec_kwargs",