Skip to content

Commit 5734e5e

Browse files
committed
mypy: disallow_untyped_defs
1 parent 68019d7 commit 5734e5e

11 files changed

+103
-84
lines changed

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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@
1717
import json
1818
import os
1919
import sys
20-
from typing import Any, Dict
20+
from typing import Any, Dict, Optional
2121

22-
from .validate_email import validate_email
22+
from .validate_email import validate_email, _Resolver
2323
from .deliverability import caching_resolver
2424
from .exceptions_types import EmailNotValidError
2525

2626

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

3030
# Set options from environment variables.

email_validator/deliverability.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Option
6969
# https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
7070
# https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
7171
# (Issue #134.)
72-
def is_global_addr(ipaddr):
72+
def is_global_addr(address: Any) -> bool:
7373
try:
74-
ipaddr = ipaddress.ip_address(ipaddr)
74+
ipaddr = ipaddress.ip_address(address)
7575
except ValueError:
7676
return False
7777
return ipaddr.is_global

email_validator/exceptions_types.py

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

44

55
class EmailNotValidError(ValueError):
@@ -63,32 +63,32 @@ class ValidatedEmail:
6363
mx_fallback_type: 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

6868
"""Tests use this constructor."""
69-
def __init__(self, **kwargs):
69+
def __init__(self, **kwargs: Any) -> None:
7070
for k, v in kwargs.items():
7171
setattr(self, k, v)
7272

73-
def __repr__(self):
73+
def __repr__(self) -> str:
7474
return f"<ValidatedEmail {self.normalized}>"
7575

7676
"""For backwards compatibility, support old field names."""
77-
def __getattr__(self, key):
77+
def __getattr__(self, key: str) -> str:
7878
if key == "original_email":
7979
return self.original
8080
if key == "email":
8181
return self.normalized
8282
raise AttributeError(key)
8383

8484
@property
85-
def email(self):
85+
def email(self) -> str:
8686
warnings.warn("ValidatedEmail.email is deprecated and will be removed, use ValidatedEmail.normalized instead", DeprecationWarning)
8787
return self.normalized
8888

8989
"""For backwards compatibility, some fields are also exposed through a dict-like interface. Note
9090
that some of the names changed when they became attributes."""
91-
def __getitem__(self, key):
91+
def __getitem__(self, key: str) -> Union[Optional[str], bool, List[Tuple[int, str]]]:
9292
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)
9393
if key == "email":
9494
return self.normalized
@@ -109,7 +109,7 @@ def __getitem__(self, key):
109109
raise KeyError()
110110

111111
"""Tests use this."""
112-
def __eq__(self, other):
112+
def __eq__(self, other: object) -> bool:
113113
if not isinstance(other, ValidatedEmail):
114114
return False
115115
return (
@@ -127,7 +127,7 @@ def __eq__(self, other):
127127
)
128128

