Skip to content

Commit a9a8a62

Browse files
authored
mypy: stricter settings (#140)
2 parents 4691a62 + 380e44e commit a9a8a62

12 files changed

+193
-131
lines changed

.github/workflows/test_and_build.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: Tests
22

3-
on: [push]
3+
on: [push, pull_request]
44

55
jobs:
66
build:

email_validator/__init__.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import TYPE_CHECKING
2+
13
# Export the main method, helper methods, and the public data types.
24
from .exceptions_types import ValidatedEmail, EmailNotValidError, \
35
EmailSyntaxError, EmailUndeliverableError
@@ -9,12 +11,14 @@
911
"EmailSyntaxError", "EmailUndeliverableError",
1012
"caching_resolver", "__version__"]
1113

12-
13-
def caching_resolver(*args, **kwargs):
14-
# Lazy load `deliverability` as it is slow to import (due to dns.resolver)
14+
if TYPE_CHECKING:
1515
from .deliverability import caching_resolver
16+
else:
17+
def caching_resolver(*args, **kwargs):
18+
# Lazy load `deliverability` as it is slow to import (due to dns.resolver)
19+
from .deliverability import caching_resolver
1620

17-
return caching_resolver(*args, **kwargs)
21+
return caching_resolver(*args, **kwargs)
1822

1923

2024
# These global attributes are a part of the library's API and can be

email_validator/__main__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,18 @@
1717
import json
1818
import os
1919
import sys
20+
from typing import Any, Dict, Optional
2021

21-
from .validate_email import validate_email
22+
from .validate_email import validate_email, _Resolver
2223
from .deliverability import caching_resolver
2324
from .exceptions_types import EmailNotValidError
2425

2526

26-
def main(dns_resolver=None):
27+
def main(dns_resolver: Optional[_Resolver] = None) -> None:
2728
# The dns_resolver argument is for tests.
2829

2930
# Set options from environment variables.
30-
options = {}
31+
options: Dict[str, Any] = {}
3132
for varname in ('ALLOW_SMTPUTF8', 'ALLOW_QUOTED_LOCAL', 'ALLOW_DOMAIN_LITERAL',
3233
'GLOBALLY_DELIVERABLE', 'CHECK_DELIVERABILITY', 'TEST_ENVIRONMENT'):
3334
if varname in os.environ:

email_validator/deliverability.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional, Any, Dict
1+
from typing import Any, List, Optional, Tuple, TypedDict
22

33
import ipaddress
44

@@ -8,17 +8,24 @@
88
import dns.exception
99

1010

11-
def caching_resolver(*, timeout: Optional[int] = None, cache=None, dns_resolver=None):
11+
def caching_resolver(*, timeout: Optional[int] = None, cache: Any = None, dns_resolver: Optional[dns.resolver.Resolver] = None) -> dns.resolver.Resolver:
1212
if timeout is None:
1313
from . import DEFAULT_TIMEOUT
1414
timeout = DEFAULT_TIMEOUT
1515
resolver = dns_resolver or dns.resolver.Resolver()
16-
resolver.cache = cache or dns.resolver.LRUCache() # type: ignore
17-
resolver.lifetime = timeout # type: ignore # timeout, in seconds
16+
resolver.cache = cache or dns.resolver.LRUCache()
17+
resolver.lifetime = timeout # timeout, in seconds
1818
return resolver
1919

2020

21-
def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Optional[int] = None, dns_resolver=None):
21+
DeliverabilityInfo = TypedDict("DeliverabilityInfo", {
22+
"mx": List[Tuple[int, str]],
23+
"mx_fallback_type": Optional[str],
24+
"unknown-deliverability": str,
25+
}, total=False)
26+
27+
28+
def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Optional[int] = None, dns_resolver: Optional[dns.resolver.Resolver] = None) -> DeliverabilityInfo:
2229
# Check that the domain resolves to an MX record. If there is no MX record,
2330
# try an A or AAAA record which is a deprecated fallback for deliverability.
2431
# Raises an EmailUndeliverableError on failure. On success, returns a dict
@@ -36,7 +43,7 @@ def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Option
3643
elif timeout is not None:
3744
raise ValueError("It's not valid to pass both timeout and dns_resolver.")
3845

