Repository: coverahealth/dataspec

Issue:
Support email address string format
Applications commonly need to validate that an email address is at least in a valid format. Python features the [`email.headerregistry.Address`](https://docs.python.org/3/library/email.headerregistry.html#email.headerregistry.Address) object which parses email address strings and can provide some reasonable hints about formatting errors. It may not be foolproof, but for common cases it is probably sufficient. 

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9629beb..cf8c599 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Add conforming string formats (#3)
 - Add ISO time string format (#4)
 - Allow type-checking specs to be created by passing a type directly to `s` (#12)
+- Add email address string format (#6)
 
 ## [0.1.0] - 2019-10-20
 ### Added
diff --git a/src/dataspec/factories.py b/src/dataspec/factories.py
index 7f5e005..35e5e0a 100644
--- a/src/dataspec/factories.py
+++ b/src/dataspec/factories.py
@@ -3,6 +3,7 @@ import sys
 import threading
 import uuid
 from datetime import date, datetime, time
+from email.headerregistry import Address
 from typing import (
     Any,
     Callable,
@@ -374,6 +375,18 @@ def register_str_format(
     return create_str_format
 
 
+@register_str_format("email")
+def _str_is_email_address(s: str) -> Iterator[ErrorDetails]:
+    try:
+        Address(addr_spec=s)
+    except (TypeError, ValueError) as e:
+        yield ErrorDetails(
+            f"String does not contain a valid email address: {e}",
+            pred=_str_is_email_address,
+            value=s,
+        )
+
+
 @register_str_format("uuid", conformer=uuid.UUID)
 def _str_is_uuid(s: str) -> Iterator[ErrorDetails]:
     try:


Test patch:
diff --git a/tests/test_factories.py b/tests/test_factories.py
index 84b2b23..3f368a8 100644
--- a/tests/test_factories.py
+++ b/tests/test_factories.py
@@ -652,6 +652,60 @@ class TestStringSpecValidation:
         def test_is_not_zipcode(self, zipcode_spec: Spec, v):
             assert not zipcode_spec.is_valid(v)
 
+    @pytest.mark.parametrize(
+        "opts",
+        [
+            {"regex": r"\d{5}(-\d{4})?", "format_": "uuid"},
+            {"regex": r"\d{5}(-\d{4})?", "conform_format": "uuid"},
+            {"conform_format": "uuid", "format_": "uuid"},
+        ],
+    )
+    def test_regex_and_format_agreement(self, opts):
+        with pytest.raises(ValueError):
+            s.str(**opts)
+
+
+class TestStringFormatValidation:
+    class TestEmailFormat:
+        @pytest.fixture
+        def email_spec(self) -> Spec:
+            return s.str(format_="email")
+
+        @pytest.mark.parametrize(
+            "v",
+            [
+                "chris@localhost",
+                "chris@gmail.com",
+                "chris+extra@gmail.com",
+                "chris.person@gmail.com",
+            ],
+        )
+        def test_is_email_str(self, email_spec: Spec, v):
+            assert email_spec.is_valid(v)
+
+        @pytest.mark.parametrize(
+            "v",
+            [
+                None,
+                25,
+                3.14,
+                [],
+                set(),
+                "abcdef",
+                "abcdefg",
+                "100017",
+                "10017-383",
+                "1945-9-2",
+                "430-10-02",
+                "chris",
+                "chris@",
+                "@gmail",
+                "@gmail.com",
+            ],
+        )
+        def test_is_not_email_str(self, email_spec: Spec, v):
+            assert not email_spec.is_valid(v)
+
     class TestISODateFormat:
         @pytest.fixture
         def conform(self):
@@ -849,18 +903,6 @@ class TestStringSpecValidation:
             assert not uuid_spec.is_valid(v)
             assert not conforming_uuid_spec.is_valid(v)
 
-    @pytest.mark.parametrize(
-        "opts",
-        [
-            {"regex": r"\d{5}(-\d{4})?", "format_": "uuid"},
-            {"regex": r"\d{5}(-\d{4})?", "conform_format": "uuid"},
-            {"conform_format": "uuid", "format_": "uuid"},
-        ],
-    )
-    def test_regex_and_format_agreement(self, opts):
-        with pytest.raises(ValueError):
-            s.str(**opts)
-
 
 class TestUUIDSpecValidation:
     @pytest.mark.parametrize(