129129
"""This helps producing the README."""
130-
def as_constructor(self):
130+
def as_constructor(self) -> str:
131131
return "ValidatedEmail(" \
132132
+ ",".join(f"\n {key}={repr(getattr(self, key))}"
133133
for key in ('normalized', 'local_part', 'domain',
@@ -139,7 +139,7 @@ def as_constructor(self):
139139
+ ")"
140140

141141
"""Convenience method for accessing ValidatedEmail as a dict"""
142-
def as_dict(self):
142+
def as_dict(self) -> Dict[str, Any]:
143143
d = self.__dict__
144144
if d.get('domain_address'):
145145
d['domain_address'] = repr(d['domain_address'])

email_validator/syntax.py

Lines changed: 22 additions & 12 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, TypedDict, Union
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
@@ -351,7 +351,7 @@ def validate_email_local_part(local: str, allow_smtputf8: bool = True, allow_emp
351351
raise EmailSyntaxError("The email address contains invalid characters before the @-sign.")
352352

353353

354-
def check_unsafe_chars(s, allow_space=False):
354+
def check_unsafe_chars(s: str, allow_space: bool = False) -> None:
355355
# Check for unsafe characters or characters that would make the string
356356
# invalid or non-sensible Unicode.
357357
bad_chars = set()
@@ -403,7 +403,7 @@ def check_unsafe_chars(s, allow_space=False):
403403
+ ", ".join(safe_character_display(c) for c in sorted(bad_chars)) + ".")
404404

405405

406-
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:
407407
# RFC 5322 3.2.3
408408
if label.endswith("."):
409409
raise EmailSyntaxError(end_descr.format("period"))
@@ -422,7 +422,12 @@ def check_dot_atom(label, start_descr, end_descr, is_hostname):
422422
raise EmailSyntaxError("An email address cannot have a period and a hyphen next to each other.")
423423

424424

425-
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:
426431
"""Validates the syntax of the domain part of an email address."""
427432

428433
# Check for invalid characters before normalization.
@@ -586,7 +591,7 @@ def validate_email_domain_name(domain, test_environment=False, globally_delivera
586591
}
587592

588593

589-
def validate_email_length(addrinfo):
594+
def validate_email_length(addrinfo: ValidatedEmail) -> None:
590595
# If the email address has an ASCII representation, then we assume it may be
591596
# transmitted in ASCII (we can't assume SMTPUTF8 will be used on all hops to
592597
# the destination) and the length limit applies to ASCII characters (which is
@@ -627,7 +632,12 @@ def validate_email_length(addrinfo):
627632
raise EmailSyntaxError(f"The email address is too long {reason}.")
628633

629634

630-
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:
631641
# This is obscure domain-literal syntax. Parse it and return
632642
# a compressed/normalized address.
633643
# RFC 5321 4.1.3 and RFC 5322 3.4.1.

email_validator/validate_email.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,20 +110,20 @@ def validate_email(
110110

111111
elif domain_part.startswith("[") and domain_part.endswith("]"):
112112
# Parse the address in the domain literal and get back a normalized domain.
113-
domain_part_info = validate_email_domain_literal(domain_part[1:-1])
113+
domain_literal_info = validate_email_domain_literal(domain_part[1:-1])
114114
if not allow_domain_literal:
115115
raise EmailSyntaxError("A bracketed IP address after the @-sign is not allowed here.")
116-
ret.domain = domain_part_info["domain"]
117-
ret.ascii_domain = domain_part_info["domain"] # Domain literals are always ASCII.
118-
ret.domain_address = domain_part_info["domain_address"]
116+
ret.domain = domain_literal_info["domain"]
117+
ret.ascii_domain = domain_literal_info["domain"] # Domain literals are always ASCII.
118+
ret.domain_address = domain_literal_info["domain_address"]
119119
is_domain_literal = True # Prevent deliverability checks.
120120

121121
else:
122122
# Check the syntax of the domain and get back a normalized
123123
# internationalized and ASCII form.
124-
domain_part_info = validate_email_domain_name(domain_part, test_environment=test_environment, globally_deliverable=globally_deliverable)
125-
ret.domain = domain_part_info["domain"]
126-
ret.ascii_domain = domain_part_info["ascii_domain"]
124+
domain_name_info = validate_email_domain_name(domain_part, test_environment=test_environment, globally_deliverable=globally_deliverable)
125+
ret.domain = domain_name_info["domain"]
126+
ret.ascii_domain = domain_name_info["ascii_domain"]
127127

128128
# Construct the complete normalized form.
129129
ret.normalized = ret.local_part + "@" + ret.domain

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ check_untyped_defs = true
66
disallow_incomplete_defs = true
77
# disallow_untyped_calls = true
88
disallow_untyped_decorators = true
9-
# disallow_untyped_defs = true
9+
disallow_untyped_defs = true
1010

1111
warn_redundant_casts = true
1212
warn_unused_ignores = true

tests/mocked_dns_response.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from typing import Any, Dict, Iterator, Optional
2+
3+
import dns.rdataset
14
import dns.resolver
25
import json
36
import os.path
@@ -23,7 +26,7 @@ class MockedDnsResponseData:
2326
INSTANCE = None
2427

2528
@staticmethod
26-
def create_resolver():
29+
def create_resolver() -> dns.resolver.Resolver:
2730
if MockedDnsResponseData.INSTANCE is None:
2831
# Create a singleton instance of this class and load the saved DNS responses.
2932
# Except when BUILD_MOCKED_DNS_RESPONSE_DATA is true, don't load the data.
@@ -37,20 +40,19 @@ def create_resolver():
3740
dns_resolver = dns.resolver.Resolver(configure=BUILD_MOCKED_DNS_RESPONSE_DATA)
3841
return caching_resolver(cache=MockedDnsResponseData.INSTANCE, dns_resolver=dns_resolver)
3942

40-
def __init__(self):
41-
self.data = {}
42-
43-
def load(self):
44-
# Loads the saved DNS response data from the JSON file and
45-
# re-structures it into dnspython classes.
46-
class Ans: # mocks the dns.resolver.Answer class
43+
def __init__(self) -> None:
44+
self.data: Dict[dns.resolver.CacheKey, Optional[MockedDnsResponseData.Ans]] = {}
4745

48-
def __init__(self, rrset):
49-
self.rrset = rrset
46+
# Loads the saved DNS response data from the JSON file and
47+
# re-structures it into dnspython classes.
48+
class Ans: # mocks the dns.resolver.Answer class
49+
def __init__(self, rrset: dns.rdataset.Rdataset) -> None:
50+
self.rrset = rrset
5051

51-
def __iter__(self):
52-
return iter(self.rrset)
52+
def __iter__(self) -> Iterator[Any]:
53+
return iter(self.rrset)
5354

55+
def load(self) -> None:
5456
with open(self.DATA_PATH) as f:
5557
data = json.load(f)
5658
for item in data:
@@ -62,11 +64,11 @@ def __iter__(self):
6264
for rr in item["answer"]
6365
]
6466
if item["answer"]:
65-
self.data[key] = Ans(dns.rdataset.from_rdata_list(0, rdatas=rdatas))
67+
self.data[key] = MockedDnsResponseData.Ans(dns.rdataset.from_rdata_list(0, rdatas=rdatas))
6668
else:
6769
self.data[key] = None
6870

69-
def save(self):
71+
def save(self) -> None:
7072
# Re-structure as a list with basic data types.
7173
data = [
7274
{
@@ -81,11 +83,12 @@ def save(self):
8183
])
8284
}
8385
for key, value in self.data.items()
86+
if value is not None
8487
]
8588
with open(self.DATA_PATH, "w") as f:
8689
json.dump(data, f, indent=True)
8790

