From 966916e0dd810a3f6e58542d056b9402811d3ab0 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 17:44:08 +0300 Subject: [PATCH 1/2] 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. --- .github/workflows/tests.yaml | 18 ++++++++++++++++++ emails/__init__.py | 2 +- emails/backend/smtp/backend.py | 6 +++--- emails/message.py | 2 ++ emails/signers.py | 18 ++++++++++-------- emails/store/file.py | 14 +++++++------- emails/store/store.py | 5 +++-- emails/utils.py | 23 ++++++++++++----------- setup.cfg | 30 ++++++++++++++++++++++++++++++ tox.ini | 4 ++++ 10 files changed, 90 insertions(+), 32 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 50d054b..217e777 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -36,6 +36,24 @@ jobs: - name: run tests run: tox -e ${{ matrix.tox }} -- -m "not e2e" + typecheck: + name: "typecheck" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - name: update pip + run: | + pip install -U wheel + pip install -U setuptools + python -m pip install -U pip + - run: pip install tox + - name: run mypy + run: tox -e typecheck + e2e: name: "e2e / ${{ matrix.name }}" runs-on: ${{ matrix.os }} diff --git a/emails/__init__.py b/emails/__init__.py index 2633ffb..57e8a20 100644 --- a/emails/__init__.py +++ b/emails/__init__.py @@ -42,7 +42,7 @@ __license__ = 'Apache 2.0' __copyright__ = 'Copyright 2013-2026 Sergey Lavrinenko' -USER_AGENT = 'python-emails/%s' % __version__ +USER_AGENT: str = 'python-emails/%s' % __version__ from .message import Message, html from .utils import MessageID diff --git a/emails/backend/smtp/backend.py b/emails/backend/smtp/backend.py index a196756..e3ff855 100644 --- a/emails/backend/smtp/backend.py +++ b/emails/backend/smtp/backend.py @@ -34,7 +34,7 @@ class SMTPBackend: def __init__(self, ssl: bool = False, fail_silently: bool = True, mail_options: list[str] | None = None, **kwargs: Any) -> None: - self.smtp_cls = ssl and self.connection_ssl_cls or self.connection_cls + self.smtp_cls = self.connection_ssl_cls if ssl else self.connection_cls self.ssl = ssl self.tls = kwargs.get('tls') @@ -50,7 +50,7 @@ def __init__(self, ssl: bool = False, fail_silently: bool = True, self.smtp_cls_kwargs = kwargs self.host: str | None = kwargs.get('host') - self.port: int = kwargs.get('port') + self.port: int = kwargs.get('port') # type: ignore[assignment] self.fail_silently = fail_silently self.mail_options = mail_options or [] @@ -111,7 +111,7 @@ def _send(self, **kwargs: Any) -> SMTPResponse: response.raise_if_needed() return response else: - return client.sendmail(**kwargs) + return client.sendmail(**kwargs) # type: ignore[no-any-return] def sendmail(self, from_addr: str, to_addrs: str | list[str], msg: Any, mail_options: list[str] | None = None, diff --git a/emails/message.py b/emails/message.py index 758de8e..58ce674 100644 --- a/emails/message.py +++ b/emails/message.py @@ -280,6 +280,7 @@ def _build_html_part(self) -> SafeMIMEText | None: p = SafeMIMEText(text, 'html', charset=self.charset) p.set_charset(self.charset) return p + return None def _build_text_part(self) -> SafeMIMEText | None: text = self.text_body @@ -287,6 +288,7 @@ def _build_text_part(self) -> SafeMIMEText | None: p = SafeMIMEText(text, 'plain', charset=self.charset) p.set_charset(self.charset) return p + return None def build_message(self, message_cls: type | None = None) -> SafeMIMEMultipart: diff --git a/emails/signers.py b/emails/signers.py index 5eb9c90..da39a70 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -30,7 +30,7 @@ def __init__(self, selector: str, domain: str, key: str | bytes | IO[bytes] | No # Compile private key try: - privkey = parse_pem_private_key(to_bytes(privkey)) + privkey = parse_pem_private_key(to_bytes(privkey)) # type: ignore[arg-type] except UnparsableKeyError as exc: raise DKIMException(exc) @@ -43,12 +43,13 @@ def get_sign_string(self, message: bytes) -> bytes | None: # pydkim module parses message and privkey on each signing # this is not optimal for mass operations # TODO: patch pydkim or use another signing module - return dkim.sign(message=message, **self._sign_params) + return dkim.sign(message=message, **self._sign_params) # type: ignore[no-any-return] except DKIMException: if self.ignore_sign_errors: logging.exception('Error signing message') else: raise + return None def get_sign_bytes(self, message: bytes) -> bytes | None: return self.get_sign_string(message) @@ -57,10 +58,11 @@ def get_sign_header(self, message: bytes) -> tuple[str, str] | None: # pydkim returns string, so we should split s = self.get_sign_string(message) if s: - (header, value) = to_native(s).split(': ', 1) + (header, value) = to_native(s).split(': ', 1) # type: ignore[union-attr] if value.endswith("\r\n"): value = value[:-2] return header, value + return None def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart: """ @@ -71,9 +73,9 @@ def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart: # but py3 smtplib requires str to send DATA command (# # so we have to convert msg.as_string - dkim_header = self.get_sign_header(to_bytes(msg.as_string())) + dkim_header = self.get_sign_header(to_bytes(msg.as_string())) # type: ignore[arg-type] if dkim_header: - msg._headers.insert(0, dkim_header) + msg._headers.insert(0, dkim_header) # type: ignore[attr-defined] return msg def sign_message_string(self, message_string: str) -> str: @@ -85,12 +87,12 @@ def sign_message_string(self, message_string: str) -> str: # but py3 smtplib requires str to send DATA command # so we have to convert message_string - s = self.get_sign_string(to_bytes(message_string)) - return s and to_native(s) + message_string or message_string + s = self.get_sign_string(to_bytes(message_string)) # type: ignore[arg-type] + return s and to_native(s) + message_string or message_string # type: ignore[operator] def sign_message_bytes(self, message_bytes: bytes) -> bytes: """ Insert DKIM header to message bytes """ s = self.get_sign_bytes(message_bytes) - return s and to_bytes(s) + message_bytes or message_bytes + return s and to_bytes(s) + message_bytes or message_bytes # type: ignore[operator] diff --git a/emails/store/file.py b/emails/store/file.py index 4cd4ff8..7ef7023 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -35,10 +35,10 @@ def __init__(self, **kwargs: Any) -> None: if no filename set, filename extracted from uri. if no uri, but filename set, then uri==filename """ - self.uri: str | None = kwargs.get('uri', None) + self.uri: str | None = kwargs.get('uri', None) # type: ignore[no-redef] self.absolute_url: str | None = kwargs.get('absolute_url', None) or self.uri - self.filename: str | None = kwargs.get('filename', None) - self.data: bytes | str | IO[bytes] | None = kwargs.get('data', None) + self.filename: str | None = kwargs.get('filename', None) # type: ignore[no-redef] + self.data: bytes | str | IO[bytes] | None = kwargs.get('data', None) # type: ignore[no-redef] self._mime_type: str | None = kwargs.get('mime_type') self._headers: dict[str, str] = kwargs.get('headers', {}) self._content_id: str | None = kwargs.get('content_id') @@ -56,7 +56,7 @@ def get_data(self) -> bytes | str | None: if isinstance(_data, str): return _data elif hasattr(_data, 'read'): - return _data.read() + return _data.read() # type: ignore[union-attr, no-any-return] else: return _data @@ -142,7 +142,7 @@ def mime(self) -> MIMEBase | None: if p is None: filename_header = encode_header(self.filename) p = MIMEBase(*self.mime_type.split('/', 1), name=filename_header) - p.set_payload(to_bytes(self.data)) + p.set_payload(to_bytes(self.data)) # type: ignore[arg-type] encode_base64(p) if 'content-disposition' not in self._headers: p.add_header('Content-Disposition', self.content_disposition, filename=filename_header) @@ -186,7 +186,7 @@ def fetch(self) -> None: def get_data(self) -> bytes | str: self.fetch() - return self._data or '' + return self._data or '' # type: ignore[return-value] def set_data(self, v: bytes | str | IO[bytes] | None) -> None: self._data = v @@ -196,7 +196,7 @@ def set_data(self, v: bytes | str | IO[bytes] | None) -> None: @property def mime_type(self) -> str: self.fetch() - return super(LazyHTTPFile, self).mime_type + return super(LazyHTTPFile, self).mime_type # type: ignore[no-any-return] @property def headers(self) -> dict[str, str]: diff --git a/emails/store/store.py b/emails/store/store.py index 67cd6b8..1f7bd84 100644 --- a/emails/store/store.py +++ b/emails/store/store.py @@ -66,9 +66,9 @@ def unique_filename(self, filename: str | None, uri: str | None = None) -> str: if filename not in self._filenames: break - self._filenames[filename] = uri + self._filenames[filename] = uri # type: ignore[index] - return filename + return filename # type: ignore[return-value] def add(self, value: BaseFile | dict[str, Any], replace: bool = False) -> BaseFile: @@ -94,6 +94,7 @@ def by_filename(self, filename: str) -> BaseFile | None: uri = self._filenames.get(filename) if uri: return self.by_uri(uri) + return None def __getitem__(self, uri: str) -> BaseFile | None: return self.by_uri(uri) or self.by_filename(uri) diff --git a/emails/utils.py b/emails/utils.py index 97b82ea..3e678a8 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -17,7 +17,8 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.header import Header, decode_header as decode_header_ -from email.utils import parseaddr, formatdate, escapesre, specialsre +from email.utils import parseaddr, formatdate +from email.utils import escapesre, specialsre # type: ignore[attr-defined] # private but stable from . import USER_AGENT from .exc import HTTPLoaderError @@ -40,8 +41,8 @@ def to_unicode(x: Any, charset: str | None = sys.getdefaultencoding(), if not isinstance(x, bytes): return str(x) if charset is None and allow_none_charset: - return x - return x.decode(charset, errors) + return x # type: ignore[return-value] # returns bytes when allow_none_charset=True + return x.decode(charset, errors) # type: ignore[arg-type] # charset is str here def to_bytes(x: str | bytes | bytearray | memoryview | None, @@ -127,8 +128,8 @@ def get_fqdn(self) -> str: def decode_header(value: str | bytes, default: str = "utf-8", errors: str = 'strict') -> str: """Decode the specified header value""" - value = to_native(value, charset=default, errors=errors) - return "".join([to_unicode(text, charset or default, errors) for text, charset in decode_header_(value)]) + value = to_native(value, charset=default, errors=errors) # type: ignore[assignment] + return "".join([to_unicode(text, charset or default, errors) for text, charset in decode_header_(value)]) # type: ignore[misc, arg-type] class MessageID: @@ -212,7 +213,7 @@ def parse_name_and_email(obj: str | tuple[str | None, str] | list[str], def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> str: if parse: - _, addr = parseaddr(to_unicode(addr)) + _, addr = parseaddr(to_unicode(addr)) # type: ignore[arg-type] try: addr.encode('ascii') except UnicodeEncodeError: # IDN @@ -228,7 +229,7 @@ def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> s def sanitize_address(addr: str | tuple[str, str], encoding: str = 'ascii') -> str: if isinstance(addr, str): - addr = parseaddr(to_unicode(addr)) + addr = parseaddr(to_unicode(addr)) # type: ignore[arg-type] nm, addr = addr # This try-except clause is needed on Python 3 < 3.2.4 # http://bugs.python.org/issue14291 @@ -266,13 +267,13 @@ def as_bytes(self, unixfrom: bool = False, linesep: str = '\n') -> bytes: return fp.getvalue() -class SafeMIMEText(MIMEMixin, MIMEText): +class SafeMIMEText(MIMEMixin, MIMEText): # type: ignore[misc] # intentional override def __init__(self, text: str, subtype: str, charset: str) -> None: self.encoding = charset MIMEText.__init__(self, text, subtype, charset) -class SafeMIMEMultipart(MIMEMixin, MIMEMultipart): +class SafeMIMEMultipart(MIMEMixin, MIMEMultipart): # type: ignore[misc] # intentional override def __init__(self, _subtype: str = 'mixed', boundary: str | None = None, _subparts: list[Any] | None = None, encoding: str | None = None, **_params: Any) -> None: @@ -280,7 +281,7 @@ def __init__(self, _subtype: str = 'mixed', boundary: str | None = None, MIMEMultipart.__init__(self, _subtype, boundary, _subparts, **_params) -DEFAULT_REQUESTS_PARAMS = dict(allow_redirects=True, +DEFAULT_REQUESTS_PARAMS: dict[str, Any] = dict(allow_redirects=True, verify=False, timeout=10, headers={'User-Agent': USER_AGENT}) @@ -299,7 +300,7 @@ def fetch_url(url: str, valid_http_codes: tuple[int, ...] = (200, ), def encode_header(value: str | Any, charset: str = 'utf-8') -> str | Any: if isinstance(value, str): - value = to_unicode(value, charset=charset).rstrip() + value = to_unicode(value, charset=charset).rstrip() # type: ignore[union-attr] _r = Header(value, charset) return str(_r) else: diff --git a/setup.cfg b/setup.cfg index 7aed5d8..602cc2a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,33 @@ +[mypy] +python_version = 3.10 +ignore_missing_imports = true +warn_return_any = true +warn_unused_ignores = true +exclude = emails/(packages|testsuite)/ + +# Mixin classes access attributes defined in BaseMessage via MRO; +# mypy checks each mixin in isolation and reports false attr-defined errors. +# Also suppress arg-type/misc/union-attr/return-value/no-any-return which +# stem from the same mixin pattern (self is typed as the mixin, not Message). +[mypy-emails.message] +disable_error_code = attr-defined, arg-type, misc, union-attr, return-value, no-any-return, assignment + +# Vendored DKIM package +[mypy-emails.packages.*] +ignore_errors = true + +# Uses private smtplib attrs (_have_ssl) and conditional class definitions +[mypy-emails.backend.smtp.client] +disable_error_code = attr-defined, no-redef + +# Optional dependency, not always installed +[mypy-emails.template.*] +ignore_errors = true + +# Django integration, not always installed +[mypy-emails.django] +ignore_errors = true + [tool:pytest] norecursedirs = .* {arch} *.egg *.egg-info dist build requirements markers = diff --git a/tox.ini b/tox.ini index 8d6735f..83d7093 100644 --- a/tox.ini +++ b/tox.ini @@ -30,6 +30,10 @@ deps = -rrequirements/tests.txt +[testenv:typecheck] +deps = mypy +commands = mypy emails/ + [testenv:style] deps = pre-commit skip_install = true From a126ecf6ce7f7a5614176bd132c3361cb7df01a7 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Tue, 31 Mar 2026 17:46:37 +0300 Subject: [PATCH 2/2] Fix mypy CI: add per-module ignore for requests stubs The global ignore_missing_imports does not suppress import-untyped for inline imports. Add explicit per-module override for requests. --- setup.cfg | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.cfg b/setup.cfg index 602cc2a..8d31f4d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,6 +20,10 @@ ignore_errors = true [mypy-emails.backend.smtp.client] disable_error_code = attr-defined, no-redef +# Optional dependency stubs +[mypy-requests.*] +ignore_missing_imports = true + # Optional dependency, not always installed [mypy-emails.template.*] ignore_errors = true