Repository: coverahealth/dataspec

Issue:
Mapping Specs could default child Spec tags from the dictionary key
Mapping Specs often require repeating the key in the child Spec tag:

```python
s(
    "user-profile",
    {
        "id": s.str("id", format_="uuid"),
        "first_name": s.str("first_name"),
        "last_name": s.str("last_name"),
        "date_of_birth": s.str("date_of_birth", format_="iso-date"),
        "gender": s("gender", {"M", "F"}),
        s.opt("state"): s.str("state", minlength=2, maxlength=2),
    }
)
```

In cases where all child Specs are not defined inline, this makes sense (as you may not necessarily want to change the child Spec's tag to match the dictionary key), but for cases like the above it's quite inconvenient.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e0668e0..b31b0fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +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.dict_tag` as a convenience factory for building mapping specs for which
+  the value spec tags are derived automatically from the corresponding dict keys (#52)
 
 ## [v0.2.5] - 2020-04-10
 ### Added
diff --git a/src/dataspec/__version__.py b/src/dataspec/__version__.py
index caef129..a165b7d 100644
--- a/src/dataspec/__version__.py
+++ b/src/dataspec/__version__.py
@@ -1,3 +1,3 @@
-VERSION = (0, 2, 5)
+VERSION = (0, 3, "dev0")
 
 __version__ = ".".join(map(str, VERSION))
diff --git a/src/dataspec/api.py b/src/dataspec/api.py
index bc594ca..050aecc 100644
--- a/src/dataspec/api.py
+++ b/src/dataspec/api.py
@@ -18,6 +18,7 @@ from dataspec.factories import (
     date_spec,
     datetime_spec,
     default_spec,
+    dict_tag_spec,
     email_spec,
     every_spec,
     nilable_spec,
@@ -107,6 +108,7 @@ class SpecAPI:
     bytes = staticmethod(bytes_spec)
     date = staticmethod(date_spec)
     default = staticmethod(default_spec)
+    dict_tag = staticmethod(dict_tag_spec)
     email = staticmethod(email_spec)
     every = staticmethod(every_spec)
     inst = staticmethod(datetime_spec)
diff --git a/src/dataspec/base.py b/src/dataspec/base.py
index 877b918..cbbcfed 100644
--- a/src/dataspec/base.py
+++ b/src/dataspec/base.py
@@ -740,10 +740,11 @@ def make_spec(  # pylint: disable=inconsistent-return-statements
     elif isinstance(pred, dict):
         return DictSpec.from_val(tag, pred, conformer=conformer)
     elif isinstance(pred, Spec):
+        if tag is not None:
+            pred = pred.with_tag(tag)
         if conformer is not None:
-            return pred.with_conformer(conformer)
-        else:
-            return pred
+            pred = pred.with_conformer(conformer)
+        return pred
     elif isinstance(pred, type):
         return type_spec(tag, pred, conformer=conformer)
     elif callable(pred):
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index 5c6a6b9..2bded83 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -179,7 +179,7 @@ def blankable_spec(
     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, {""})`.
+    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
@@ -660,6 +660,49 @@ else:
         )
 
 
+def dict_tag_spec(
+    *args: Union[Tag, SpecPredicate], conformer: Optional[Conformer] = None
+) -> Spec:
+    """
+    Return a mapping Spec for which the Tags for each of the ``dict`` values is set
+    to the corresponding key.
+
+    This is a convenience factory for the common pattern of creating a mapping Spec
+    with all of the key Specs' Tags bearing the same name as the corresponding key.
+    The value Specs are created as by :py:data:`dataspec.s`, so existing Specs will
+    not be modified; instead new Specs will be created by
+    :py:meth:`dataspec.Spec.with_tag`.
+
+    For more precise tagging of mapping Spec values, use the default ``s`` constructor
+    with a ``dict`` value.
+
+    :param tag: an optional tag for the resulting spec
+    :param pred: a mapping spec predicate
+    :param conformer: an optional conformer for the value
+    :return: a mapping Spec
+    """
+    tag, preds = tag_maybe(*args)  # pylint: disable=no-value-for-parameter
+    if len(preds) > 1:
+        raise ValueError(
+            f"Dict specs may only specify one spec predicate, not {len(preds)}"
+        )
+
+    pred = preds[0]
+    if not isinstance(pred, dict):
+        raise TypeError(f"Dict spec predicate must be a dict, not {type(pred)}")
+
+    def _unwrap_opt_key(k: Union[OptionalKey, str]) -> str:
+        if isinstance(k, OptionalKey):
+            return k.key
+        return k
+
+    return make_spec(
+        *((tag,) if tag is not None else ()),
+        {k: make_spec(_unwrap_opt_key(k), v) for k, v in pred.items()},
+        conformer=conformer,
+    )
+
+
 _IGNORE_OBJ_PARAM = object()
 _EMAIL_RESULT_FIELDS = frozenset({"username", "domain"})
 


Test patch:
diff --git a/tests/test_factories.py b/tests/test_factories.py
index efd8288..6621bd8 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -6,7 +6,7 @@ from enum import Enum
 
 import pytest
 
-from dataspec import INVALID, Spec, s
+from dataspec import INVALID, Spec, ValidationError, s
 
 try:
     from dateutil.parser import parse as parse_date
@@ -407,6 +407,56 @@ class TestDefaultSpecConformation:
         assert conformed == default_spec.conform(v)
 
 
+class TestDictTagSpecValidation:
+    def test_invalid_dict_tag_spec(self):
+        with pytest.raises(ValueError):
+            s.dict_tag(
+                "person",
+                {"id": s.str(format_="uuid"), "first_name": s.str()},
+                {"last_name": s.str()},
+            )
+
+        with pytest.raises(ValueError):
+            s.dict_tag(
+                {"id": s.str(format_="uuid"), "first_name": s.str()},
+                {"last_name": s.str()},
+            )
+
+        with pytest.raises(TypeError):
+            s.dict_tag("person", [{"id": s.str(format_="uuid"), "first_name": s.str()}])
+
+        with pytest.raises(TypeError):
+            s.dict_tag([{"id": s.str(format_="uuid"), "first_name": s.str()}])
+
+    @pytest.mark.parametrize(
+        "v,via",
+        [
+            (
+                {"name": "Mark Jones", "license_states": "GA"},
+                ["licensures", "license_states", "coll"],
+            ),
+            (
+                {"name": "Darryl Smith", "license_states": ["NY", "California"]},
+                ["licensures", "license_states", "state", "str_is_exactly_len"],
+            ),
+            ({"name": "GaryBusey"}, ["licensures", "name", "str_matches_regex"]),
+        ],
+    )
+    def test_error_details(self, v, via):
+        try:
+            s.dict_tag(
+                "licensures",
+                {
+                    "name": s.str(regex=r"(\w+) (\w+)"),
+                    s.opt("license_states"): [s.str("state", length=2)],
+                },
+            ).validate_ex(v)
+            assert False
+        except ValidationError as e:
+            err = e.errors[0]
+            assert via == err.via
+
+
 class TestEmailSpecValidation:
     @pytest.mark.parametrize(
         "spec_kwargs",