88-
def get(self, key):
91+
def get(self, key: dns.resolver.CacheKey) -> Optional[Ans]:
8992
# Special-case a domain to create a timeout.
9093
if key[0].to_text() == "timeout.com.":
9194
raise dns.exception.Timeout()
@@ -108,16 +111,16 @@ def get(self, key):
108111

109112
raise ValueError(f"Saved DNS data did not contain query: {key}")
110113

111-
def put(self, key, value):
114+
def put(self, key: dns.resolver.CacheKey, value: Ans) -> None:
112115
# Build the DNS data by saving the live query response.
113116
if not BUILD_MOCKED_DNS_RESPONSE_DATA:
114117
raise ValueError("Should not get here.")
115118
self.data[key] = value
116119

117120

118121
@pytest.fixture(scope="session", autouse=True)
119-
def MockedDnsResponseDataCleanup(request):
120-
def cleanup_func():
122+
def MockedDnsResponseDataCleanup(request: pytest.FixtureRequest) -> None:
123+
def cleanup_func() -> None:
121124
if BUILD_MOCKED_DNS_RESPONSE_DATA and MockedDnsResponseData.INSTANCE is not None:
122125
MockedDnsResponseData.INSTANCE.save()
123126
request.addfinalizer(cleanup_func)

0 commit comments

Comments
 (0)