Repository: HypothesisWorks/hypothesis

Issue:
Explicit support for special-use domains.
Hypothesis’ [`emails()`](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.emails) strategy generated an email address `0@a.arpa` which was rejected by the [email-validator](https://github.com/JoshData/python-email-validator) package:
```python
>>> import email_validator
>>> email_validator.validate_email('0@a.arpa')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../.venv/lib/python3.9/site-packages/email_validator/__init__.py", line 319, in validate_email
    domain_part_info = validate_email_domain_part(parts[1], test_environment=test_environment, globally_deliverable=globally_deliverable)
  File "/.../.venv/lib/python3.9/site-packages/email_validator/__init__.py", line 580, in validate_email_domain_part
    raise EmailSyntaxError("The domain name %s is a special-use or reserved name that cannot be used with email." % domain_i18n)
email_validator.EmailSyntaxError: The domain name a.arpa is a special-use or reserved name that cannot be used with email.
```

The email-validator package defines “special-use or reserved names” [here](https://github.com/JoshData/python-email-validator/blob/364d2d189e28b334437dcdbf554a484d07977a9b/email_validator/__init__.py#L19-L82).

Now, Hypothesis builds email addresses using the internal [`domains()`](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.provisional.domains) strategy:

https://github.com/HypothesisWorks/hypothesis/blob/48165d90f6afc1c0cd618ae0659f02a88b579f8b/hypothesis-python/src/hypothesis/strategies/_internal/core.py#L2029-L2031

which doesn’t have explicit support to filter out or use these special-use domain names.

I think adding an optional kwarg `allow_special_use=True` to the `emails()` strategy which then propagates to the [`DomainNameStrategy` class](https://github.com/HypothesisWorks/hypothesis/blob/48165d90f6afc1c0cd618ae0659f02a88b579f8b/hypothesis-python/src/hypothesis/provisional.py#L47-L128) would be useful. Happy to supply a PR?
Add domains() strategy as optional arg for the emails() strategy
Splitting out this separate change from PR #3572.


Discussion:
A PR would be great, thanks!  Let's add `allow_special_use: bool = True` to `provisional.domains()`, and `*, domains: st.SearchStrategy[str] = domains()` to `st.emails()`[^1] - that way people can also pass e.g. `domains=st.just("employer.com")`, so it's a more general upgrade 🙂 

[^1]: My reading of RFC-5322 is that special domains are fine according to the standard so IMO Hypothesis should include them by default, and [the `email-validator` docs](https://github.com/JoshData/python-email-validator#test-addresses) describes how to enable them in testing, noting that they're off by default as an anti-footgun.


Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/provisional.py b/hypothesis-python/src/hypothesis/provisional.py
--- a/hypothesis-python/src/hypothesis/provisional.py
+++ b/hypothesis-python/src/hypothesis/provisional.py
@@ -34,14 +34,16 @@
 # The file contains additional information about the date that it was last updated.
 try:  # pragma: no cover
     traversable = resources.files("hypothesis.vendor") / "tlds-alpha-by-domain.txt"
-    _tlds = traversable.read_text().splitlines()
+    _comment, *_tlds = traversable.read_text().splitlines()
 except (AttributeError, ValueError):  # pragma: no cover  # .files() was added in 3.9
-    _tlds = resources.read_text(
+    _comment, *_tlds = resources.read_text(
         "hypothesis.vendor", "tlds-alpha-by-domain.txt"
     ).splitlines()
+assert _comment.startswith("#")
 
-assert _tlds[0].startswith("#")
-TOP_LEVEL_DOMAINS = ["COM"] + sorted(_tlds[1:], key=len)
+# Remove special-use domain names from the list. For more discussion
+# see https://github.com/HypothesisWorks/hypothesis/pull/3572
+TOP_LEVEL_DOMAINS = ["COM"] + sorted((d for d in _tlds if d != "ARPA"), key=len)
 
 
 class DomainNameStrategy(st.SearchStrategy):
diff --git a/hypothesis-python/src/hypothesis/strategies/_internal/core.py b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
--- a/hypothesis-python/src/hypothesis/strategies/_internal/core.py
+++ b/hypothesis-python/src/hypothesis/strategies/_internal/core.py
@@ -2012,21 +2012,30 @@ def deferred(definition: Callable[[], SearchStrategy[Ex]]) -> SearchStrategy[Ex]
     return DeferredStrategy(definition)
 
 
+def domains():
+    import hypothesis.provisional
+
+    return hypothesis.provisional.domains()
+
+
 @defines_strategy(force_reusable_values=True)
-def emails() -> SearchStrategy[str]:
+def emails(
+    *, domains: SearchStrategy[str] = LazyStrategy(domains, (), {})
+) -> SearchStrategy[str]:
     """A strategy for generating email addresses as unicode strings. The
     address format is specified in :rfc:`5322#section-3.4.1`. Values shrink
     towards shorter local-parts and host domains.
 
+    If ``domains`` is given then it must be a strategy that generates domain
+    names for the emails, defaulting to :func:`~hypothesis.provisional.domains`.
+
     This strategy is useful for generating "user data" for tests, as
     mishandling of email addresses is a common source of bugs.
     """
-    from hypothesis.provisional import domains
-
     local_chars = string.ascii_letters + string.digits + "!#$%&'*+-/=^_`{|}~"
     local_part = text(local_chars, min_size=1, max_size=64)
     # TODO: include dot-atoms, quoted strings, escaped chars, etc in local part
-    return builds("{}@{}".format, local_part, domains()).filter(
+    return builds("{}@{}".format, local_part, domains).filter(
         lambda addr: len(addr) <= 254
     )
 


Test patch:
diff --git a/hypothesis-python/tests/nocover/test_emails.py b/hypothesis-python/tests/nocover/test_emails.py
--- a/hypothesis-python/tests/nocover/test_emails.py
+++ b/hypothesis-python/tests/nocover/test_emails.py
@@ -9,13 +9,19 @@
 # obtain one at https://mozilla.org/MPL/2.0/.
 
 from hypothesis import given
-from hypothesis.strategies import emails
+from hypothesis.strategies import emails, just
 
 
 @given(emails())
-def test_is_valid_email(address):
+def test_is_valid_email(address: str):
     local, at_, domain = address.rpartition("@")
     assert len(address) <= 254
     assert at_ == "@"
     assert local
     assert domain
+    assert not domain.lower().endswith(".arpa")
+
+
+@given(emails(domains=just("mydomain.com")))
+def test_can_restrict_email_domains(address: str):
+    assert address.endswith("@mydomain.com")