Repository: encode/typesystem

Issue:
`Email` field
adding new `Email` field:

- Validates inputs to be valid emails
- Displayed as `<input type="email">` in forms

Discussion:

Patch:
diff --git a/typesystem/__init__.py b/typesystem/__init__.py
index 165a090..1f72729 100644
--- a/typesystem/__init__.py
+++ b/typesystem/__init__.py
@@ -8,6 +8,7 @@ from typesystem.fields import (
     Date,
     DateTime,
     Decimal,
+    Email,
     Field,
     Float,
     Integer,
@@ -35,6 +36,7 @@ __all__ = [
     "Date",
     "DateTime",
     "Decimal",
+    "Email",
     "Integer",
     "Jinja2Forms",
     "Field",
diff --git a/typesystem/fields.py b/typesystem/fields.py
index 4837d6b..b78255a 100644
--- a/typesystem/fields.py
+++ b/typesystem/fields.py
@@ -14,6 +14,7 @@ FORMATS = {
     "time": formats.TimeFormat(),
     "datetime": formats.DateTimeFormat(),
     "uuid": formats.UUIDFormat(),
+    "email": formats.EmailFormat(),
 }
 
 
@@ -769,6 +770,11 @@ class UUID(String):
         super().__init__(format="uuid", **kwargs)
 
 
+class Email(String):
+    def __init__(self, **kwargs: typing.Any) -> None:
+        super().__init__(format="email", **kwargs)
+
+
 class Password(String):
     def __init__(self, **kwargs: typing.Any) -> None:
         super().__init__(format="password", **kwargs)
diff --git a/typesystem/formats.py b/typesystem/formats.py
index 9f56ca3..ed8c5cc 100644
--- a/typesystem/formats.py
+++ b/typesystem/formats.py
@@ -23,6 +23,13 @@ UUID_REGEX = re.compile(
     r"[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
 )
 
+EMAIL_REGEX = re.compile(
+    r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*"
+    r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*'
+    r")@(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,63}(?<!-)\.?$",
+    re.IGNORECASE,
+)
+
 
 class BaseFormat:
     errors: typing.Dict[str, str] = {}
@@ -169,3 +176,20 @@ class UUIDFormat(BaseFormat):
 
     def serialize(self, obj: typing.Any) -> str:
         return str(obj)
+
+
+class EmailFormat(BaseFormat):
+    errors = {"format": "Must be a valid email format."}
+
+    def is_native_type(self, value: typing.Any) -> bool:
+        return False
+
+    def validate(self, value: typing.Any) -> uuid.UUID:
+        match = EMAIL_REGEX.match(value)
+        if not match:
+            raise self.validation_error("format")
+
+        return value
+
+    def serialize(self, obj: typing.Any) -> str:
+        return str(obj)


Test patch:
diff --git a/tests/test_fields.py b/tests/test_fields.py
index d391eda..91a1d47 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -13,6 +13,7 @@ from typesystem.fields import (
     Date,
     DateTime,
     Decimal,
+    Email,
     Float,
     Integer,
     Number,
@@ -835,6 +836,16 @@ def test_validation_error_is_hashable():
     hash(error)
 
 
+def test_email():
+    validator = Email()
+    value, error = validator.validate_or_error("info@example.com")
+    assert value == "info@example.com"
+
+    validator = Email()
+    value, error = validator.validate_or_error("example.com")
+    assert error == ValidationError(text="Must be a valid email format.", code="format")
+
+
 def test_password():
     validator = Password()
     value, _ = validator.validate_or_error("secret")
diff --git a/tests/test_forms.py b/tests/test_forms.py
index 33fa264..e93f010 100644
--- a/tests/test_forms.py
+++ b/tests/test_forms.py
@@ -13,6 +13,7 @@ contact = typesystem.Schema(
             choices=[("abc", "Abc"), ("def", "Def"), ("ghi", "Ghi")]
         ),
         "extra": typesystem.Boolean(default=True, read_only=True),
+        "email": typesystem.Email(),
         "password": typesystem.Password(),
     }
 )
@@ -30,6 +31,7 @@ def test_form_rendering():
     assert html.count('<input type="text" ') == 1
     assert html.count("<textarea ") == 1
     assert html.count("<select ") == 1
+    assert html.count('<input type="email" ') == 1
     assert html.count('<input type="password" ') == 1
 
 
diff --git a/tests/test_schemas.py b/tests/test_schemas.py
index da1d7d9..e3c31ea 100644
--- a/tests/test_schemas.py
+++ b/tests/test_schemas.py
@@ -500,3 +500,11 @@ def test_definitions_to_json_schema():
             },
         }
     }
+
+
+def test_schema_email_serialization():
+    user = typesystem.Schema(fields={"email": typesystem.Email()})
+
+    item = {"email": "team@encode.io"}
+    data = user.serialize(item)
+    assert data == {"email": "team@encode.io"}