Skip to content

Commit 43a3f8f

Browse files
committed
Add mypy type checking to CI
Configure mypy in setup.cfg with per-module overrides: - emails.message: suppress mixin pattern false positives - emails.packages: ignore vendored DKIM code - emails.backend.smtp.client: private smtplib attrs - emails.template, emails.django: optional deps Add targeted type: ignore comments for known safe patterns. Add tox typecheck environment and CI job. mypy now passes clean on 26 source files.
1 parent 39ef060 commit 43a3f8f

10 files changed

Lines changed: 90 additions & 32 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ jobs:
3636
- name: run tests
3737
run: tox -e ${{ matrix.tox }} -- -m "not e2e"
3838

39+
typecheck:
40+
name: "typecheck"
41+
runs-on: ubuntu-latest
42+
steps:
43+
- uses: actions/checkout@v4
44+
- uses: actions/setup-python@v5
45+
with:
46+
python-version: '3.12'
47+
cache: pip
48+
- name: update pip
49+
run: |
50+
pip install -U wheel
51+
pip install -U setuptools
52+
python -m pip install -U pip
53+
- run: pip install tox
54+
- name: run mypy
55+
run: tox -e typecheck
56+
3957
e2e:
4058
name: "e2e / ${{ matrix.name }}"
4159
runs-on: ${{ matrix.os }}

emails/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
__license__ = 'Apache 2.0'
4343
__copyright__ = 'Copyright 2013-2026 Sergey Lavrinenko'
4444

45-
USER_AGENT = 'python-emails/%s' % __version__
45+
USER_AGENT: str = 'python-emails/%s' % __version__
4646

4747
from .message import Message, html
4848
from .utils import MessageID

emails/backend/smtp/backend.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class SMTPBackend:
3434
def __init__(self, ssl: bool = False, fail_silently: bool = True,
3535
mail_options: list[str] | None = None, **kwargs: Any) -> None:
3636

37-
self.smtp_cls = ssl and self.connection_ssl_cls or self.connection_cls
37+
self.smtp_cls = self.connection_ssl_cls if ssl else self.connection_cls
3838

