Repository: coverahealth/dataspec

Issue:
Add functionality for validating generic map Specs
Normal mapping Specs involve defining specific keys with Specs for each, but it may be useful occasionally to validate dictionaries with generic key/value Specs similar to collection Specs:

```python
s.map("map_spec", str, s.str(regex="\d+"))
```

Discussion:

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index fa1fa12..68e7d61 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -18,6 +18,7 @@ jobs:
           shell: /bin/bash -leo pipefail
           environment:
             TOX_NUM_CORES: 2
+            TOX_PARALLEL_NO_SPINNER: 1
             TOX_SHOW_OUTPUT: "True"
           command: |
             tox -p $TOX_NUM_CORES
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2e117f..13c48db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   conformers (#65)
 - Added `s.merge` to allow seamless merging of mapping Specs (#70)
 - Added `ErrorDetails.as_map` to convert `ErrorDetails` instances to simple dicts (#79)
+- Added `s.kv` to validate and conform generic key/value mapping types (#71)
 
 ### Changed
 - **Breaking** `Spec.with_conformer` will now replace the default conformer applied
@@ -29,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Fixed a bug where `s(None)` is not a valid alias for `s(type(None))` (#61)
 - Fixed a bug where it was possible to define duplicate keys in mapping Specs with
   `s.opt(k)` (#74)
+- Fixed a bug where string Spec factory error message for values which do not match
+  a regex incorrectly indicates that the string _does_ match the regex (#77)
 
 ## [v0.2.5] - 2020-04-10
 ### Added
diff --git a/docs/usage.rst b/docs/usage.rst
index 18dcb2c..3859f55 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -456,6 +456,30 @@ input Spec.
    Spec types, you should wrap the mapping Specs with ``s.merge`` and pass that to
    ``s.all``.
 
+.. _key_value_specs:
+
+Key/Value Specs
+^^^^^^^^^^^^^^^
+
+Mapping Specs are useful for heterogeneous associative data structures for which the
+keys are known *a priori*. However, you may often wish to validate a homogeneous
+mapping with unknown keys. For such cases, you can turn to
+:py:meth:`dataspec.SpecAPI.kv`.
+
+.. code-block:: python
+
+   spec = s.kv(s.str(regex=r"[A-Z]{2}"), s.str(regex=r"[A-Z][\w ]+"))
+   spec.is_valid({"GA": "Georgia", "NM": "New Mexico"})  # True
+   spec.is_valid({"ga": "Georgia", "NM": "New Mexico"})  # False
+   spec.is_valid({"ga": "Georgia", "NM": "new mexico"})  # False
+
+.. note::
+
+   By default :py:meth:`s.kv <dataspec.SpecAPI.kv>` will not conform keys on input
+   values, to avoid potential creating potentially duplicate keys from the key
+   conformer. You can override this behavior with the ``conform_keys`` keyword
+   argument.
+
 .. _tuple_specs:
 
 Tuple Specs
diff --git a/src/dataspec/api.py b/src/dataspec/api.py
index 9683eed..6331126 100644
--- a/src/dataspec/api.py
+++ b/src/dataspec/api.py
@@ -9,6 +9,7 @@ from dataspec.base import (
     ValidationError,
     all_spec,
     any_spec,
+    kv_spec,
     make_spec,
     merge_spec,
 )
@@ -115,6 +116,7 @@ class SpecAPI:
     email = staticmethod(email_spec)
     every = staticmethod(every_spec)
     inst = staticmethod(datetime_spec)
+    kv = staticmethod(kv_spec)
     merge = staticmethod(merge_spec)
     nilable = staticmethod(nilable_spec)
     num = staticmethod(num_spec)
diff --git a/src/dataspec/base.py b/src/dataspec/base.py
index 9653ae0..a9e7fb4 100644
--- a/src/dataspec/base.py
+++ b/src/dataspec/base.py
@@ -65,6 +65,7 @@ SpecPredicate = Union[  # type: ignore
     List["SpecPredicate"],  # type: ignore
     FrozenSet[Any],
     Set[Any],
+    Type[Any],
     PredicateFn,
     ValidatorFn,
     "Spec",
@@ -650,7 +651,7 @@ class DictSpec(Spec):
                         via=[self.tag],
                         path=[k],
                     )
-        except TypeError:
+        except (AttributeError, TypeError):
             yield ErrorDetails(
                 message="Value is not a mapping type",
                 pred=self,
@@ -688,6 +689,83 @@ class DictSpec(Spec):
         )
 
 
+def kv_spec(
+    tag_or_pred: Union[Tag, SpecPredicate],
+    *preds: SpecPredicate,
+    conform_keys: bool = False,
+    conformer: Optional[Conformer] = None,
+) -> Spec:
+    """
+    Return a Spec that validates mapping types against a single Spec for all keys
+    and a single Spec for all values.
+
+    If ``conform_keys`` is specified as :py:obj:`True`, the default conformer will
+    conform keys and values. By default, ``conform_keys`` is :py:obj:`False` to avoid
+    duplicate names produced during the conformation.
+
+    The returned Spec's :py:meth:`dataspec.Spec.conform` method will return a
+    :py:class:`dict` with values conformed by the corresponding input Spec. If a
+    ``conformer`` is provided via keyword argument, that conformer will be provided a
+    :py:class:`dict` with the conformed :py:class:`dict` as described above. Otherwise,
+    the default conformer will simply return the conformed :py:class:`dict` . Note that
+    the default conformer does **not** modify the input mapping in place.
+
+    Exactly two Specs must be provided or a :py:class:`ValueError` will be raised
+    during construction.
+
+    :param tag_or_pred: an optional tag for the resulting spec or the key Spec
+        or value which can be converted into a Spec
+    :param preds: if a tag is given, preds should be exactly two Specs or values
+        which can be converted into Specs; the first shall be the Spec for the
+        keys and the second shall be the Spec for values
+    :param conform_keys: if :py:obj:`True`, the default conformer will also conform
+        keys according to the input key Spec; default is :py:obj:`False`
+    :param conformer: an optional conformer which will be composed with the
+        default conformer
+    :return: a Spec
+    """
+    tag, preds = tag_maybe(tag_or_pred, *preds)
+
+    if len(preds) != 2:
+        raise ValueError("Must specify a key and value Spec for k/v Specs")
+
+    tag = tag or "kv"
+    keyspec = make_spec(preds[0])
+    valspec = make_spec(preds[1])
+
+    def _kv_valid(d) -> Iterator[ErrorDetails]:
+        assert tag is not None
+
+        try:
+            for k, v in d.items():
+                yield from _enrich_errors(keyspec.validate(k), tag, d)
+                yield from _enrich_errors(valspec.validate(v), tag, k)
+        except (AttributeError, TypeError):
+            yield ErrorDetails(
+                message="Value is not a mapping type",
+                pred=_kv_valid,
+                value=d,
+                via=[tag],
+            )
+            return
+
+    if conform_keys:
+
+        def conform_mapping(d: Mapping) -> Mapping:
+            return {keyspec.conform(k): valspec.conform(v) for k, v in d.items()}
+
+    else:
+
+        def conform_mapping(d: Mapping) -> Mapping:
+            return {k: valspec.conform(v) for k, v in d.items()}
+
+    return ValidatorSpec(
+        tag,
+        _kv_valid,
+        conformer=compose_conformers(conform_mapping, *filter(None, (conformer,))),
+    )
+
+
 @attr.s(auto_attribs=True, frozen=True, slots=True)
 class ObjectSpec(Spec):
     tag: Tag
@@ -1210,7 +1288,8 @@ def make_spec(  # pylint: disable=inconsistent-return-statements  # noqa: MC0001
     Specs may be created for enumerated types using a Python ``set`` or ``frozenset``
     or using Python :py:class:`enum.Enum` types. Specs created for enumerated types
     based on :py:class:`enum.Enum` values validate the Enum name, value, or Enum
-    singleton and conform the the Enum singleton.
+    singleton and conform the input value to the corresponding :py:class:`enum.Enum`
+    value.
 
     Specs may be created for homogeneous collections using a Python ``list`` type.
     Callers can specify a few additional parameters for collection specs by providing
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index 0597c87..092dbb4 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -1298,7 +1298,9 @@ def str_spec(  # noqa: MC0001  # pylint: disable=too-many-arguments
         _pattern = regex if isinstance(regex, Pattern) else re.compile(regex)
 
         @pred_to_validator(
-            "String '{value}' does match regex '{regex}'", complement=True, regex=regex
+            "String '{value}' does not match regex '{regex}'",
+            complement=True,
+            regex=regex,
         )
         def str_matches_regex(s: str) -> bool:
             return bool(_pattern.fullmatch(s))


Test patch:
diff --git a/tests/test_base.py b/tests/test_base.py
index d1d9d34..4b6510f 100644
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -419,6 +419,117 @@ class TestDictSpecConformation:
         assert None is conformed.get("date_of_birth")
 
 
+class TestKVSpecValidation:
+    @pytest.mark.parametrize("args", [("tag",), ("tag", str), (str,),])
+    def test_kv_spec_definition(self, args):
+        with pytest.raises(ValueError):
+            s.kv(*args)
+
+    @pytest.fixture
+    def kv_spec(self) -> Spec:
+        return s.kv(s.str(regex=r"[A-Za-z\-]+"), s({"email": s.email()}),)
+
+    @pytest.mark.parametrize(
+        "d",
+        [
+            {"home-email": {"email": "chris@home.net"},},
+            {
+                "home-email": {"email": "chris@home.net"},
+                "work-email": {"email": "chris@work.net"},
+            },
+            {"other": {"email": "secret@mailinator.net", "subscribe": False}},
+        ],
+    )
+    def test_kv_spec(self, kv_spec: Spec, d):
+        assert kv_spec.is_valid(d)
+
+    @pytest.mark.parametrize(
+        "d",
+        [
+            None,
+            "a string",
+            0,
+            3.14,
+            True,
+            False,
+            {"a", "b", "c"},
+            ["a", "b", "c"],
+            ("a", "b", "c"),
+            {"home_email": {"email": "chris@home.net"}},
+            {"home-email": {"email-address": "chris@home.net"}},
+            {"home-email": {}},
+        ],
+    )
+    def test_kv_spec_failure(self, kv_spec: Spec, d):
+        assert not kv_spec.is_valid(d)
+        assert list(kv_spec.validate(d))
+
+
+class TestKVSpecConformation:
+    @pytest.fixture
+    def kv_spec(self) -> Spec:
+        return s.kv(
+            s.str(regex=r"[A-Za-z\-]+", conformer=lambda s: s.replace("-", "")),
+            s({"email": s.email()}, conformer=lambda m: m["email"]),
+        )
+
+    @pytest.fixture
+    def kv_spec_conform_keys(self) -> Spec:
+        return s.kv(
+            s.str(regex=r"[A-Za-z\-]+", conformer=lambda s: s.replace("-", "")),
+            s({"email": s.email()}, conformer=lambda m: m["email"]),
+            conform_keys=True,
+        )
+
+    @pytest.mark.parametrize(
+        "orig,conformed",
+        [
+            (
+                {"home-email": {"email": "chris@home.net"},},
+                {"home-email": "chris@home.net"},
+            ),
+            (
+                {
+                    "home-email": {"email": "chris@home.net"},
+                    "work-email": {"email": "chris@work.net"},
+                },
+                {"home-email": "chris@home.net", "work-email": "chris@work.net"},
+            ),
+            (
+                {"other": {"email": "secret@mailinator.net", "subscribe": False}},
+                {"other": "secret@mailinator.net"},
+            ),
+        ],
+    )
+    def test_kv_spec_conformation(self, kv_spec: Spec, orig, conformed):
+        assert conformed == kv_spec.conform(orig)
+
+    @pytest.mark.parametrize(
+        "orig,conformed",
+        [
+            (
+                {"home-email": {"email": "chris@home.net"},},
+                {"homeemail": "chris@home.net"},
+            ),
+            (
+                {
+                    "home-email": {"email": "chris@home.net"},
+                    "work-email": {"email": "chris@work.net"},
+                },
+                {"homeemail": "chris@home.net", "workemail": "chris@work.net"},
+            ),
+            (
+                {"other": {"email": "secret@mailinator.net", "subscribe": False}},
+                {"other": "secret@mailinator.net"},
+            ),
+        ],
+    )
+    def test_kv_spec_conform_keys_conformation(
+        self, kv_spec_conform_keys: Spec, orig, conformed
+    ):
+        assert conformed == kv_spec_conform_keys.conform(orig)
+
+
 class TestObjectSpecValidation:
     @pytest.mark.parametrize(
         "pred", [{"id": str, s.opt("id"): int}, {s.opt("id"): int, "id": str},]