39-
deliverability_info: Dict[str, Any] = {}
46+
deliverability_info: DeliverabilityInfo = {}
4047

4148
try:
4249
try:
@@ -69,9 +76,9 @@ def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Option
6976
# https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
7077
# https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
7178
# (Issue #134.)
72-
def is_global_addr(ipaddr):
79+
def is_global_addr(address: Any) -> bool:
7380
try:
74-
ipaddr = ipaddress.ip_address(ipaddr)
81+
ipaddr = ipaddress.ip_address(address)
7582
except ValueError:
7683
return False
7784
return ipaddr.is_global
@@ -115,7 +122,6 @@ def is_global_addr(ipaddr):
115122
for rec in response:
116123
value = b"".join(rec.strings)
117124
if value.startswith(b"v=spf1 "):
118-
deliverability_info["spf"] = value.decode("ascii", errors='replace')
119125
if value == b"v=spf1 -all":
120126
raise EmailUndeliverableError(f"The domain name {domain_i18n} does not send email.")
121127
except dns.resolver.NoAnswer:

email_validator/exceptions_types.py

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import warnings
2-
from typing import Optional
2+
from typing import Any, Dict, List, Optional, Tuple, Union
33

44

55
class EmailNotValidError(ValueError):
@@ -24,7 +24,7 @@ class ValidatedEmail:
2424
"""The email address that was passed to validate_email. (If passed as bytes, this will be a string.)"""
2525
original: str
2626

