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/MANIFEST.in b/MANIFEST.in index 645a28c..615d03f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ include README.rst LICENSE requirements.txt +include emails/py.typed 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/response.py b/emails/backend/response.py index 20dfb29..dd10832 100644 --- a/emails/backend/response.py +++ b/emails/backend/response.py @@ -37,15 +37,15 @@ def __init__(self, exception: Exception | None = None, backend: Any = None) -> N self.responses: list[list] = [] - self.esmtp_opts: str | None = None - self.rcpt_options: str | None = None + self.esmtp_opts: list[str] | None = None + self.rcpt_options: list[str] | None = None self.status_code: int | None = None - self.status_text: str | None = None + self.status_text: bytes | None = None self.last_command: str | None = None - self.refused_recipient: dict[str, tuple[int, str]] = {} + self.refused_recipients: dict[str, tuple[int, bytes]] = {} - def set_status(self, command: str, code: int, text: str, **kwargs: Any) -> None: + def set_status(self, command: str, code: int, text: bytes, **kwargs: Any) -> None: self.responses.append([command, code, text, kwargs]) self.status_code = code self.status_text = text diff --git a/emails/backend/smtp/backend.py b/emails/backend/smtp/backend.py index a196756..3f7231d 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['port'] # always set as int two lines above self.fail_silently = fail_silently self.mail_options = mail_options or [] @@ -80,9 +80,9 @@ def close(self) -> None: def make_response(self, exception: Exception | None = None) -> SMTPResponse: return self.response_cls(backend=self, exception=exception) - def retry_on_disconnect(self, func: Callable[..., SMTPResponse]) -> Callable[..., SMTPResponse]: + def retry_on_disconnect(self, func: Callable[..., SMTPResponse | None]) -> Callable[..., SMTPResponse | None]: @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> SMTPResponse: + def wrapper(*args: Any, **kwargs: Any) -> SMTPResponse | None: try: return func(*args, **kwargs) except smtplib.SMTPServerDisconnected: @@ -92,7 +92,7 @@ def wrapper(*args: Any, **kwargs: Any) -> SMTPResponse: return func(*args, **kwargs) return wrapper - def _send(self, **kwargs: Any) -> SMTPResponse: + def _send(self, **kwargs: Any) -> SMTPResponse | None: response = None try: @@ -131,7 +131,7 @@ def sendmail(self, from_addr: str, to_addrs: str | list[str], mail_options=mail_options or self.mail_options, rcpt_options=rcpt_options) - if not self.fail_silently: + if response and not self.fail_silently: response.raise_if_needed() return response diff --git a/emails/backend/smtp/client.py b/emails/backend/smtp/client.py index fb0b5cb..3798c06 100644 --- a/emails/backend/smtp/client.py +++ b/emails/backend/smtp/client.py @@ -1,10 +1,13 @@ # encoding: utf-8 +from __future__ import annotations + __all__ = ['SMTPClientWithResponse', 'SMTPClientWithResponse_SSL'] import smtplib -from smtplib import _have_ssl, SMTP +from smtplib import _have_ssl, SMTP # noqa: private API import logging -from ... utils import sanitize_email +from ..response import SMTPResponse +from ...utils import sanitize_email logger = logging.getLogger(__name__) @@ -55,7 +58,9 @@ def _rset(self): except smtplib.SMTPServerDisconnected: pass - def sendmail(self, from_addr, to_addrs, msg, mail_options=None, rcpt_options=None): + def sendmail(self, from_addr: str, to_addrs: list[str] | str, + msg: bytes, mail_options: list[str] | None = None, + rcpt_options: list[str] | None = None) -> SMTPResponse | None: if not to_addrs: return None diff --git a/emails/message.py b/emails/message.py index 758de8e..15c39fe 100644 --- a/emails/message.py +++ b/emails/message.py @@ -6,7 +6,7 @@ from email.utils import getaddresses from typing import Any, IO -from .utils import (formataddr, to_unicode, to_native, +from .utils import (formataddr, SafeMIMEText, SafeMIMEMultipart, sanitize_address, parse_name_and_email, load_email_charsets, encode_header as encode_header_, @@ -39,7 +39,7 @@ class BaseMessage: def __init__(self, charset: str | None = None, message_id: str | MessageID | bool | None = None, - date: str | datetime | float | bool | Callable[[], str] | None = None, + date: str | datetime | float | bool | Callable[..., str | datetime | float] | None = None, subject: str | None = None, mail_from: _Address = None, mail_to: _AddressList = None, @@ -158,7 +158,7 @@ def get_subject(self) -> str | None: def render(self, **kwargs: Any) -> None: self.render_data = kwargs - def set_date(self, value: str | datetime | float | bool | Callable[[], str] | None) -> None: + def set_date(self, value: str | datetime | float | bool | Callable[..., str | datetime | float] | None) -> None: self._date = value def get_date(self) -> str | None: @@ -231,7 +231,7 @@ def set_header(self, msg: SafeMIMEMultipart, key: str, return if not isinstance(value, str): - value = to_unicode(value) + value = value.decode() if isinstance(value, bytes) else str(value) # Prevent header injection if '\n' in value or '\r' in value: @@ -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: @@ -343,7 +345,7 @@ def as_string(self, message_cls: type | None = None) -> str: Note: this method costs one less message-to-string conversions for dkim in compare to self.as_message().as_string() """ - r = to_native(self.build_message(message_cls=message_cls).as_string()) + r = self.build_message(message_cls=message_cls).as_string() if self._signer: r = self.sign_string(r) return r @@ -354,7 +356,7 @@ def as_bytes(self, message_cls: type | None = None) -> bytes: """ r = self.build_message(message_cls=message_cls).as_bytes() if self._signer: - r = self.sign_string(r) + r = self.sign_bytes(r) return r diff --git a/emails/signers.py b/emails/signers.py index 5eb9c90..71c8365 100644 --- a/emails/signers.py +++ b/emails/signers.py @@ -9,7 +9,6 @@ from .packages import dkim from .packages.dkim import DKIMException, UnparsableKeyError from .packages.dkim.crypto import parse_pem_private_key -from .utils import to_bytes, to_native class DKIMSigner: @@ -28,27 +27,32 @@ def __init__(self, selector: str, domain: str, key: str | bytes | IO[bytes] | No if privkey and hasattr(privkey, 'read'): privkey = privkey.read() + # Normalize to bytes before parsing + privkey_bytes = privkey if isinstance(privkey, bytes) else str(privkey).encode() + # Compile private key try: - privkey = parse_pem_private_key(to_bytes(privkey)) + privkey_parsed = parse_pem_private_key(privkey_bytes) except UnparsableKeyError as exc: raise DKIMException(exc) - self._sign_params.update({'privkey': privkey, - 'domain': to_bytes(domain), - 'selector': to_bytes(selector)}) + self._sign_params.update({'privkey': privkey_parsed, + 'domain': domain.encode(), + 'selector': selector.encode()}) def get_sign_string(self, message: bytes) -> bytes | None: try: # 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) + result: bytes = dkim.sign(message=message, **self._sign_params) + return result 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 +61,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) = s.decode().split(': ', 1) if value.endswith("\r\n"): value = value[:-2] return header, value + return None def sign_message(self, msg: MIMEMultipart) -> MIMEMultipart: """ @@ -71,9 +76,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(msg.as_string().encode()) 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 +90,16 @@ 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(message_string.encode()) + if s: + return s.decode() + message_string + return message_string 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 + if s: + return s + message_bytes + return message_bytes diff --git a/emails/store/file.py b/emails/store/file.py index 4cd4ff8..1fbc0f5 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -29,16 +29,18 @@ class BaseFile: Store base "attachment-file" information. """ + _data: bytes | str | IO[bytes] | None + def __init__(self, **kwargs: Any) -> None: """ uri and filename are connected properties. 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 = kwargs.get('uri', None) 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 = kwargs.get('filename', None) + self.data = kwargs.get('data', None) 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') @@ -52,13 +54,13 @@ def as_dict(self, fields: tuple[str, ...] | None = None) -> dict[str, Any]: return dict([(k, getattr(self, k)) for k in fields]) def get_data(self) -> bytes | str | None: - _data = getattr(self, '_data', None) - if isinstance(_data, str): + _data = self._data + if isinstance(_data, (str, bytes)): return _data - elif hasattr(_data, 'read'): - return _data.read() + elif _data is None: + return None else: - return _data + return _data.read() def set_data(self, value: bytes | str | IO[bytes] | None) -> None: self._data = value @@ -142,7 +144,8 @@ 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)) + payload = to_bytes(self.data) or b'' + p.set_payload(payload) encode_base64(p) if 'content-disposition' not in self._headers: p.add_header('Content-Disposition', self.content_disposition, filename=filename_header) @@ -186,7 +189,12 @@ def fetch(self) -> None: def get_data(self) -> bytes | str: self.fetch() - return self._data or '' + data = self._data + if data is None: + return '' + if isinstance(data, (str, bytes)): + return data + return data.read() def set_data(self, v: bytes | str | IO[bytes] | None) -> None: self._data = v @@ -196,7 +204,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 self.get_mime_type() @property def headers(self) -> dict[str, str]: diff --git a/emails/store/store.py b/emails/store/store.py index 67cd6b8..ae6e4c1 100644 --- a/emails/store/store.py +++ b/emails/store/store.py @@ -54,7 +54,7 @@ def remove(self, uri: BaseFile | str) -> None: del self._filenames[filename] del self._files[uri] - def unique_filename(self, filename: str | None, uri: str | None = None) -> str: + def unique_filename(self, filename: str | None, uri: str | None = None) -> str | None: if filename in self._filenames: n = 1 @@ -66,7 +66,8 @@ def unique_filename(self, filename: str | None, uri: str | None = None) -> str: if filename not in self._filenames: break - self._filenames[filename] = uri + if filename is not None: + self._filenames[filename] = uri return filename @@ -94,6 +95,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/testsuite/message/test_dkim.py b/emails/testsuite/message/test_dkim.py index 4c9eafd..c3ea16f 100644 --- a/emails/testsuite/message/test_dkim.py +++ b/emails/testsuite/message/test_dkim.py @@ -132,6 +132,16 @@ def test_dkim_error(): m.as_message() +def test_dkim_as_bytes(): + + priv_key, pub_key = _generate_key(length=1024) + message = Message(**common_email_data()) + message.dkim(key=priv_key, selector='_dkim', domain='somewhere.net') + result = message.as_bytes() + assert isinstance(result, bytes) + assert b'DKIM-Signature: ' in result + + def test_dkim_sign_twice(): # Test #44: diff --git a/emails/testsuite/smtp/test_smtp_response.py b/emails/testsuite/smtp/test_smtp_response.py index e69de29..47ddab3 100644 --- a/emails/testsuite/smtp/test_smtp_response.py +++ b/emails/testsuite/smtp/test_smtp_response.py @@ -0,0 +1,45 @@ +# encoding: utf-8 +from emails.backend.response import SMTPResponse + + +def test_smtp_response_defaults(): + r = SMTPResponse() + assert r.status_code is None + assert r.status_text is None + assert r.refused_recipients == {} + assert r.esmtp_opts is None + assert r.rcpt_options is None + assert not r.success + assert r.error is None + + +def test_smtp_response_set_status(): + r = SMTPResponse() + r.set_status('mail', 250, b'OK') + assert r.status_code == 250 + assert r.status_text == b'OK' + assert r.last_command == 'mail' + assert len(r.responses) == 1 + + +def test_smtp_response_success(): + r = SMTPResponse() + r.set_status('data', 250, b'OK') + assert not r.success # _finished is False + r._finished = True + assert r.success + + +def test_smtp_response_refused_recipients(): + r = SMTPResponse() + r.refused_recipients = {} + r.refused_recipients['bad@example.com'] = (550, b'User unknown') + assert 'bad@example.com' in r.refused_recipients + assert r.refused_recipients['bad@example.com'] == (550, b'User unknown') + + +def test_smtp_response_exception(): + exc = Exception('connection failed') + r = SMTPResponse(exception=exc) + assert r.error is exc + assert not r.success diff --git a/emails/testsuite/store/test_store.py b/emails/testsuite/store/test_store.py index 30a5ada..462f113 100644 --- a/emails/testsuite/store/test_store.py +++ b/emails/testsuite/store/test_store.py @@ -1,8 +1,10 @@ # encoding: utf-8 +from io import BytesIO + import pytest import emails import emails.store -from emails.store.file import fix_content_type +from emails.store.file import BaseFile, fix_content_type def test_fix_content_type(): @@ -47,6 +49,26 @@ def test_store_unique_name(): assert f2.content_id != f3.content_id +def test_get_data_str(): + f = BaseFile(data='hello') + assert f.data == 'hello' + + +def test_get_data_bytes(): + f = BaseFile(data=b'hello') + assert f.data == b'hello' + + +def test_get_data_filelike(): + f = BaseFile(data=BytesIO(b'hello')) + assert f.data == b'hello' + + +def test_get_data_none(): + f = BaseFile() + assert f.data is None + + def test_store_commons2(): store = emails.store.MemoryFileStore() f1 = store.add({'uri': '/a/c.gif'}) diff --git a/emails/utils.py b/emails/utils.py index 97b82ea..78f5a3b 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -10,14 +10,15 @@ from functools import wraps from io import StringIO, BytesIO from collections.abc import Callable -from typing import Any, TypeVar +from typing import Any, TypeVar, cast, overload import email.charset from email import generator 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 @@ -32,15 +33,19 @@ def to_native(x: str | bytes | None, charset: str = sys.getdefaultencoding(), return x.decode(charset, errors) -def to_unicode(x: Any, charset: str | None = sys.getdefaultencoding(), - errors: str = 'strict', - allow_none_charset: bool = False) -> str | None: +@overload +def to_unicode(x: None, charset: str = ..., errors: str = ...) -> None: ... +@overload +def to_unicode(x: str | bytes, charset: str = ..., errors: str = ...) -> str: ... +@overload +def to_unicode(x: Any, charset: str = ..., errors: str = ...) -> str | None: ... + +def to_unicode(x: Any, charset: str = sys.getdefaultencoding(), + errors: str = 'strict') -> str | None: if x is None: return None if not isinstance(x, bytes): return str(x) - if charset is None and allow_none_charset: - return x return x.decode(charset, errors) @@ -127,8 +132,15 @@ 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)]) + if isinstance(value, bytes): + value = value.decode(default, errors) + parts: list[str] = [] + for text, charset in decode_header_(value): + if isinstance(text, bytes): + parts.append(text.decode(charset or default, errors)) + else: + parts.append(text) + return "".join(parts) class MessageID: @@ -207,12 +219,12 @@ def parse_name_and_email(obj: str | tuple[str | None, str] | list[str], else: raise ValueError("Can not parse_name_and_email from %s" % obj) - return to_unicode(name, encoding) or None, to_unicode(email, encoding) or None + return name or None, email or None def sanitize_email(addr: str, encoding: str = 'ascii', parse: bool = False) -> str: if parse: - _, addr = parseaddr(to_unicode(addr)) + _, addr = parseaddr(addr) try: addr.encode('ascii') except UnicodeEncodeError: # IDN @@ -228,7 +240,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(addr) nm, addr = addr # This try-except clause is needed on Python 3 < 3.2.4 # http://bugs.python.org/issue14291 @@ -266,13 +278,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 +292,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 +311,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 = value.rstrip() _r = Header(value, charset) return str(_r) else: @@ -317,7 +329,7 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: else: return r - return wrapper # type: ignore[return-value] + return cast(F, wrapper) def format_date_header(v: datetime | float | None, localtime: bool = True) -> str: diff --git a/setup.cfg b/setup.cfg index 7aed5d8..b3840e7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,38 @@ +[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), conditional class definitions, +# and overrides smtplib.SMTP.sendmail with incompatible return type +[mypy-emails.backend.smtp.client] +disable_error_code = attr-defined, no-redef, override, no-any-return, assignment + +# Optional dependency stubs +[mypy-requests.*] +ignore_missing_imports = true + +# 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/setup.py b/setup.py index a1dac98..f73614b 100644 --- a/setup.py +++ b/setup.py @@ -126,6 +126,7 @@ def find_version(*file_paths): 'emails.packages', 'emails.packages.dkim' ], + package_data={'emails': ['py.typed']}, scripts=['scripts/make_rfc822.py'], python_requires='>=3.10', install_requires=['python-dateutil'], 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