3939
self.ssl = ssl
4040
self.tls = kwargs.get('tls')
@@ -50,7 +50,7 @@ def __init__(self, ssl: bool = False, fail_silently: bool = True,
5050
self.smtp_cls_kwargs = kwargs
5151

5252
self.host: str | None = kwargs.get('host')
53-
self.port: int = kwargs.get('port')
53+
self.port: int = kwargs.get('port') # type: ignore[assignment]
5454
self.fail_silently = fail_silently
5555
self.mail_options = mail_options or []
5656

@@ -111,7 +111,7 @@ def _send(self, **kwargs: Any) -> SMTPResponse:
111111
response.raise_if_needed()
112112
return response
113113
else:
114-
return client.sendmail(**kwargs)
114+
return client.sendmail(**kwargs) # type: ignore[no-any-return]
115115

116116
def sendmail(self, from_addr: str, to_addrs: str | list[str],
117117
msg: Any, mail_options: list[str] | None = None,

emails/message.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,13 +280,15 @@ def _build_html_part(self) -> SafeMIMEText | None:
280280
p = SafeMIMEText(text, 'html', charset=self.charset)
281281
p.set_charset(self.charset)
282282
return p
283+
return None
283284

284285
def _build_text_part(self) -> SafeMIMEText | None:
285286
text = self.text_body
286287
if text:
287288
p = SafeMIMEText(text, 'plain', charset=self.charset)
288289
p.set_charset(self.charset)
289290
return p
291+
return None
290292

291293
def build_message(self, message_cls: type | None = None) -> SafeMIMEMultipart:
292294

emails/signers.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def __init__(self, selector: str, domain: str, key: str | bytes | IO[bytes] | No
3030

3131
# Compile private key
3232
try:
33-
privkey = parse_pem_private_key(to_bytes(privkey))
33+
privkey = parse_pem_private_key(to_bytes(privkey)) # type: ignore[arg-type]
3434
except UnparsableKeyError as exc:
3535
raise DKIMException(exc)
3636

@@ -43,12 +43,13 @@ def get_sign_string(self, message: bytes) -> bytes | None:
4343
# pydkim module parses message and privkey on each signing
4444
# this is not optimal for mass operations
4545
# TODO: patch pydkim or use another signing module
46-
return dkim.sign(message=message, **self._sign_params)
46+
return dkim.sign(message=message, **self._sign_params) # type: ignore[no-any-return]
4747
except DKIMException:
4848
if self.ignore_sign_errors:
4949
logging.exception('Error signing message')
5050
else:
5151
raise
52+
return None
5253

5354
def get_sign_bytes(self, message: bytes) -> bytes | None:
5455
return self.get_sign_string(message)
@@ -57,10 +58,11 @@ def get_sign_header(self, message: bytes) -> tuple[str, str] | None:
5758
# pydkim returns string, so we should split
5859
s = self.get_sign_string(message)
5960
if s:
60-
(header, value) = to_native(s).split(': ', 1)
61+
(header, value) = to_native(s).split(': ', 1) # type: ignore[union-attr]
6162
if value.endswith("\r\n"):
6263
value = value[:-2]
6364
return header, value
65+
return None
6466

6567
def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart:
6668
"""
@@ -71,9 +73,9 @@ def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart:
7173
# but py3 smtplib requires str to send DATA command (#
7274
# so we have to convert msg.as_string
7375

74-
dkim_header = self.get_sign_header(to_bytes(msg.as_string()))
76+
dkim_header = self.get_sign_header(to_bytes(msg.as_string())) # type: ignore[arg-type]
7577
if dkim_header:
76-
msg._headers.insert(0, dkim_header)
78+
msg._headers.insert(0, dkim_header) # type: ignore[attr-defined]
7779
return msg
7880

7981
def sign_message_string(self, message_string: str) -> str:
@@ -85,12 +87,12 @@ def sign_message_string(self, message_string: str) -> str:
8587
# but py3 smtplib requires str to send DATA command
8688
# so we have to convert message_string
8789

88-
s = self.get_sign_string(to_bytes(message_string))
89-
return s and to_native(s) + message_string or message_string
90+
s = self.get_sign_string(to_bytes(message_string)) # type: ignore[arg-type]
91+
return s and to_native(s) + message_string or message_string # type: ignore[operator]
9092

9193
def sign_message_bytes(self, message_bytes: bytes) -> bytes:
9294
"""
9395
Insert DKIM header to message bytes
9496
"""
9597
s = self.get_sign_bytes(message_bytes)
96-
return s and to_bytes(s) + message_bytes or message_bytes
98+
return s and to_bytes(s) + message_bytes or message_bytes # type: ignore[operator]

emails/store/file.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ def __init__(self, **kwargs: Any) -> None:
3535
if no filename set, filename extracted from uri.
3636
if no uri, but filename set, then uri==filename
3737
"""
38-
self.uri: str | None = kwargs.get('uri', None)
38+
self.uri: str | None = kwargs.get('uri', None) # type: ignore[no-redef]
3939
self.absolute_url: str | None = kwargs.get('absolute_url', None) or self.uri
40-
self.filename: str | None = kwargs.get('filename', None)
41-
self.data: bytes | str | IO[bytes] | None = kwargs.get('data', None)
40+
self.filename: str | None = kwargs.get('filename', None) # type: ignore[no-redef]
41+
self.data: bytes | str | IO[bytes] | None = kwargs.get('data', None) # type: ignore[no-redef]
4242
self._mime_type: str | None = kwargs.get('mime_type')
4343
self._headers: dict[str, str] = kwargs.get('headers', {})
4444
self._content_id: str | None = kwargs.get('content_id')
@@ -56,7 +56,7 @@ def get_data(self) -> bytes | str | None:
5656
if isinstance(_data, str):
5757
return _data
5858
elif hasattr(_data, 'read'):
59-
return _data.read()
59+
return _data.read() # type: ignore[union-attr, no-any-return]
6060
else:
6161
return _data
6262

@@ -142,7 +142,7 @@ def mime(self) -> MIMEBase | None:
142142
if p is None:
143143
filename_header = encode_header(self.filename)
144144
p = MIMEBase(*self.mime_type.split('/', 1), name=filename_header)
145-
p.set_payload(to_bytes(self.data))
145+
p.set_payload(to_bytes(self.data)) # type: ignore[arg-type]
146146
encode_base64(p)
147147
if 'content-disposition' not in self._headers:
148148
p.add_header('Content-Disposition', self.content_disposition, filename=filename_header)
@@ -186,7 +186,7 @@ def fetch(self) -> None:
186186

187187
def get_data(self) -> bytes | str:
188188
self.fetch()
189-
return self._data or ''
189+
return self._data or '' # type: ignore[return-value]
190190

191191
def set_data(self, v: bytes | str | IO[bytes] | None) -> None:
192192
self._data = v
@@ -196,7 +196,7 @@ def set_data(self, v: bytes | str | IO[bytes] | None) -> None:
196196
@property
197197
def mime_type(self) -> str:
198198
self.fetch()
199-
return super(LazyHTTPFile, self).mime_type
199+
return super(LazyHTTPFile, self).mime_type # type: ignore[no-any-return]
200200

201201
@property
202202
def headers(self) -> dict[str, str]:

emails/store/store.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ def unique_filename(self, filename: str | None, uri: str | None = None) -> str:
6666
if filename not in self._filenames:
6767
break
6868

69-
self._filenames[filename] = uri
69+
self._filenames[filename] = uri # type: ignore[index]
7070

71-
return filename
71+
return filename # type: ignore[return-value]
7272

7373
def add(self, value: BaseFile | dict[str, Any], replace: bool = False) -> BaseFile:
7474

@@ -94,6 +94,7 @@ def by_filename(self, filename: str) -> BaseFile | None:
9494
uri = self._filenames.get(filename)
9595
if uri:
9696
return self.by_uri(uri)
97+
return None
9798

9899
def __getitem__(self, uri: str) -> BaseFile | None:
99100
return self.by_uri(uri) or self.by_filename(uri)

emails/utils.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
from email.mime.text import MIMEText
1818
from email.mime.multipart import MIMEMultipart
1919
from email.header import Header, decode_header as decode_header_
20-
from email.utils import parseaddr, formatdate, escapesre, specialsre
20+
from email.utils import parseaddr, formatdate
21+
from email.utils import escapesre, specialsre # type: ignore[attr-defined] # private but stable
2122

2223
from . import USER_AGENT
2324
from .exc import HTTPLoaderError
@@ -40,8 +41,8 @@ def to_unicode(x: Any, charset: str | None = sys.getdefaultencoding(),
4041
if not isinstance(x, bytes):
4142
return str(x)
4243
if charset is None and allow_none_charset:
43-
return x
44-
return x.decode(charset, errors)
44+
return x # type: ignore[return-value] # returns bytes when allow_none_charset=True
45+
return x.decode(charset, errors) # type: ignore[arg-type] # charset is str here
4546

4647

4748
def to_bytes(x: str | bytes | bytearray | memoryview | None,
@@ -127,8 +128,8 @@ def get_fqdn(self) -> str:
127128

128129
def decode_header(value: str | bytes, default: str = "utf-8", errors: str = 'strict') -> str:
129130
"""Decode the specified header value"""
130-
value = to_native(value, charset=default, errors=errors)
131-
return "".join([to_unicode(text, charset or default, errors) for text, charset in decode_header_(value)])
131+
value = to_native(value, charset=default, errors=errors) # type: ignore[assignment]
132+
return "".join([to_unicode(text, charset or default, errors) for text, charset in decode_header_(value)]) # type: ignore[misc, arg-type]
132133

133134

134135
class MessageID:
@@ -212,7 +213,7 @@ def parse_name_and_email(obj: str | tuple[str | None, str] | list[str],
212213

213214
def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> str:
214215
if parse:
215-
_, addr = parseaddr(to_unicode(addr))
216+
_, addr = parseaddr(to_unicode(addr)) # type: ignore[arg-type]
216217
try:
217218
addr.encode('ascii')
218219
except UnicodeEncodeError: # IDN
@@ -228,7 +229,7 @@ def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> s
228229

229230
def sanitize_address(addr: str | tuple[str, str], encoding: str = 'ascii') -> str:
230231
if isinstance(addr, str):
231-
addr = parseaddr(to_unicode(addr))
232+
addr = parseaddr(to_unicode(addr)) # type: ignore[arg-type]
232233
nm, addr = addr
233234
# This try-except clause is needed on Python 3 < 3.2.4
234235
# http://bugs.python.org/issue14291
@@ -266,21 +267,21 @@ def as_bytes(self, unixfrom: bool = False, linesep: str = '\n') -> bytes:
266267
return fp.getvalue()
267268

268269

269-
class SafeMIMEText(MIMEMixin, MIMEText):
270+
class SafeMIMEText(MIMEMixin, MIMEText): # type: ignore[misc] # intentional override
270271
def __init__(self, text: str, subtype: str, charset: str) -> None:
271272
self.encoding = charset
272273
MIMEText.__init__(self, text, subtype, charset)
273274

274275

275-
class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
276+
class SafeMIMEMultipart(MIMEMixin, MIMEMultipart): # type: ignore[misc] # intentional override
276277
def __init__(self, _subtype: str = 'mixed', boundary: str | None = None,
277278
_subparts: list[Any] | None = None,
278279
encoding: str | None = None, **_params: Any) -> None:
279280
self.encoding = encoding
280281
MIMEMultipart.__init__(self, _subtype, boundary, _subparts, **_params)
281282

282283

283-
DEFAULT_REQUESTS_PARAMS = dict(allow_redirects=True,
284+
DEFAULT_REQUESTS_PARAMS: dict[str, Any] = dict(allow_redirects=True,
284285
verify=False, timeout=10,
285286
headers={'User-Agent': USER_AGENT})
286287

@@ -299,7 +300,7 @@ def fetch_url(url: str, valid_http_codes: tuple[int, ...] = (200, ),
299300

300301
def encode_header(value: str | Any, charset: str = 'utf-8') -> str | Any:
301302
if isinstance(value, str):
302-
value = to_unicode(value, charset=charset).rstrip()
303+
value = to_unicode(value, charset=charset).rstrip() # type: ignore[union-attr]
303304
_r = Header(value, charset)
304305
return str(_r)
305306
else:

setup.cfg

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,33 @@
1+
[mypy]
2+
python_version = 3.10
3+
ignore_missing_imports = true
4+
warn_return_any = true
5+
warn_unused_ignores = true
6+
exclude = emails/(packages|testsuite)/
7+
8+
# Mixin classes access attributes defined in BaseMessage via MRO;
9+
# mypy checks each mixin in isolation and reports false attr-defined errors.
10+
# Also suppress arg-type/misc/union-attr/return-value/no-any-return which
11+
# stem from the same mixin pattern (self is typed as the mixin, not Message).
12+
[mypy-emails.message]
13+
disable_error_code = attr-defined, arg-type, misc, union-attr, return-value, no-any-return, assignment
14+
15+
# Vendored DKIM package
16+
[mypy-emails.packages.*]
17+
ignore_errors = true
18+
19+
# Uses private smtplib attrs (_have_ssl) and conditional class definitions
20+
[mypy-emails.backend.smtp.client]
21+
disable_error_code = attr-defined, no-redef
22+
23+
# Optional dependency, not always installed
24+
[mypy-emails.template.*]
25+
ignore_errors = true
26+
27+
# Django integration, not always installed
28+
[mypy-emails.django]
29+
ignore_errors = true
30+
131
[tool:pytest]
232
norecursedirs = .* {arch} *.egg *.egg-info dist build requirements
333
markers =

tox.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ deps =
3030
-rrequirements/tests.txt
3131

3232

33+
[testenv:typecheck]
34+
deps = mypy
35+
commands = mypy emails/
36+
3337
[testenv:style]
3438
deps = pre-commit
3539
skip_install = true

0 commit comments

Comments
 (0)