27-
"""The normalized email address, which should always be used in preferance to the original address.
27+
"""The normalized email address, which should always be used in preference to the original address.
2828
The normalized address converts an IDNA ASCII domain name to Unicode, if possible, and performs
2929
Unicode normalization on the local part and on the domain (if originally Unicode). It is the
3030
concatenation of the local_part and domain attributes, separated by an @-sign."""
@@ -56,39 +56,34 @@ class ValidatedEmail:
5656

5757
"""If a deliverability check is performed and if it succeeds, a list of (priority, domain)
5858
tuples of MX records specified in the DNS for the domain."""
59-
mx: list
59+
mx: List[Tuple[int, str]]
6060

6161
"""If no MX records are actually specified in DNS and instead are inferred, through an obsolete
6262
mechanism, from A or AAAA records, the value is the type of DNS record used instead (`A` or `AAAA`)."""
63-
mx_fallback_type: str
63+
mx_fallback_type: Optional[str]
6464

6565
"""The display name in the original input text, unquoted and unescaped, or None."""
66-
display_name: str
66+
display_name: Optional[str]
6767

68-
"""Tests use this constructor."""
69-
def __init__(self, **kwargs):
70-
for k, v in kwargs.items():
71-
setattr(self, k, v)
72-
73-
def __repr__(self):
68+
def __repr__(self) -> str:
7469
return f"<ValidatedEmail {self.normalized}>"
7570

7671
"""For backwards compatibility, support old field names."""
77-
def __getattr__(self, key):
72+
def __getattr__(self, key: str) -> str:
7873
if key == "original_email":
7974
return self.original
8075
if key == "email":
8176
return self.normalized
8277
raise AttributeError(key)
8378

8479
@property
85-
def email(self):
80+
def email(self) -> str:
8681
warnings.warn("ValidatedEmail.email is deprecated and will be removed, use ValidatedEmail.normalized instead", DeprecationWarning)
8782
return self.normalized
8883

8984
"""For backwards compatibility, some fields are also exposed through a dict-like interface. Note
9085
that some of the names changed when they became attributes."""
91-
def __getitem__(self, key):
86+
def __getitem__(self, key: str) -> Union[Optional[str], bool, List[Tuple[int, str]]]:
9287
warnings.warn("dict-like access to the return value of validate_email is deprecated and may not be supported in the future.", DeprecationWarning, stacklevel=2)
9388
if key == "email":
9489
return self.normalized
@@ -109,7 +104,7 @@ def __getitem__(self, key):
109104
raise KeyError()
110105

111106
"""Tests use this."""
112-
def __eq__(self, other):
107+
def __eq__(self, other: object) -> bool:
113108
if not isinstance(other, ValidatedEmail):
114109
return False
115110
return (
@@ -127,7 +122,7 @@ def __eq__(self, other):
127122
)
128123

129124
"""This helps producing the README."""
130-
def as_constructor(self):
125+
def as_constructor(self) -> str:
131126
return "ValidatedEmail(" \
132127
+ ",".join(f"\n {key}={repr(getattr(self, key))}"
133128
for key in ('normalized', 'local_part', 'domain',
@@ -139,7 +134,7 @@ def as_constructor(self):
139134
+ ")"
140135

141136
"""Convenience method for accessing ValidatedEmail as a dict"""
142-
def as_dict(self):
137+
def as_dict(self) -> Dict[str, Any]:
143138
d = self.__dict__
144139
if d.get('domain_address'):
145140
d['domain_address'] = repr(d['domain_address'])

email_validator/syntax.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .exceptions_types import EmailSyntaxError
1+
from .exceptions_types import EmailSyntaxError, ValidatedEmail
22
from .rfc_constants import EMAIL_MAX_LENGTH, LOCAL_PART_MAX_LENGTH, DOMAIN_MAX_LENGTH, \
33
DOT_ATOM_TEXT, DOT_ATOM_TEXT_INTL, ATEXT_RE, ATEXT_INTL_DOT_RE, ATEXT_HOSTNAME_INTL, QTEXT_INTL, \
44
DNS_LABEL_LENGTH_LIMIT, DOT_ATOM_TEXT_HOSTNAME, DOMAIN_NAME_REGEX, DOMAIN_LITERAL_CHARS
@@ -7,10 +7,10 @@
77
import unicodedata
88
import idna # implements IDNA 2008; Python's codec is only IDNA 2003
99
import ipaddress
10-
from typing import Optional
10+
from typing import Optional, Tuple, TypedDict, Union
1111

1212

13-
def split_email(email):
13+
def split_email(email: str) -> Tuple[Optional[str], str, str, bool]:
1414
# Return the display name, unescaped local part, and domain part
1515
# of the address, and whether the local part was quoted. If no
1616
# display name was present and angle brackets do not surround
@@ -46,7 +46,7 @@ def split_email(email):
4646
# We assume the input string is already stripped of leading and
4747
# trailing CFWS.
4848

49-
def split_string_at_unquoted_special(text, specials):
49+
def split_string_at_unquoted_special(text: str, specials: Tuple[str, ...]) -> Tuple[str, str]:
5050
# Split the string at the first character in specials (an @-sign
5151
# or left angle bracket) that does not occur within quotes.
5252
inside_quote = False
@@ -77,7 +77,7 @@ def split_string_at_unquoted_special(text, specials):
7777

7878
return left_part, right_part
7979

80-
def unquote_quoted_string(text):
80+
def unquote_quoted_string(text: str) -> Tuple[str, bool]:
8181
# Remove surrounding quotes and unescape escaped backslashes
8282
# and quotes. Escapes are parsed liberally. I think only
8383
# backslashes and quotes can be escaped but we'll allow anything
@@ -155,15 +155,15 @@ def unquote_quoted_string(text):
155155
return display_name, local_part, domain_part, is_quoted_local_part
156156

157157

158-
def get_length_reason(addr, utf8=False, limit=EMAIL_MAX_LENGTH):
158+
def get_length_reason(addr: str, utf8: bool = False, limit: int = EMAIL_MAX_LENGTH) -> str:
159159
"""Helper function to return an error message related to invalid length."""
160160
diff = len(addr) - limit
161161
prefix = "at least " if utf8 else ""
162162
suffix = "s" if diff > 1 else ""
163163
return f"({prefix}{diff} character{suffix} too many)"
164164

165165

166-
def safe_character_display(c):
166+
def safe_character_display(c: str) -> str:
167167
# Return safely displayable characters in quotes.
168168
if c == '\\':
169169
return f"\"{c}\"" # can't use repr because it escapes it
@@ -180,8 +180,14 @@ def safe_character_display(c):
180180
return unicodedata.name(c, h)
181181

182182

183+
class LocalPartValidationResult(TypedDict):
184+
local_part: str
185+
ascii_local_part: Optional[str]
186+
smtputf8: bool
187+
188+
183189
def validate_email_local_part(local: str, allow_smtputf8: bool = True, allow_empty_local: bool = False,
184-
quoted_local_part: bool = False):
190+
quoted_local_part: bool = False) -> LocalPartValidationResult:
185191
"""Validates the syntax of the local part of an email address."""
186192

187193
if len(local) == 0:
@@ -345,7 +351,7 @@ def validate_email_local_part(local: str, allow_smtputf8: bool = True, allow_emp
345351
raise EmailSyntaxError("The email address contains invalid characters before the @-sign.")
346352

347353

348-
def check_unsafe_chars(s, allow_space=False):
354+
def check_unsafe_chars(s: str, allow_space: bool = False) -> None:
349355
# Check for unsafe characters or characters that would make the string
350356
# invalid or non-sensible Unicode.
351357
bad_chars = set()
@@ -397,7 +403,7 @@ def check_unsafe_chars(s, allow_space=False):
397403
+ ", ".join(safe_character_display(c) for c in sorted(bad_chars)) + ".")
398404

399405

400-
def check_dot_atom(label, start_descr, end_descr, is_hostname):
406+
def check_dot_atom(label: str, start_descr: str, end_descr: str, is_hostname: bool) -> None:
401407
# RFC 5322 3.2.3
402408
if label.endswith("."):
403409
raise EmailSyntaxError(end_descr.format("period"))
@@ -416,7 +422,12 @@ def check_dot_atom(label, start_descr, end_descr, is_hostname):
416422
raise EmailSyntaxError("An email address cannot have a period and a hyphen next to each other.")
417423

418424

419-
def validate_email_domain_name(domain, test_environment=False, globally_deliverable=True):
425+
class DomainNameValidationResult(TypedDict):
426+
ascii_domain: str
427+
domain: str
428+
429+
430+
def validate_email_domain_name(domain: str, test_environment: bool = False, globally_deliverable: bool = True) -> DomainNameValidationResult:
420431
"""Validates the syntax of the domain part of an email address."""
421432

422433
# Check for invalid characters before normalization.
@@ -580,7 +591,7 @@ def validate_email_domain_name(domain, test_environment=False, globally_delivera
580591
}
581592

582593

583-
def validate_email_length(addrinfo):
594+
def validate_email_length(addrinfo: ValidatedEmail) -> None:
584595
# If the email address has an ASCII representation, then we assume it may be
585596
# transmitted in ASCII (we can't assume SMTPUTF8 will be used on all hops to
586597
# the destination) and the length limit applies to ASCII characters (which is
@@ -621,11 +632,18 @@ def validate_email_length(addrinfo):
621632
raise EmailSyntaxError(f"The email address is too long {reason}.")
622633

623634

624-
def validate_email_domain_literal(domain_literal):
635+
class DomainLiteralValidationResult(TypedDict):
636+
domain_address: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
637+
domain: str
638+
639+
640+
def validate_email_domain_literal(domain_literal: str) -> DomainLiteralValidationResult:
625641
# This is obscure domain-literal syntax. Parse it and return
626642
# a compressed/normalized address.
627643
# RFC 5321 4.1.3 and RFC 5322 3.4.1.
628644

645+
addr: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
646+
629647
# Try to parse the domain literal as an IPv4 address.
630648
# There is no tag for IPv4 addresses, so we can never
631649
# be sure if the user intends an IPv4 address.

0 commit comments

Comments
 (0)