diff --git a/0001-Implement-Unthreaded-Controller-256.patch b/0001-Implement-Unthreaded-Controller-256.patch new file mode 100644 index 0000000..78d3628 --- /dev/null +++ b/0001-Implement-Unthreaded-Controller-256.patch @@ -0,0 +1,2019 @@ +From 747a7c467d45354d8d1ea72bc9d2fce15e186479 Mon Sep 17 00:00:00 2001 +From: Pandu E POLUAN +Date: Tue, 9 Mar 2021 00:25:48 +0700 +Subject: [PATCH 1/4] Implement Unthreaded Controller (#256) + +* Complete rewrite of aiosmtpd.controller +* Implement Unthreaded Controllers +* Implement tests of Unthreaded Controllers +* Improve other tests +* Improve coverage by replacing nocover's with conditional pragmas +* Suppress exception ignored during __del__ +* Blackification +* Update badges +* Tidy up table + link to Public PGP on GH +* Bump version to 1.5.0a1 and update NEWS.rst +--- + DESCRIPTION.rst | 45 ++- + README.rst | 24 +- + aiosmtpd/__init__.py | 2 +- + aiosmtpd/controller.py | 296 ++++++++++++---- + aiosmtpd/docs/NEWS.rst | 12 + + aiosmtpd/docs/controller.rst | 642 +++++++++++++++++++++++----------- + aiosmtpd/docs/smtp.rst | 10 +- + aiosmtpd/handlers.py | 11 +- + aiosmtpd/proxy_protocol.py | 2 +- + aiosmtpd/tests/conftest.py | 15 + + aiosmtpd/tests/test_main.py | 27 +- + aiosmtpd/tests/test_server.py | 248 ++++++++++--- + pyproject.toml | 6 +- + 13 files changed, 958 insertions(+), 382 deletions(-) + +diff --git a/DESCRIPTION.rst b/DESCRIPTION.rst +index 9ec007b..caa9e7a 100644 +--- a/DESCRIPTION.rst ++++ b/DESCRIPTION.rst +@@ -2,16 +2,22 @@ + aiosmtpd - asyncio based SMTP server + ###################################### + +-| |github license| |_| |PyPI Version| |PyPI Python| +-| |GA badge| |codecov| |_| |LGTM.com| |readthedocs| |_| +-| |GH Release| |_| |PullRequests| |_| |LastCommit| ++| |github license| |_| |PyPI Version| |_| |PyPI Python| ++| |GA badge| |_| |codecov| |_| |LGTM.com| |_| |readthedocs| ++| |GH Release| |_| |GH PRs| |_| |GH LastCommit| + | + + .. |_| unicode:: 0xA0 + :trim: +-.. |github license| image:: https://img.shields.io/github/license/aio-libs/aiosmtpd ++.. |github license| image:: https://img.shields.io/github/license/aio-libs/aiosmtpd?logo=Open+Source+Initiative&logoColor=0F0 + :target: https://github.com/aio-libs/aiosmtpd/blob/master/LICENSE + :alt: Project License on GitHub ++.. |PyPI Version| image:: https://img.shields.io/pypi/v/aiosmtpd?logo=pypi&logoColor=yellow ++ :target: https://pypi.org/project/aiosmtpd/ ++ :alt: PyPI Package ++.. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd?logo=python&logoColor=yellow ++ :target: https://pypi.org/project/aiosmtpd/ ++ :alt: Supported Python Versions + .. .. For |GA badge|, don't forget to check actual workflow name in unit-testing-and-coverage.yml + .. |GA badge| image:: https://github.com/aio-libs/aiosmtpd/workflows/aiosmtpd%20CI/badge.svg + :target: https://github.com/aio-libs/aiosmtpd/actions +@@ -25,21 +31,15 @@ + .. |readthedocs| image:: https://img.shields.io/readthedocs/aiosmtpd?logo=Read+the+Docs + :target: https://aiosmtpd.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status +-.. |PyPI Version| image:: https://badge.fury.io/py/aiosmtpd.svg +- :target: https://badge.fury.io/py/aiosmtpd +- :alt: PyPI Package +-.. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd.svg +- :target: https://pypi.org/project/aiosmtpd/ +- :alt: Supported Python Versions + .. .. Do NOT include the Discourse badge! + .. .. Below are badges just for PyPI + .. |GH Release| image:: https://img.shields.io/github/v/release/aio-libs/aiosmtpd?logo=github + :target: https://github.com/aio-libs/aiosmtpd/releases + :alt: GitHub latest release +-.. |PullRequests| image:: https://img.shields.io/github/issues-pr/aio-libs/aiosmtpd?logo=GitHub ++.. |GH PRs| image:: https://img.shields.io/github/issues-pr/aio-libs/aiosmtpd?logo=GitHub + :target: https://github.com/aio-libs/aiosmtpd/pulls + :alt: GitHub pull requests +-.. |LastCommit| image:: https://img.shields.io/github/last-commit/aio-libs/aiosmtpd?logo=GitHub ++.. |GH LastCommit| image:: https://img.shields.io/github/last-commit/aio-libs/aiosmtpd?logo=GitHub + :target: https://github.com/aio-libs/aiosmtpd/commits/master + :alt: GitHub last commit + +@@ -61,10 +61,19 @@ Starting version 1.3.1, + files provided through PyPI or `GitHub Releases`_ + will be signed using one of the following GPG Keys: + +-+-------------------------+----------------+------------------------------+ +-| GPG Key ID | Owner | Email | +-+=========================+================+==============================+ +-| ``5D60 CE28 9CD7 C258`` | Pandu E POLUAN | pepoluan at gmail period com | +-+-------------------------+----------------+------------------------------+ +- + .. _`GitHub Releases`: https://github.com/aio-libs/aiosmtpd/releases ++ ++.. .. In the second column of the table, prefix each line with "| " ++ .. In the third column, refrain from putting in a direct link to keep the table tidy. ++ Rather, use the |...|_ construct and do the replacement+linking directive below the table ++ +++-------------------------+--------------------------------+-----------+ ++| GPG Key ID | Owner / Email | Key | +++=========================+================================+===========+ ++| ``5D60 CE28 9CD7 C258`` | | Pandu POLUAN / | |pep_gh|_ | ++| | | pepoluan at gmail period com | | +++-------------------------+--------------------------------+-----------+ ++ ++.. .. The |_| contruct is U+00A0 (non-breaking space), defined at the start of the file ++.. |pep_gh| replace:: On |_| GitHub ++.. _`pep_gh`: https://github.com/pepoluan.gpg +diff --git a/README.rst b/README.rst +index 35dcb88..2c1bab7 100644 +--- a/README.rst ++++ b/README.rst +@@ -2,14 +2,22 @@ + aiosmtpd - An asyncio based SMTP server + ========================================= + +-| |github license| |PyPI| |PyPI Python| +-| |GA badge| |codecov| |LGTM.com| |readthedocs| ++| |github license| |_| |PyPI Version| |_| |PyPI Python| ++| |GA badge| |_| |codecov| |_| |LGTM.com| |_| |readthedocs| + | + | |Discourse| + +-.. |github license| image:: https://img.shields.io/github/license/aio-libs/aiosmtpd ++.. |_| unicode:: 0xA0 ++ :trim: ++.. |github license| image:: https://img.shields.io/github/license/aio-libs/aiosmtpd?logo=Open+Source+Initiative&logoColor=0F0 + :target: https://github.com/aio-libs/aiosmtpd/blob/master/LICENSE + :alt: Project License on GitHub ++.. |PyPI Version| image:: https://img.shields.io/pypi/v/aiosmtpd?logo=pypi&logoColor=yellow ++ :target: https://pypi.org/project/aiosmtpd/ ++ :alt: PyPI Package ++.. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd?logo=python&logoColor=yellow ++ :target: https://pypi.org/project/aiosmtpd/ ++ :alt: Supported Python Versions + .. .. For |GA badge|, don't forget to check actual workflow name in unit-testing-and-coverage.yml + .. |GA badge| image:: https://github.com/aio-libs/aiosmtpd/workflows/aiosmtpd%20CI/badge.svg + :target: https://github.com/aio-libs/aiosmtpd/actions +@@ -20,15 +28,9 @@ + .. |LGTM.com| image:: https://img.shields.io/lgtm/grade/python/github/aio-libs/aiosmtpd.svg?logo=lgtm&logoWidth=18 + :target: https://lgtm.com/projects/g/aio-libs/aiosmtpd/context:python + :alt: Semmle/LGTM.com quality +-.. |readthedocs| image:: https://readthedocs.org/projects/aiosmtpd/badge/?version=latest +- :target: https://aiosmtpd.readthedocs.io/en/latest/?badge=latest ++.. |readthedocs| image:: https://img.shields.io/readthedocs/aiosmtpd?logo=Read+the+Docs&logoColor=white ++ :target: https://aiosmtpd.readthedocs.io/en/latest/ + :alt: Documentation Status +-.. |PyPI| image:: https://badge.fury.io/py/aiosmtpd.svg +- :target: https://badge.fury.io/py/aiosmtpd +- :alt: PyPI Package +-.. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd.svg +- :target: https://pypi.org/project/aiosmtpd/ +- :alt: Supported Python Versions + .. .. If you edit the above badges, don't forget to edit setup.cfg + .. .. The |Discourse| badge MUST NOT be included in setup.cfg + .. |Discourse| image:: https://img.shields.io/discourse/status?server=https%3A%2F%2Faio-libs.discourse.group%2F&style=social +diff --git a/aiosmtpd/__init__.py b/aiosmtpd/__init__.py +index 7d459d8..9c7b938 100644 +--- a/aiosmtpd/__init__.py ++++ b/aiosmtpd/__init__.py +@@ -1,4 +1,4 @@ + # Copyright 2014-2021 The aiosmtpd Developers + # SPDX-License-Identifier: Apache-2.0 + +-__version__ = "1.4.2" ++__version__ = "1.5.0a1" +diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py +index 2258c54..d3345b8 100644 +--- a/aiosmtpd/controller.py ++++ b/aiosmtpd/controller.py +@@ -5,6 +5,7 @@ import asyncio + import errno + import os + import ssl ++import sys + import threading + import time + from abc import ABCMeta, abstractmethod +@@ -19,6 +20,11 @@ try: + except ImportError: # pragma: on-not-win32 + AF_UNIX = None + from typing import Any, Coroutine, Dict, Optional, Union ++ ++if sys.version_info >= (3, 8): ++ from typing import Literal # pragma: py-lt-38 ++else: # pragma: py-ge-38 ++ from typing_extensions import Literal + from warnings import warn + + from public import public +@@ -38,13 +44,14 @@ class IP6_IS: + YES = {errno.EADDRINUSE} + + +-def _has_ipv6(): ++def _has_ipv6() -> bool: + # Helper function to assist in mocking + return has_ipv6 + + + @public +-def get_localhost() -> str: ++def get_localhost() -> Literal["::1", "127.0.0.1"]: ++ """Returns numeric address to localhost depending on IPv6 availability""" + # Ref: + # - https://github.com/urllib3/urllib3/pull/611#issuecomment-100954017 + # - https://github.com/python/cpython/blob/ : +@@ -91,24 +98,17 @@ class _FakeServer(asyncio.StreamReaderProtocol): + + + @public +-class BaseThreadedController(metaclass=ABCMeta): +- """ +- `Documentation can be found here +- `_. +- """ ++class BaseController(metaclass=ABCMeta): ++ smtpd = None + server: Optional[AsyncServer] = None + server_coro: Optional[Coroutine] = None +- smtpd = None +- _factory_invoked: Optional[threading.Event] = None +- _thread: Optional[threading.Thread] = None +- _thread_exception: Optional[Exception] = None ++ _factory_invoked: threading.Event = None + + def __init__( + self, +- handler, +- loop=None, ++ handler: Any, ++ loop: asyncio.AbstractEventLoop = None, + *, +- ready_timeout: float, + ssl_context: Optional[ssl.SSLContext] = None, + # SMTP parameters + server_hostname: Optional[str] = None, +@@ -119,9 +119,6 @@ class BaseThreadedController(metaclass=ABCMeta): + self.loop = asyncio.new_event_loop() + else: + self.loop = loop +- self.ready_timeout = float( +- os.getenv("AIOSMTPD_CONTROLLER_TIMEOUT", ready_timeout) +- ) + self.ssl_context = ssl_context + self.SMTP_kwargs: Dict[str, Any] = {} + if "server_kwargs" in SMTP_parameters: +@@ -139,9 +136,11 @@ class BaseThreadedController(metaclass=ABCMeta): + # It actually conflicts with SMTP class's default, but the reasoning is + # discussed in the docs. + self.SMTP_kwargs.setdefault("enable_SMTPUTF8", True) ++ # ++ self._factory_invoked = threading.Event() + + def factory(self): +- """Allow subclasses to customize the handler/server creation.""" ++ """Subclasses can override this to customize the handler/server creation.""" + return SMTP(self.handler, **self.SMTP_kwargs) + + def _factory_invoker(self): +@@ -159,13 +158,72 @@ class BaseThreadedController(metaclass=ABCMeta): + + @abstractmethod + def _create_server(self) -> Coroutine: +- raise NotImplementedError # pragma: nocover ++ """ ++ Overridden by subclasses to actually perform the async binding to the ++ listener endpoint. When overridden, MUST refer the _factory_invoker() method. ++ """ ++ raise NotImplementedError ++ ++ def _cleanup(self): ++ """Reset internal variables to prevent contamination""" ++ self._thread_exception = None ++ self._factory_invoked.clear() ++ self.server_coro = None ++ self.server = None ++ self.smtpd = None ++ ++ def cancel_tasks(self, stop_loop: bool = True): ++ """ ++ Convenience method to stop the loop and cancel all tasks. ++ Use loop.call_soon_threadsafe() to invoke this. ++ """ ++ if stop_loop: # pragma: nobranch ++ self.loop.stop() ++ try: ++ _all_tasks = asyncio.all_tasks # pytype: disable=module-attr ++ except AttributeError: # pragma: py-gt-36 ++ _all_tasks = asyncio.Task.all_tasks ++ for task in _all_tasks(self.loop): ++ # This needs to be invoked in a thread-safe way ++ task.cancel() ++ ++ ++@public ++class BaseThreadedController(BaseController, metaclass=ABCMeta): ++ _thread: Optional[threading.Thread] = None ++ _thread_exception: Optional[Exception] = None ++ ++ def __init__( ++ self, ++ handler: Any, ++ loop: asyncio.AbstractEventLoop = None, ++ *, ++ ready_timeout: float = DEFAULT_READY_TIMEOUT, ++ ssl_context: Optional[ssl.SSLContext] = None, ++ # SMTP parameters ++ server_hostname: Optional[str] = None, ++ **SMTP_parameters, ++ ): ++ super().__init__( ++ handler, ++ loop, ++ ssl_context=ssl_context, ++ server_hostname=server_hostname, ++ **SMTP_parameters, ++ ) ++ self.ready_timeout = float( ++ os.getenv("AIOSMTPD_CONTROLLER_TIMEOUT", ready_timeout) ++ ) + + @abstractmethod + def _trigger_server(self): +- raise NotImplementedError # pragma: nocover ++ """ ++ Overridden by subclasses to trigger asyncio to actually initialize the SMTP ++ class (it's lazy initialization, done only on initial connection). ++ """ ++ raise NotImplementedError + +- def _run(self, ready_event): ++ def _run(self, ready_event: threading.Event): + asyncio.set_event_loop(self.loop) + try: + # Need to do two-step assignments here to ensure IDEs can properly +@@ -187,14 +245,19 @@ class BaseThreadedController(metaclass=ABCMeta): + return + self.loop.call_soon(ready_event.set) + self.loop.run_forever() ++ # We reach this point when loop is ended (by external code) ++ # Perform some stoppages to ensure endpoint no longer bound. + self.server.close() + self.loop.run_until_complete(self.server.wait_closed()) + self.loop.close() + self.server = None + + def start(self): ++ """ ++ Start a thread and run the asyncio event loop in that thread ++ """ + assert self._thread is None, "SMTP daemon already running" +- self._factory_invoked = threading.Event() ++ self._factory_invoked.clear() + + ready_event = threading.Event() + self._thread = threading.Thread(target=self._run, args=(ready_event,)) +@@ -240,43 +303,26 @@ class BaseThreadedController(metaclass=ABCMeta): + if self.smtpd is None: + raise RuntimeError("Unknown Error, failed to init SMTP server") + +- def _stop(self): +- self.loop.stop() +- try: +- _all_tasks = asyncio.all_tasks # pytype: disable=module-attr +- except AttributeError: # pragma: py-gt-36 +- _all_tasks = asyncio.Task.all_tasks +- for task in _all_tasks(self.loop): +- task.cancel() +- +- def stop(self, no_assert=False): ++ def stop(self, no_assert: bool = False): ++ """ ++ Stop the loop, the tasks in the loop, and terminate the thread as well. ++ """ + assert no_assert or self._thread is not None, "SMTP daemon not running" +- self.loop.call_soon_threadsafe(self._stop) ++ self.loop.call_soon_threadsafe(self.cancel_tasks) + if self._thread is not None: + self._thread.join() + self._thread = None +- self._thread_exception = None +- self._factory_invoked = None +- self.server_coro = None +- self.server = None +- self.smtpd = None ++ self._cleanup() + + + @public +-class Controller(BaseThreadedController): +- """ +- `Documentation can be found here +- `_. +- """ ++class BaseUnthreadedController(BaseController, metaclass=ABCMeta): + def __init__( + self, +- handler, +- hostname: Optional[str] = None, +- port: int = 8025, +- loop=None, ++ handler: Any, ++ loop: asyncio.AbstractEventLoop = None, + *, +- ready_timeout: float = DEFAULT_READY_TIMEOUT, +- ssl_context: ssl.SSLContext = None, ++ ssl_context: Optional[ssl.SSLContext] = None, + # SMTP parameters + server_hostname: Optional[str] = None, + **SMTP_parameters, +@@ -284,15 +330,80 @@ class Controller(BaseThreadedController): + super().__init__( + handler, + loop, +- ready_timeout=ready_timeout, ++ ssl_context=ssl_context, + server_hostname=server_hostname, +- **SMTP_parameters ++ **SMTP_parameters, + ) +- self.hostname = get_localhost() if hostname is None else hostname ++ self.ended = threading.Event() ++ ++ def begin(self): ++ """ ++ Sets up the asyncio server task and inject it into the asyncio event loop. ++ Does NOT actually start the event loop itself. ++ """ ++ asyncio.set_event_loop(self.loop) ++ # Need to do two-step assignments here to ensure IDEs can properly ++ # detect the types of the vars. Cannot use `assert isinstance`, because ++ # Python 3.6 in asyncio debug mode has a bug wherein CoroWrapper is not ++ # an instance of Coroutine ++ self.server_coro = self._create_server() ++ srv: AsyncServer = self.loop.run_until_complete(self.server_coro) ++ self.server = srv ++ ++ async def finalize(self): ++ """ ++ Perform orderly closing of the server listener. ++ NOTE: This is an async method; await this from an async or use ++ loop.create_task() (if loop is still running), or ++ loop.run_until_complete() (if loop has stopped) ++ """ ++ self.ended.clear() ++ server = self.server ++ server.close() ++ await server.wait_closed() ++ self.server_coro.close() ++ self._cleanup() ++ self.ended.set() ++ ++ def end(self): ++ """ ++ Convenience method to asynchronously invoke finalize(). ++ Consider using loop.call_soon_threadsafe to invoke this method, especially ++ if your loop is running in a different thread. You can afterwards .wait() on ++ ended attribute (a threading.Event) to check for completion, if needed. ++ """ ++ self.ended.clear() ++ if self.loop.is_running(): ++ self.loop.create_task(self.finalize()) ++ else: ++ self.loop.run_until_complete(self.finalize()) ++ ++ ++@public ++class InetMixin(BaseController, metaclass=ABCMeta): ++ def __init__( ++ self, ++ handler: Any, ++ hostname: Optional[str] = None, ++ port: int = 8025, ++ loop: asyncio.AbstractEventLoop = None, ++ **kwargs, ++ ): ++ super().__init__( ++ handler, ++ loop, ++ **kwargs, ++ ) ++ self._localhost = get_localhost() ++ self.hostname = self._localhost if hostname is None else hostname + self.port = port +- self.ssl_context = ssl_context + + def _create_server(self) -> Coroutine: ++ """ ++ Creates a 'server task' that listens on an INET host:port. ++ Does NOT actually start the protocol object itself; ++ _factory_invoker() is only called upon fist connection attempt. ++ """ + return self.loop.create_server( + self._factory_invoker, + host=self.hostname, +@@ -308,42 +419,36 @@ class Controller(BaseThreadedController): + """ + # At this point, if self.hostname is Falsy, it most likely is "" (bind to all + # addresses). In such case, it should be safe to connect to localhost) +- hostname = self.hostname or get_localhost() ++ hostname = self.hostname or self._localhost + with ExitStack() as stk: + s = stk.enter_context(create_connection((hostname, self.port), 1.0)) + if self.ssl_context: + s = stk.enter_context(self.ssl_context.wrap_socket(s)) +- _ = s.recv(1024) ++ s.recv(1024) + + +-class UnixSocketController(BaseThreadedController): # pragma: on-win32 on-cygwin +- """ +- `Documentation can be found here +- `_. +- """ ++@public ++class UnixSocketMixin(BaseController, metaclass=ABCMeta): # pragma: no-unixsock + def __init__( + self, +- handler, +- unix_socket: Optional[Union[str, Path]], +- loop=None, +- *, +- ready_timeout: float = DEFAULT_READY_TIMEOUT, +- ssl_context: ssl.SSLContext = None, +- # SMTP parameters +- server_hostname: str = None, +- **SMTP_parameters, ++ handler: Any, ++ unix_socket: Union[str, Path], ++ loop: asyncio.AbstractEventLoop = None, ++ **kwargs, + ): + super().__init__( + handler, + loop, +- ready_timeout=ready_timeout, +- ssl_context=ssl_context, +- server_hostname=server_hostname, +- **SMTP_parameters ++ **kwargs, + ) + self.unix_socket = str(unix_socket) + + def _create_server(self) -> Coroutine: ++ """ ++ Creates a 'server task' that listens on a Unix Socket file. ++ Does NOT actually start the protocol object itself; ++ _factory_invoker() is only called upon fist connection attempt. ++ """ + return self.loop.create_unix_server( + self._factory_invoker, + path=self.unix_socket, +@@ -351,9 +456,52 @@ class UnixSocketController(BaseThreadedController): # pragma: on-win32 on-cygwi + ) + + def _trigger_server(self): ++ """ ++ Opens a socket connection to the newly launched server, wrapping in an SSL ++ Context if necessary, and read some data from it to ensure that factory() ++ gets invoked. ++ """ + with ExitStack() as stk: + s: makesock = stk.enter_context(makesock(AF_UNIX, SOCK_STREAM)) + s.connect(self.unix_socket) + if self.ssl_context: + s = stk.enter_context(self.ssl_context.wrap_socket(s)) +- _ = s.recv(1024) ++ s.recv(1024) ++ ++ ++@public ++class Controller(InetMixin, BaseThreadedController): ++ """Provides a multithreaded controller that listens on an INET endpoint""" ++ ++ def _trigger_server(self): ++ # Prevent confusion on which _trigger_server() to invoke. ++ # Or so LGTM.com claimed ++ InetMixin._trigger_server(self) ++ ++ ++@public ++class UnixSocketController( # pragma: no-unixsock ++ UnixSocketMixin, BaseThreadedController ++): ++ """Provides a multithreaded controller that listens on a Unix Socket file""" ++ ++ def _trigger_server(self): # pragma: no-unixsock ++ # Prevent confusion on which _trigger_server() to invoke. ++ # Or so LGTM.com claimed ++ UnixSocketMixin._trigger_server(self) ++ ++ ++@public ++class UnthreadedController(InetMixin, BaseUnthreadedController): ++ """Provides an unthreaded controller that listens on an INET endpoint""" ++ ++ pass ++ ++ ++@public ++class UnixSocketUnthreadedController( # pragma: no-unixsock ++ UnixSocketMixin, BaseUnthreadedController ++): ++ """Provides an unthreaded controller that listens on a Unix Socket file""" ++ ++ pass +diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst +index fb32de4..ce627a7 100644 +--- a/aiosmtpd/docs/NEWS.rst ++++ b/aiosmtpd/docs/NEWS.rst +@@ -3,6 +3,18 @@ + ################### + + ++1.5.0 (aiosmtpd-next-next) ++========================== ++ ++Added ++----- ++* Unthreaded Controllers (Closes #160) ++ ++Fixed/Improved ++-------------- ++* All Controllers now have more rationale design, as they are now composited from a Base + a Mixin ++ ++ + 1.4.2 (2021-03-08) + ===================== + +diff --git a/aiosmtpd/docs/controller.rst b/aiosmtpd/docs/controller.rst +index d3e08ed..e43720b 100644 +--- a/aiosmtpd/docs/controller.rst ++++ b/aiosmtpd/docs/controller.rst +@@ -5,15 +5,15 @@ + ==================== + + If you already have an `asyncio event loop`_, you can `create a server`_ using +-the ``SMTP`` class as the *protocol factory*, and then run the loop forever. ++the :class:`~aiosmtpd.smtp.SMTP` class as the *protocol factory*, and then run the loop forever. + If you need to pass arguments to the ``SMTP`` constructor, use + :func:`functools.partial` or write your own wrapper function. You might also + want to add a signal handler so that the loop can be stopped, say when you hit + control-C. + +-It's probably easier to use a *controller* which runs the SMTP server in a ++It's probably easier to use a *threaded controller* which runs the SMTP server in a + separate thread with a dedicated event loop. The controller provides useful +-and reliable *start* and *stop* semantics so that the foreground thread ++and reliable ``start`` and ``stop`` semantics so that the foreground thread + doesn't block. Among other use cases, this makes it convenient to spin up an + SMTP server for unit tests. + +@@ -30,7 +30,7 @@ Using the controller + TCP-based Server + ---------------- + +-The :class:`Controller` class creates a TCP-based server, ++The :class:`~aiosmtpd.controller.Controller` class creates a TCP-based server, + listening on an Internet endpoint (i.e., ``ip_address:port`` pair). + + Say you want to receive email for ``example.com`` and print incoming mail data +@@ -100,11 +100,11 @@ Connect to the server and send a message, which then gets printed by + End of message + + You'll notice that at the end of the ``DATA`` command, your handler's +-``handle_DATA()`` method was called. The sender, recipients, and message ++:meth:`handle_DATA` method was called. The sender, recipients, and message + contents were taken from the envelope, and printed at the console. The + handler methods also returns a successful status message. + +-The ``ExampleHandler`` class also implements a ``handle_RCPT()`` method. This ++The ``ExampleHandler`` class also implements a :meth:`handle_RCPT` method. This + gets called after the ``RCPT TO`` command is sanity checked. The method + ensures that all recipients are local to the ``@example.com`` domain, + returning an error status if not. It is the handler's responsibility to add +@@ -148,10 +148,11 @@ use to do some common tasks, and it's easy to write your own handler. For a + full overview of the methods that handler classes may implement, see the + section on :ref:`handler hooks `. + ++ + Unix Socket-based Server + ------------------------ + +-The :class:`UnixSocketController` class creates a server listening to ++The :class:`~aiosmtpd.controller.UnixSocketController` class creates a server listening to + a Unix Socket (i.e., a special file that can act as a 'pipe' for interprocess + communication). + +@@ -168,8 +169,13 @@ with some differences: + >>> controller = UnixSocketController(Sink(), unix_socket="smtp_socket~") + >>> controller.start() + ++.. warning:: ++ ++ Do not exceed the Operating System limit for the length of the socket file path. ++ On Linux, the limit is 108 characters. On BSD OSes, it's 104 characters. ++ + **Rather than connecting to IP:port, you connect to the Socket file.** +-Python's :class:`smtplib.SMTP` sadly cannot connect to a Unix Socket, ++Python's :class:`smtplib.SMTP` class sadly cannot connect to a Unix Socket, + so we need to handle it on our own here: + + .. doctest:: unix_socket +@@ -178,9 +184,8 @@ so we need to handle it on our own here: + >>> import socket + >>> sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + >>> sock.connect("smtp_socket~") +- >>> resp = sock.recv(1024) +- >>> resp[0:4] +- b'220 ' ++ >>> sock.recv(1024) ++ b'220 ...' + + Try sending something, don't forget to end with ``"\r\n"``: + +@@ -189,9 +194,8 @@ Try sending something, don't forget to end with ``"\r\n"``: + + >>> sock.send(b"HELO example.org\r\n") + 18 +- >>> resp = sock.recv(1024) +- >>> resp[0:4] +- b'250 ' ++ >>> sock.recv(1024) ++ b'250 ...' + + And close everything when done: + +@@ -200,13 +204,116 @@ And close everything when done: + + >>> sock.send(b"QUIT\r\n") + 6 +- >>> resp = sock.recv(1024) +- >>> resp[0:4] +- b'221 ' ++ >>> sock.recv(1024) ++ b'221 Bye...' + >>> sock.close() + >>> controller.stop() + + ++.. _unthreaded: ++ ++Unthreaded Controllers ++---------------------- ++ ++In addition to the **threaded** controllers described above, ++``aiosmtpd`` also provides the following **UNthreaded** controllers: ++ ++* :class:`UnthreadedController` -- the unthreaded version of :class:`Controller` ++* :class:`UnixSocketUnthreadedController` -- the unthreaded version of :class:`UnixSocketController` ++ ++These classes are considered *advanced* classes, ++because you'll have to manage the event loop yourself. ++ ++For example, to start an unthreaded controller, ++you'll have to do something similar to this: ++ ++.. doctest:: unthreaded ++ ++ >>> import asyncio ++ >>> loop = asyncio.get_event_loop() ++ >>> from aiosmtpd.controller import UnthreadedController ++ >>> from aiosmtpd.handlers import Sink ++ >>> controller = UnthreadedController(Sink(), loop=loop) ++ >>> controller.begin() ++ ++Note that unlike the threaded counterparts, ++the method used to start the controller is named ``begin()``. ++And unlike the method in the threaded version, ++``begin()`` does NOT start the asyncio event loop; ++you'll have to start it yourself. ++ ++For the purposes of trying this, ++let's create a thread and have it run the asyncio event loop; ++we'll also schedule an autostop so it won't hang: ++ ++.. doctest:: unthreaded ++ ++ >>> def runner(): ++ ... # Set the delay to something long enough so you have time ++ ... # to do some testing ++ ... loop.call_later(3.0, loop.stop) ++ ... loop.run_forever() ++ >>> import threading ++ >>> thread = threading.Thread(target=runner) ++ >>> thread.setDaemon(True) ++ >>> thread.start() ++ >>> import time ++ >>> time.sleep(0.1) # Allow the loop to begin ++ ++At this point in time, the server would be listening: ++ ++.. doctest:: unthreaded ++ ++ >>> from smtplib import SMTP as Client ++ >>> client = Client(controller.hostname, controller.port) ++ >>> client.helo("example.com") ++ (250, ...) ++ >>> client.quit() ++ (221, b'Bye') ++ ++The complex thing will be to end it; ++that is why we're marking these classes as "advanced". ++ ++For our example here, ++since we have created an "autostop loop", ++all we have to do is wait for the runner thread to end: ++ ++.. doctest:: unthreaded ++ ++ >>> thread.join() ++ >>> loop.is_running() ++ False ++ ++We still need to do some cleanup to fully release the bound port. ++Since the loop has ended, we can simply call the :meth:`end` method: ++ ++.. doctest:: unthreaded ++ ++ >>> controller.end() ++ ++If you want to end the controller *but* keep the loop running, ++you'll have to do it like this:: ++ ++ loop.call_soon_threadsafe(controller.end) ++ # If you want to ensure that controller has stopped, you can wait() here: ++ controller.ended.wait(10.0) # Optional ++ ++You must remember to cleanup the canceled tasks yourself. ++We have provided a convenience method, ++:meth:`~aiosmtpd.controller.BaseController.cancel_tasks`:: ++ ++ # Will also stop the loop! ++ loop.call_soon_threadsafe(controller.cancel_tasks) ++ ++(If you invoke ``cancel_tasks`` with the parameter ``stop_loop=False``, ++then loop will NOT be stopped. ++That is a much too-advanced topic and we will not discuss it further in this documentation.) ++ ++The Unix Socket variant, ``UnixSocketUnthreadedController``, works in the same way. ++The difference is only in how to access the server, i.e., through a Unix Socket instead of TCP/IP. ++We'll leave out the details for you to figure it out yourself. ++ ++ + .. _enablesmtputf8: + + Enabling SMTPUTF8 +@@ -253,265 +360,398 @@ Controller API + + .. py:module:: aiosmtpd.controller + +-.. class:: IP6_IS + +- .. py:attribute:: NO +- :type: set ++.. py:data:: DEFAULT_READY_TIMEOUT ++ :type: float ++ :value: 5.0 ++ ++ ++.. py:function:: get_localhost() + +- Contains constants from :mod:`errno` that will be raised by `socket.bind()` +- if IPv6 is not available on the system. ++ :return: The numeric address of the loopback interface; ``"::1"`` if IPv6 is supported, ++ ``"127.0.0.1"`` if IPv6 is not supported. ++ :rtype: Literal["::1", "127.0.0.1"] ++ ++ ++.. class:: IP6_IS + +- .. important:: ++ .. py:attribute:: NO ++ :type: set[int] + +- If your system does not have IPv6 support but :func:`get_localhost` +- raises an error instead of returning ``"127.0.0.1"``, +- you can add the error number into this attribute. ++ Contains constants from :mod:`errno` that will be raised by :meth:`socket.socket.bind` ++ if IPv6 is NOT available on the system. + + .. py:attribute:: YES +- :type: set ++ :type: set[int] + +- Contains constants from :mod:`errno` that will be raised by `socket.bind()` +- if IPv6 is not available on the system. ++ Contains constants from :mod:`errno` that will be raised by :meth:`socket.socket.bind` ++ if IPv6 IS available on the system. + +-.. py:function:: get_localhost ++ .. note:: + +- :return: The numeric address of the loopback interface; ``"::1"`` if IPv6 is supported, +- ``"127.0.0.1"`` if IPv6 is not supported. +- :rtype: str ++ You can customize the contents of these attributes by adding/removing from them, ++ in case the behavior does not align with your expectations *and* ++ you cannot wait for a patch to be merged. + +-.. class:: BaseThreadedController(\ +- handler, \ +- loop=None, \ +- *, \ +- ready_timeout, \ +- ssl_context=None, \ +- server_hostname=None, server_kwargs=None, **SMTP_parameters) + +- :param handler: Handler object +- :param loop: The asyncio event loop in which the server will run. +- If not given, :func:`asyncio.new_event_loop` will be called to create the event loop. +- :param ready_timeout: How long to wait until server starts. +- The :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` takes precedence over this parameter. +- See :attr:`ready_timeout` for more information. +- :type ready_timeout: float +- :param ssl_context: SSL Context to wrap the socket in. +- Will be passed-through to :meth:`~asyncio.loop.create_server` method +- :type ssl_context: ssl.SSLContext +- :param server_hostname: Server's hostname, +- will be passed-through as ``hostname`` parameter of :class:`~aiosmtpd.smtp.SMTP` +- :type server_hostname: Optional[str] +- :param server_kwargs: (DEPRECATED) A dict that +- will be passed-through as keyword arguments of :class:`~aiosmtpd.smtp.SMTP`. +- Explicitly listed keyword arguments going into ``**SMTP_parameters`` +- will take precedence over this parameter +- :type server_kwargs: Dict[str, Any] +- :param SMTP_parameters: Optional keyword arguments that +- will be passed-through as keyword arguments of :class:`~aiosmtpd.smtp.SMTP` ++.. class:: BaseController(\ ++ handler, \ ++ loop=None, \ ++ *, \ ++ ssl_context=None, \ ++ server_hostname=None, \ ++ server_kwargs=None, \ ++ **SMTP_parameters, \ ++ ) + +- .. important:: ++ This **Abstract Base Class** defines parameters, attributes, and methods common between ++ all concrete controller classes. + +- Usually, setting the ``ssl_context`` parameter will switch the protocol to ``SMTPS`` mode, +- implying unconditional encryption of the connection, +- and preventing the use of the ``STARTTLS`` mechanism. ++ :param handler: Handler object ++ :param loop: The asyncio event loop in which the server will run. ++ If not given, :func:`asyncio.new_event_loop` will be called to create the event loop. ++ :type loop: asyncio.AbstractEventLoop ++ :param ssl_context: SSL Context to wrap the socket in. ++ Will be passed-through to :meth:`~asyncio.loop.create_server` method ++ :type ssl_context: ssl.SSLContext ++ :param server_hostname: Server's hostname, ++ will be passed-through as ``hostname`` parameter of :class:`~aiosmtpd.smtp.SMTP` ++ :type server_hostname: Optional[str] ++ :param server_kwargs: *(DEPRECATED)* A dict that will be passed-through as keyword ++ arguments of :class:`~aiosmtpd.smtp.SMTP`. ++ This is DEPRECATED; please use ``**SMTP_parameters`` instead. ++ :type server_kwargs: dict ++ :param SMTP_parameters: Optional keyword arguments that ++ will be passed-through as keyword arguments of :class:`~aiosmtpd.smtp.SMTP` + +- Actual behavior depends on the subclass's implementation. ++ | ++ | :part:`Attributes` + +- | +- | :part:`Attributes` ++ .. attribute:: handler ++ :noindex: + +- .. attribute:: handler +- :noindex: ++ The instance of the event *handler* passed to the constructor. + +- The instance of the event *handler* passed to the constructor. ++ .. attribute:: loop ++ :noindex: + +- .. attribute:: loop +- :noindex: ++ The event loop being used. + +- The event loop being used. ++ .. attribute:: server + +- .. attribute:: ready_timeout +- :type: float ++ This is the server instance returned by ++ :meth:`_create_server` after the server has started. + +- The timeout value used to wait for the server to start. ++ You can retrieve the :class:`~socket.socket` objects the server is listening on ++ from the ``server.sockets`` attribute. + +- This will either be the value of +- the :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` environment variable (converted to float), +- or the :attr:`ready_timeout` parameter. ++ .. py:attribute:: smtpd ++ :type: aiosmtpd.smtp.SMTP + +- Setting this to a high value will NOT slow down controller startup, +- because it's a timeout limit rather than a sleep delay. +- However, you may want to reduce the default value to something 'just enough' +- so you don't have to wait too long for an exception, if problem arises. ++ The server instance (of class SMTP) created by :meth:`factory` after ++ the controller is started. + +- If this timeout is breached, a :class:`TimeoutError` exception will be raised. ++ | ++ | :part:`Methods` + +- .. attribute:: server ++ .. method:: factory() -> aiosmtpd.smtp.SMTP + +- This is the server instance returned by +- :meth:`_create_server` after the server has started. ++ You can override this method to create custom instances of ++ the :class:`~aiosmtpd.smtp.SMTP` class being controlled. + +- .. py:attribute:: smtpd +- :type: aiosmtpd.smtp.SMTP ++ By default, this creates an ``SMTP`` instance, ++ passing in your handler and setting flags from the :attr:`**SMTP_Parameters` parameter. + +- The server instance (of class SMTP) created by :meth:`factory` after +- the controller is started. ++ Examples of why you would want to override this method include ++ creating an :ref:`LMTP ` server instance instead of the standard ``SMTP`` server. + +- | +- | :part:`Methods` ++ .. py:method:: cancel_tasks(stop_loop=True) + +- .. py:method:: _create_server() -> Coroutine +- :abstractmethod: ++ :param stop_loop: If ``True``, stops the loop before canceling tasks. ++ :type stop_loop: bool + +- This method will be called by :meth:`_run` during :meth:`start` procedure. ++ This is a convenience class that will stop the loop & ++ cancel all asyncio tasks for you. + +- It must return a ``Coroutine`` object which will be executed by the asyncio event loop. + +- .. py:method:: _trigger_server() -> None +- :abstractmethod: ++.. class:: Controller(\ ++ handler, \ ++ hostname=None, \ ++ port=8025, \ ++ loop=None, \ ++ *, \ ++ ready_timeout=DEFAULT_READY_TIMEOUT, \ ++ ssl_context=None, \ ++ server_hostname=None, \ ++ server_kwargs=None, \ ++ **SMTP_parameters) + +- The :meth:`asyncio.loop.create_server` method (or its parallel) +- invokes :meth:`factory` "lazily", +- so exceptions in :meth:`factory` can go undetected during :meth:`start`. ++ A concrete subclass of :class:`BaseController` that provides ++ a threaded, INET listener. + +- This method will create a connection to the started server and 'exchange' some traffic, +- thus triggering :meth:`factory` invocation, +- allowing the Controller to catch exceptions during initialization. ++ :param hostname: Will be given to the event loop's :meth:`~asyncio.loop.create_server` method ++ as the ``host`` parameter, with a slight processing (see below) ++ :type hostname: Optional[str] ++ :param port: Will be passed-through to :meth:`~asyncio.loop.create_server` method ++ :type port: int ++ :param ready_timeout: How long to wait until server starts. ++ The :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` takes precedence over this parameter. ++ See :attr:`ready_timeout` for more information. ++ :type ready_timeout: float + +- .. method:: start() -> None ++ Other parameters are defined in the :class:`BaseController` class. + +- :raises TimeoutError: if the server takes too long to get ready, +- exceeding the ``ready_timeout`` parameter. +- :raises RuntimeError: if an unrecognized & unhandled error happened, +- resulting in non-creation of a server object +- (:attr:`smtpd` remains ``None``) ++ The ``hostname`` parameter will be passed to the event loop's ++ :meth:`~asyncio.loop.create_server` method as the ``host`` parameter, ++ :boldital:`except` ``None`` (default) will be translated to ``::1``. + +- Start the server in the subthread. +- The subthread is always a :class:`daemon thread ` +- (i.e., we always set ``thread.daemon=True``). ++ * To bind `dual-stack`_ locally, use ``localhost``. ++ * To bind `dual-stack`_ on all interfaces, use ``""`` (empty string). + +- Exceptions can be raised +- if the server does not start within :attr:`ready_timeout` seconds, +- or if any other exception occurs in :meth:`factory` while creating the server. ++ .. important:: + +- .. important:: ++ The ``hostname`` parameter does NOT get passed through to the SMTP instance; ++ if you want to give the SMTP instance a custom hostname ++ (e.g., for use in HELO/EHLO greeting), ++ you must pass it through the :attr:`server_hostname` parameter. + +- If :meth:`start` raises an Exception, +- cleanup is not performed automatically, +- to support deep inspection post-exception (if you wish to do so.) +- Cleanup must still be performed manually by calling :meth:`stop` ++ Explicitly defined SMTP keyword arguments will override keyword arguments of the ++ same names defined in the (deprecated) ``server_kwargs`` argument. + +- For example:: ++ .. doctest:: controller_kwargs + +- # Assume SomeController is a concrete subclass of BaseThreadedController +- controller = SomeController(handler) +- try: +- controller.start() +- except ...: +- ... exception handling and/or inspection ... +- finally: +- controller.stop() ++ >>> from aiosmtpd.controller import Controller ++ >>> from aiosmtpd.handlers import Sink ++ >>> controller = Controller( ++ ... Sink(), timeout=200, server_kwargs=dict(timeout=400) ++ ... ) ++ >>> controller.SMTP_kwargs["timeout"] ++ 200 + +- .. method:: stop() -> None ++ Finally, setting the ``ssl_context`` parameter will switch the protocol to ``SMTPS`` mode, ++ implying unconditional encryption of the connection, ++ and preventing the use of the ``STARTTLS`` mechanism. + +- :raises AssertionError: if :meth:`stop` is called before :meth:`start` is called successfully ++ Actual behavior depends on the subclass's implementation. + +- Stop the server and the event loop, and cancel all tasks. ++ | ++ | :part:`Attributes` + +- .. method:: factory() -> aiosmtpd.smtp.SMTP ++ In addition to those provided by :class:`BaseController`, ++ this class provides the following: + +- You can override this method to create custom instances of the ``SMTP`` +- class being controlled. ++ .. attribute:: hostname: str ++ port: int + +- By default, this creates an ``SMTP`` instance, +- passing in your handler and setting flags from the :attr:`**SMTP_Parameters` parameter. ++ The values of the *hostname* and *port* arguments. + +- Examples of why you would want to override this method include +- creating an :ref:`LMTP ` server instance instead of the standard ``SMTP`` server. ++ .. attribute:: ready_timeout ++ :type: float + ++ The timeout value used to wait for the server to start. + ++ This will either be the value of ++ the :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` environment variable (converted to float), ++ or the :attr:`ready_timeout` parameter. + +-.. class:: Controller(\ +- handler, \ +- hostname=None, port=8025, \ +- loop=None, \ +- *, \ +- ready_timeout=3.0, \ +- ssl_context=None, \ +- server_hostname=None, server_kwargs=None, **SMTP_parameters) +- +- :param hostname: Will be given to the event loop's :meth:`~asyncio.loop.create_server` method +- as the ``host`` parameter, with a slight processing (see below) +- :type hostname: Optional[str] +- :param port: Will be passed-through to :meth:`~asyncio.loop.create_server` method +- :type port: int ++ Setting this to a high value will NOT slow down controller startup, ++ because it's a timeout limit rather than a sleep delay. ++ However, you may want to reduce the default value to something 'just enough' ++ so you don't have to wait too long for an exception, if problem arises. + +- .. note:: ++ If this timeout is breached, a :class:`TimeoutError` exception will be raised. ++ ++ | ++ | :part:`Methods` + +- The ``hostname`` parameter will be passed to the event loop's +- :meth:`~asyncio.loop.create_server` method as the ``host`` parameter, +- :boldital:`except` ``None`` (default) will be translated to ``::1``. ++ In addition to those provided by :class:`BaseController`, ++ this class provides the following: + +- * To bind `dual-stack`_ locally, use ``localhost``. ++ .. method:: start() -> None + +- * To bind `dual-stack`_ on all interfaces, use ``""`` (empty string). ++ :raises TimeoutError: if the server takes too long to get ready, ++ exceeding the ``ready_timeout`` parameter. ++ :raises RuntimeError: if an unrecognized & unhandled error happened, ++ resulting in non-creation of a server object ++ (:attr:`smtpd` remains ``None``) + +- .. important:: ++ Start the server in the subthread. ++ The subthread is always a :class:`daemon thread ` ++ (i.e., we always set ``thread.daemon=True``). + +- The ``hostname`` parameter does NOT get passed through to the SMTP instance; +- if you want to give the SMTP instance a custom hostname +- (e.g., for use in HELO/EHLO greeting), +- you must pass it through the :attr:`server_hostname` parameter. ++ Exceptions can be raised ++ if the server does not start within :attr:`ready_timeout` seconds, ++ or if any other exception occurs in :meth:`~BaseController.factory` ++ while creating the server. + +- .. important:: ++ .. important:: + +- Explicitly defined SMTP keyword arguments will override keyword arguments of the +- same names defined in the (deprecated) ``server_kwargs`` argument. ++ If :meth:`start` raises an Exception, ++ cleanup is not performed automatically, ++ to support deep inspection post-exception (if you wish to do so.) ++ Cleanup must still be performed manually by calling :meth:`stop` + +- >>> from aiosmtpd.handlers import Sink +- >>> controller = Controller(Sink(), timeout=200, server_kwargs=dict(timeout=400)) +- >>> controller.SMTP_kwargs["timeout"] +- 200 ++ For example:: + +- One example is the ``enable_SMTPUTF8`` flag described in the +- :ref:`Enabling SMTPUTF8 section ` above. ++ # Assume SomeController is a concrete subclass of BaseThreadedController ++ controller = SomeController(handler) ++ try: ++ controller.start() ++ except ...: ++ ... exception handling and/or inspection ... ++ finally: ++ controller.stop() + +- | +- | :part:`Attributes` ++ .. method:: stop(no_assert=False) -> None + +- .. attribute:: hostname: str +- port: int +- :noindex: ++ :param no_assert: If ``True``, skip the assertion step so an ``AssertionError`` will ++ not be raised if thread had not been started successfully. ++ :type no_assert: bool + +- The values of the *hostname* and *port* arguments. ++ :raises AssertionError: if this method is called before ++ :meth:`start` is called successfully *AND* ``no_assert=False`` + +- Other parameters, attributes, and methods are identical to :class:`BaseThreadedController` +- and thus are not repeated nor explained here. ++ Stop the server and the event loop, and cancel all tasks ++ via :meth:`~BaseController.cancel_tasks`. + + + .. class:: UnixSocketController(\ +- handler, \ +- unix_socket, \ +- loop=None, \ +- *, \ +- ready_timeout=3.0, \ +- ssl_context=None, \ +- server_hostname=None,\ +- **SMTP_parameters) ++ handler, \ ++ unix_socket, \ ++ loop=None, \ ++ *, \ ++ ready_timeout=DEFAULT_READY_TIMEOUT, \ ++ ssl_context=None, \ ++ server_hostname=None, \ ++ **SMTP_parameters) ++ ++ A concrete subclass of :class:`BaseController` that provides ++ a threaded, Unix Socket listener. ++ ++ :param unix_socket: Socket file, ++ will be passed-through to :meth:`asyncio.loop.create_unix_server` ++ :type unix_socket: Union[str, pathlib.Path] ++ ++ For the other parameters, see the description under :class:`Controller` ++ ++ | ++ | :part:`Attributes` ++ ++ .. py:attribute:: unix_socket ++ :type: str ++ ++ The stringified version of the ``unix_socket`` parameter ++ ++ Other attributes (except ``hostname`` and ``port``) are identical to :class:`Controller` ++ and thus are not repeated nor explained here. ++ ++ | ++ | :part:`Methods` ++ ++ All methods are identical to :class:`Controller` ++ and thus are not repeated nor explained here. ++ ++ ++.. class:: UnthreadedController(\ ++ handler, \ ++ hostname=None, \ ++ port=8025, \ ++ loop=None, \ ++ *, \ ++ ssl_context=None, \ ++ server_hostname=None, \ ++ server_kwargs=None, \ ++ **SMTP_parameters) ++ ++ .. versionadded:: 1.5.0 ++ ++ A concrete subclass of :class:`BaseController` that provides ++ an UNthreaded, INET listener. ++ ++ Parameters are identical to the :class:`Controller` class. ++ ++ | ++ | :part:`Attributes` ++ ++ Attributes are identical to the :class:`Controller` class with one addition: ++ ++ .. py:attribute:: ended ++ :type: threading.Event ++ ++ An ``Event`` that can be ``.wait()``-ed when ending the controller. ++ Please see the :ref:`Unthreaded Controllers ` section for more info. ++ ++ | ++ | :part:`Methods` ++ ++ In addition to those provided by :class:`BaseController`, ++ this class provides the following: ++ ++ .. py:method:: begin ++ ++ Initializes the server task and insert it into the asyncio event loop. ++ ++ .. note:: ++ ++ The SMTP class itself will only be initialized upon first connection ++ to the server task. ++ ++ .. py:method:: finalize ++ :async: ++ ++ Perform orderly closing of the server listener. ++ If you need to close the server from a non-async function, ++ you can use the :meth:`~UnthreadedController.end` method instead. ++ ++ Upon completion of this method, the :attr:`ended` attribute will be ``set()``. ++ ++ .. py:method:: end ++ ++ This is a convenience method that will asynchronously invoke the ++ :meth:`finalize` method. ++ This method non-async, and thus is callable from non-async functions. ++ ++ .. note:: ++ ++ If the asyncio event loop has been stopped, ++ then it is safe to invoke this method directly. ++ Otherwise, it is recommended to invoke this method ++ using the :meth:`~asyncio.loop.call_soon_threadsafe` method. ++ ++ ++.. class:: UnixSocketUnthreadedController(\ ++ handler, \ ++ unix_socket, \ ++ loop=None, \ ++ *, \ ++ ssl_context=None, \ ++ server_hostname=None,\ ++ server_kwargs=None, \ ++ **SMTP_parameters) ++ ++ .. versionadded:: 1.5.0 ++ ++ A concrete subclass of :class:`BaseController` that provides ++ an UNthreaded, Unix Socket listener. ++ ++ Parameters are identical to the :class:`UnixSocketController` class. ++ ++ | ++ | :part:`Attributes` + +- :param unix_socket: Socket file, +- will be passed-through to :meth:`asyncio.loop.create_unix_server` +- :type unix_socket: Union[str, pathlib.Path] ++ Attributes are identical to the :class:`UnixSocketController` class, ++ with the following addition: + +- | +- | :part:`Attributes` ++ .. py:attribute:: ended ++ :type: threading.Event + +- .. py:attribute:: unix_socket +- :type: str ++ An ``Event`` that can be ``.wait()``-ed when ending the controller. ++ Please see the :ref:`Unthreaded Controllers ` section for more info. + +- The stringified version of the ``unix_socket`` parameter ++ | ++ | :part:`Methods` + +- Other parameters, attributes, and methods are identical to :class:`BaseThreadedController` +- and thus are not repeated nor explained here. ++ Methods are identical to the :class:`UnthreadedController` class. + + + .. _`asyncio event loop`: https://docs.python.org/3/library/asyncio-eventloop.html +diff --git a/aiosmtpd/docs/smtp.rst b/aiosmtpd/docs/smtp.rst +index f48b717..3305079 100644 +--- a/aiosmtpd/docs/smtp.rst ++++ b/aiosmtpd/docs/smtp.rst +@@ -99,7 +99,8 @@ Server hooks + The ``SMTP`` server class also implements some hooks which your subclass can + override to provide additional responses. + +-``ehlo_hook()`` ++.. py:function:: ehlo_hook() ++ + This hook makes it possible for subclasses to return additional ``EHLO`` + responses. This method, called *asynchronously* and taking no arguments, + can do whatever it wants, including (most commonly) pushing new +@@ -107,12 +108,17 @@ override to provide additional responses. + before the standard ``250 HELP`` which ends the ``EHLO`` response from the + server. + +-``rset_hook()`` ++ .. deprecated:: 1.2 ++ ++.. py:function:: rset_hook() ++ + This hook makes it possible to return additional ``RSET`` responses. This + method, called *asynchronously* and taking no arguments, is called just + before the standard ``250 OK`` which ends the ``RSET`` response from the + server. + ++ .. deprecated:: 1.2 ++ + + .. _smtp_api: + +diff --git a/aiosmtpd/handlers.py b/aiosmtpd/handlers.py +index b13dd12..ada1e91 100644 +--- a/aiosmtpd/handlers.py ++++ b/aiosmtpd/handlers.py +@@ -15,6 +15,7 @@ import mailbox + import re + import smtplib + import sys ++from abc import ABCMeta, abstractmethod + from email import message_from_bytes, message_from_string + + from public import public +@@ -148,7 +149,7 @@ class Sink: + + + @public +-class Message: ++class Message(metaclass=ABCMeta): + def __init__(self, message_class=None): + self.message_class = message_class + +@@ -172,12 +173,13 @@ class Message: + message['X-RcptTo'] = COMMASPACE.join(envelope.rcpt_tos) + return message + ++ @abstractmethod + def handle_message(self, message): +- raise NotImplementedError # pragma: nocover ++ raise NotImplementedError + + + @public +-class AsyncMessage(Message): ++class AsyncMessage(Message, metaclass=ABCMeta): + def __init__(self, message_class=None, *, loop=None): + super().__init__(message_class) + self.loop = loop or asyncio.get_event_loop() +@@ -187,8 +189,9 @@ class AsyncMessage(Message): + await self.handle_message(message) + return '250 OK' + ++ @abstractmethod + async def handle_message(self, message): +- raise NotImplementedError # pragma: nocover ++ raise NotImplementedError + + + @public +diff --git a/aiosmtpd/proxy_protocol.py b/aiosmtpd/proxy_protocol.py +index a171211..621098c 100644 +--- a/aiosmtpd/proxy_protocol.py ++++ b/aiosmtpd/proxy_protocol.py +@@ -99,7 +99,7 @@ class UnknownTypeTLV(KeyError): + + + @public +-class AsyncReader(Protocol): # pragma: nocover ++class AsyncReader(Protocol): + async def read(self, num_bytes: Optional[int] = None) -> bytes: + ... + return b"" +diff --git a/aiosmtpd/tests/conftest.py b/aiosmtpd/tests/conftest.py +index 08fc0e8..d0a6cd3 100644 +--- a/aiosmtpd/tests/conftest.py ++++ b/aiosmtpd/tests/conftest.py +@@ -29,6 +29,7 @@ __all__ = [ + "controller_data", + "handler_data", + "Global", ++ "AUTOSTOP_DELAY", + "SERVER_CRT", + "SERVER_KEY", + ] +@@ -64,6 +65,9 @@ class Global: + cls.SrvAddr = HostPort(contr.hostname, contr.port) + + ++# If less than 1.0, might cause intermittent error if test system ++# is too busy/overloaded. ++AUTOSTOP_DELAY = 1.0 + SERVER_CRT = resource_filename("aiosmtpd.tests.certs", "server.crt") + SERVER_KEY = resource_filename("aiosmtpd.tests.certs", "server.key") + +@@ -204,6 +208,17 @@ def temp_event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: + asyncio.set_event_loop(default_loop) + + ++@pytest.fixture ++def autostop_loop(temp_event_loop) -> Generator[asyncio.AbstractEventLoop, None, None]: ++ # Create a new event loop, and arrange for that loop to end almost ++ # immediately. This will allow the calls to main() in these tests to ++ # also exit almost immediately. Otherwise, the foreground test ++ # process will hang. ++ temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop) ++ # ++ yield temp_event_loop ++ ++ + @pytest.fixture + def plain_controller(get_handler, get_controller) -> Generator[Controller, None, None]: + """ +diff --git a/aiosmtpd/tests/test_main.py b/aiosmtpd/tests/test_main.py +index f9ac424..36992f3 100644 +--- a/aiosmtpd/tests/test_main.py ++++ b/aiosmtpd/tests/test_main.py +@@ -16,8 +16,9 @@ import pytest + from aiosmtpd import __version__ + from aiosmtpd.handlers import Debugging + from aiosmtpd.main import main, parseargs ++from aiosmtpd.testing.helpers import catchup_delay + from aiosmtpd.testing.statuscodes import SMTP_STATUS_CODES as S +-from aiosmtpd.tests.conftest import SERVER_CRT, SERVER_KEY ++from aiosmtpd.tests.conftest import AUTOSTOP_DELAY, SERVER_CRT, SERVER_KEY + + try: + import pwd +@@ -27,10 +28,6 @@ except ImportError: + HAS_SETUID = hasattr(os, "setuid") + MAIL_LOG = logging.getLogger("mail.log") + +-# If less than 1.0, might cause intermittent error if test system +-# is too busy/overloaded. +-AUTOSTOP_DELAY = 1.0 +- + + # region ##### Custom Handlers ######################################################## + +@@ -53,17 +50,6 @@ class NullHandler: + # region ##### Fixtures ############################################################### + + +-@pytest.fixture +-def autostop_loop(temp_event_loop) -> Generator[asyncio.AbstractEventLoop, None, None]: +- # Create a new event loop, and arrange for that loop to end almost +- # immediately. This will allow the calls to main() in these tests to +- # also exit almost immediately. Otherwise, the foreground test +- # process will hang. +- temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop) +- # +- yield temp_event_loop +- +- + @pytest.fixture + def nobody_uid() -> Generator[int, None, None]: + if pwd is None: +@@ -97,10 +83,10 @@ def watch_for_tls(ready_flag, retq: MP.Queue): + req_tls = False + ready_flag.set() + start = time.monotonic() +- delay = AUTOSTOP_DELAY * 1.5 ++ delay = AUTOSTOP_DELAY * 4 + while (time.monotonic() - start) <= delay: + try: +- with SMTPClient("localhost", 8025) as client: ++ with SMTPClient("localhost", 8025, timeout=0.1) as client: + resp = client.docmd("HELP", "HELO") + if resp == S.S530_STARTTLS_FIRST: + req_tls = True +@@ -121,7 +107,7 @@ def watch_for_smtps(ready_flag, retq: MP.Queue): + delay = AUTOSTOP_DELAY * 1.5 + while (time.monotonic() - start) <= delay: + try: +- with SMTP_SSL("localhost", 8025) as client: ++ with SMTP_SSL("localhost", 8025, timeout=0.1) as client: + client.ehlo("exemple.org") + has_smtps = True + break +@@ -215,6 +201,7 @@ class TestMainByWatcher: + with watcher_process(watch_for_tls) as retq: + temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop) + main_n("--tlscert", str(SERVER_CRT), "--tlskey", str(SERVER_KEY)) ++ catchup_delay() + has_starttls = retq.get() + assert has_starttls is True + require_tls = retq.get() +@@ -230,6 +217,7 @@ class TestMainByWatcher: + str(SERVER_KEY), + "--no-requiretls", + ) ++ catchup_delay() + has_starttls = retq.get() + assert has_starttls is True + require_tls = retq.get() +@@ -239,6 +227,7 @@ class TestMainByWatcher: + with watcher_process(watch_for_smtps) as retq: + temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop) + main_n("--smtpscert", str(SERVER_CRT), "--smtpskey", str(SERVER_KEY)) ++ catchup_delay() + has_smtps = retq.get() + assert has_smtps is True + +diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py +index 99c5630..41225dc 100644 +--- a/aiosmtpd/tests/test_server.py ++++ b/aiosmtpd/tests/test_server.py +@@ -3,16 +3,18 @@ + + """Test other aspects of the server implementation.""" + ++import asyncio + import errno + import platform + import socket +-import ssl + import time + from contextlib import ExitStack + from functools import partial + from pathlib import Path ++from smtplib import SMTP as SMTPClient, SMTPServerDisconnected + from tempfile import mkdtemp +-from typing import Generator ++from threading import Thread ++from typing import Generator, Optional + + import pytest + from pytest_mock import MockFixture +@@ -20,13 +22,17 @@ from pytest_mock import MockFixture + from aiosmtpd.controller import ( + Controller, + UnixSocketController, ++ UnthreadedController, ++ UnixSocketMixin, ++ UnixSocketUnthreadedController, + _FakeServer, + get_localhost, + ) + from aiosmtpd.handlers import Sink + from aiosmtpd.smtp import SMTP as Server ++from aiosmtpd.testing.helpers import catchup_delay + +-from .conftest import Global ++from .conftest import Global, AUTOSTOP_DELAY + + + class SlowStartController(Controller): +@@ -91,6 +97,45 @@ def safe_socket_dir() -> Generator[Path, None, None]: + tmpdir.rmdir() + + ++def assert_smtp_socket(controller: UnixSocketMixin): ++ assert Path(controller.unix_socket).exists() ++ sockfile = controller.unix_socket ++ ssl_context = controller.ssl_context ++ with ExitStack() as stk: ++ sock: socket.socket = stk.enter_context( ++ socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) ++ ) ++ sock.settimeout(AUTOSTOP_DELAY) ++ sock.connect(str(sockfile)) ++ if ssl_context: ++ sock = stk.enter_context(ssl_context.wrap_socket(sock)) ++ catchup_delay() ++ try: ++ resp = sock.recv(1024) ++ except socket.timeout: ++ return False ++ if not resp: ++ return False ++ assert resp.startswith(b"220 ") ++ assert resp.endswith(b"\r\n") ++ sock.send(b"EHLO socket.test\r\n") ++ # We need to "build" resparr because, especially when socket is wrapped ++ # in SSL, the SMTP server takes it sweet time responding with the list ++ # of ESMTP features ... ++ resparr = bytearray() ++ while not resparr.endswith(b"250 HELP\r\n"): ++ catchup_delay() ++ resp = sock.recv(1024) ++ if not resp: ++ break ++ resparr += resp ++ assert resparr.endswith(b"250 HELP\r\n") ++ sock.send(b"QUIT\r\n") ++ catchup_delay() ++ resp = sock.recv(1024) ++ assert resp.startswith(b"221") ++ ++ + class TestServer: + """Tests for the aiosmtpd.smtp.SMTP class""" + +@@ -272,10 +317,7 @@ class TestController: + + # Apparently errno.E* constants adapts to the OS, so on Windows they will + # automatically use the analogous WSAE* constants +- @pytest.mark.parametrize( +- "err", +- [errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT] +- ) ++ @pytest.mark.parametrize("err", [errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT]) + def test_getlocalhost_6no(self, mocker, err): + mock_makesock: mocker.Mock = mocker.patch( + "aiosmtpd.controller.makesock", +@@ -320,70 +362,176 @@ class TestController: + @pytest.mark.skipif(in_cygwin(), reason="Cygwin AF_UNIX is problematic") + @pytest.mark.skipif(in_win32(), reason="Win32 does not yet fully implement AF_UNIX") + class TestUnixSocketController: +- sockfile: Path = None +- +- def _assert_good_server(self, ssl_context: ssl.SSLContext = None): +- # Note: all those time.sleep()s are necessary +- # Remember that we're running in "Threaded" mode, and there's the GIL... +- # The time.sleep()s lets go of the GIL allowing the asyncio loop to move +- # forward +- assert self.sockfile.exists() +- with ExitStack() as stk: +- sock: socket.socket = stk.enter_context( +- socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +- ) +- sock.connect(str(self.sockfile)) +- if ssl_context: +- sock = stk.enter_context(ssl_context.wrap_socket(sock)) +- time.sleep(0.1) +- resp = sock.recv(1024) +- assert resp.startswith(b"220 ") +- assert resp.endswith(b"\r\n") +- sock.send(b"EHLO socket.test\r\n") +- # We need to "build" resparr because, especially when socket is wrapped +- # in SSL, the SMTP server takes it sweet time responding with the list +- # of ESMTP features ... +- resparr = bytearray() +- while not resparr.endswith(b"250 HELP\r\n"): +- time.sleep(0.1) +- resp = sock.recv(1024) +- if not resp: +- break +- resparr += resp +- assert resparr.endswith(b"250 HELP\r\n") +- sock.send(b"QUIT\r\n") +- time.sleep(0.1) +- resp = sock.recv(1024) +- assert resp.startswith(b"221") +- + def test_server_creation(self, safe_socket_dir): +- self.sockfile = safe_socket_dir / "smtp" +- cont = UnixSocketController(Sink(), unix_socket=self.sockfile) ++ sockfile = safe_socket_dir / "smtp" ++ cont = UnixSocketController(Sink(), unix_socket=sockfile) + try: + cont.start() +- self._assert_good_server() ++ assert_smtp_socket(cont) + finally: + cont.stop() + + def test_server_creation_ssl(self, safe_socket_dir, ssl_context_server): +- self.sockfile = safe_socket_dir / "smtp" ++ sockfile = safe_socket_dir / "smtp" + cont = UnixSocketController( +- Sink(), unix_socket=self.sockfile, ssl_context=ssl_context_server ++ Sink(), unix_socket=sockfile, ssl_context=ssl_context_server + ) + try: + cont.start() + # Allow additional time for SSL to kick in +- time.sleep(0.1) +- self._assert_good_server(ssl_context_server) ++ catchup_delay() ++ assert_smtp_socket(cont) + finally: + cont.stop() + + ++class TestUnthreaded: ++ @pytest.fixture ++ def runner(self): ++ thread: Optional[Thread] = None ++ ++ def _runner(loop: asyncio.AbstractEventLoop): ++ loop.run_forever() ++ ++ def starter(loop: asyncio.AbstractEventLoop): ++ nonlocal thread ++ thread = Thread(target=_runner, args=(loop,)) ++ thread.setDaemon(True) ++ thread.start() ++ catchup_delay() ++ ++ def joiner(timeout: float = None): ++ nonlocal thread ++ assert isinstance(thread, Thread) ++ thread.join(timeout=timeout) ++ ++ def is_alive(): ++ nonlocal thread ++ assert isinstance(thread, Thread) ++ return thread.is_alive() ++ ++ starter.join = joiner ++ starter.is_alive = is_alive ++ return starter ++ ++ @pytest.mark.skipif(in_cygwin(), reason="Cygwin AF_UNIX is problematic") ++ @pytest.mark.skipif(in_win32(), reason="Win32 does not yet fully implement AF_UNIX") ++ def test_unixsocket(self, safe_socket_dir, autostop_loop, runner): ++ sockfile = safe_socket_dir / "smtp" ++ cont = UnixSocketUnthreadedController( ++ Sink(), unix_socket=sockfile, loop=autostop_loop ++ ) ++ cont.begin() ++ # Make sure event loop is not running (will be started in thread) ++ assert autostop_loop.is_running() is False ++ runner(autostop_loop) ++ # Make sure event loop is up and running (started within thread) ++ assert autostop_loop.is_running() is True ++ # Check we can connect ++ assert_smtp_socket(cont) ++ # Wait until thread ends, which it will be when the loop autostops ++ runner.join(timeout=AUTOSTOP_DELAY) ++ assert runner.is_alive() is False ++ catchup_delay() ++ assert autostop_loop.is_running() is False ++ # At this point, the loop _has_ stopped, but the task is still listening ++ assert assert_smtp_socket(cont) is False ++ # Stop the task ++ cont.end() ++ catchup_delay() ++ # Now the listener has gone away ++ # noinspection PyTypeChecker ++ with pytest.raises((socket.timeout, ConnectionError)): ++ assert_smtp_socket(cont) ++ ++ @pytest.mark.filterwarnings( ++ "ignore::pytest.PytestUnraisableExceptionWarning" ++ ) ++ def test_inet_loopstop(self, autostop_loop, runner): ++ """ ++ Verify behavior when the loop is stopped before controller is stopped ++ """ ++ autostop_loop.set_debug(True) ++ cont = UnthreadedController(Sink(), loop=autostop_loop) ++ cont.begin() ++ # Make sure event loop is not running (will be started in thread) ++ assert autostop_loop.is_running() is False ++ runner(autostop_loop) ++ # Make sure event loop is up and running (started within thread) ++ assert autostop_loop.is_running() is True ++ # Check we can connect ++ with SMTPClient(cont.hostname, cont.port, timeout=AUTOSTOP_DELAY) as client: ++ code, _ = client.helo("example.org") ++ assert code == 250 ++ # Wait until thread ends, which it will be when the loop autostops ++ runner.join(timeout=AUTOSTOP_DELAY) ++ assert runner.is_alive() is False ++ catchup_delay() ++ assert autostop_loop.is_running() is False ++ # At this point, the loop _has_ stopped, but the task is still listening, ++ # so rather than socket.timeout, we'll get a refusal instead, thus causing ++ # SMTPServerDisconnected ++ with pytest.raises(SMTPServerDisconnected): ++ SMTPClient(cont.hostname, cont.port, timeout=0.1) ++ cont.end() ++ catchup_delay() ++ cont.ended.wait() ++ # Now the listener has gone away, and thus we will end up with socket.timeout ++ # or ConnectionError (depending on OS) ++ # noinspection PyTypeChecker ++ with pytest.raises((socket.timeout, ConnectionError)): ++ SMTPClient(cont.hostname, cont.port, timeout=0.1) ++ ++ @pytest.mark.filterwarnings( ++ "ignore::pytest.PytestUnraisableExceptionWarning" ++ ) ++ def test_inet_contstop(self, temp_event_loop, runner): ++ """ ++ Verify behavior when the controller is stopped before loop is stopped ++ """ ++ cont = UnthreadedController(Sink(), loop=temp_event_loop) ++ cont.begin() ++ # Make sure event loop is not running (will be started in thread) ++ assert temp_event_loop.is_running() is False ++ runner(temp_event_loop) ++ # Make sure event loop is up and running ++ assert temp_event_loop.is_running() is True ++ try: ++ # Check that we can connect ++ with SMTPClient(cont.hostname, cont.port, timeout=AUTOSTOP_DELAY) as client: ++ code, _ = client.helo("example.org") ++ assert code == 250 ++ client.quit() ++ catchup_delay() ++ temp_event_loop.call_soon_threadsafe(cont.end) ++ for _ in range(10): # 10 is arbitrary ++ catchup_delay() # effectively yield to other threads/event loop ++ if cont.ended.wait(1.0): ++ break ++ assert temp_event_loop.is_running() is True ++ # Because we've called .end() there, the server listener should've gone ++ # away, so we should end up with a socket.timeout or ConnectionError or ++ # SMTPServerDisconnected (depending on lotsa factors) ++ expect_errs = (socket.timeout, ConnectionError, SMTPServerDisconnected) ++ # noinspection PyTypeChecker ++ with pytest.raises(expect_errs): ++ SMTPClient(cont.hostname, cont.port, timeout=0.1) ++ finally: ++ # Wrap up, or else we'll hang ++ temp_event_loop.call_soon_threadsafe(cont.cancel_tasks) ++ catchup_delay() ++ runner.join() ++ assert runner.is_alive() is False ++ assert temp_event_loop.is_running() is False ++ assert temp_event_loop.is_closed() is False ++ ++ + class TestFactory: + def test_normal_situation(self): + cont = Controller(Sink()) + try: + cont.start() ++ catchup_delay() + assert cont.smtpd is not None + assert cont._thread_exception is None + finally: +diff --git a/pyproject.toml b/pyproject.toml +index b61bfa6..e067d36 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -3,7 +3,6 @@ requires = ["setuptools", "wheel"] + build-backend = "setuptools.build_meta" + + [tool.pytest.ini_options] +-# addopts = """--doctest-glob="*.rst" --strict-markers -rfEX""" + addopts = """--strict-markers -rfEX""" + markers = [ + "client_data", +@@ -37,6 +36,7 @@ source = [ + [tool.coverage.coverage_conditional_plugin.rules] + # Here we specify our pragma rules: + py-ge-38 = "sys_version_info >= (3, 8)" ++py-lt-38 = "sys_version_info < (3, 8)" + py-gt-36 = "sys_version_info > (3, 6)" + has-mypy = "is_installed('mypy')" + has-pwd = "is_installed('pwd')" +@@ -47,10 +47,14 @@ on-wsl = "'Microsoft' in platform_release" + # As of 2021-02-07, only WSL has a kernel with "Microsoft" in the version. + on-not-win32 = "sys_platform != 'win32'" + on-cygwin = "sys_platform == 'cygwin'" ++no-unixsock = "sys_platform in {'win32', 'cygwin'}" + + [tool.coverage.report] + exclude_lines = [ + "pragma: nocover", ++ "pragma: no cover", ++ "@abstract", ++ 'class \S+\(Protocol\):' + ] + fail_under = 100 + show_missing = true +-- +2.32.0 + diff --git a/0002-Code-Hygiene-259.patch b/0002-Code-Hygiene-259.patch new file mode 100644 index 0000000..5fddb26 --- /dev/null +++ b/0002-Code-Hygiene-259.patch @@ -0,0 +1,2735 @@ +From 1a1c1bb15d4659f1076c7e14a064721761d81aa6 Mon Sep 17 00:00:00 2001 +From: Pandu E POLUAN +Date: Tue, 23 Mar 2021 13:31:32 +0700 +Subject: [PATCH 2/4] Code Hygiene (#259) + +* Activate LOTS of flake8 plugins to enforce code hygiene +* Tune Annotation Thresholds +* Add Annotation +* Add pytest-mock to the deps of "docs" +* Fix post-rebase flake8 complaints +* Update NEWS.rst +* Move flake8 plugins into a pseudo-section in tox.ini +* Create concrete class for MessageHandler +* Bump Version to 1.5.0a2 +* Experimentally enable tox-ing on 3.10 +* Use typing.ByteString instead of custom AnyBytes +--- + .../workflows/unit-testing-and-coverage.yml | 10 +- + aiosmtpd/__init__.py | 2 +- + aiosmtpd/controller.py | 10 +- + aiosmtpd/docs/NEWS.rst | 5 +- + aiosmtpd/docs/_exts/autoprogramm.py | 64 ++++--- + aiosmtpd/docs/conf.py | 9 +- + aiosmtpd/docs/proxyprotocol.rst | 6 +- + aiosmtpd/docs/smtp.rst | 2 +- + aiosmtpd/handlers.py | 158 +++++++++++------- + aiosmtpd/lmtp.py | 6 +- + aiosmtpd/main.py | 11 +- + aiosmtpd/proxy_protocol.py | 32 ++-- + aiosmtpd/qa/test_0packaging.py | 38 ++++- + aiosmtpd/qa/test_1testsuite.py | 6 +- + aiosmtpd/smtp.py | 124 ++++++++------ + aiosmtpd/testing/helpers.py | 10 +- + aiosmtpd/tests/conftest.py | 48 +++--- + aiosmtpd/tests/test_handlers.py | 90 +++++++--- + aiosmtpd/tests/test_main.py | 16 +- + aiosmtpd/tests/test_proxyprotocol.py | 67 +++++--- + aiosmtpd/tests/test_server.py | 26 +-- + aiosmtpd/tests/test_smtp.py | 89 +++++----- + aiosmtpd/tests/test_starttls.py | 17 +- + housekeep.py | 3 +- + setup.cfg | 52 +++++- + tox.ini | 57 ++++++- + 26 files changed, 627 insertions(+), 331 deletions(-) + +diff --git a/.github/workflows/unit-testing-and-coverage.yml b/.github/workflows/unit-testing-and-coverage.yml +index f7b0e32..ebc2248 100644 +--- a/.github/workflows/unit-testing-and-coverage.yml ++++ b/.github/workflows/unit-testing-and-coverage.yml +@@ -38,9 +38,17 @@ jobs: + python -m pip install --upgrade pip setuptools wheel + python setup.py develop + - name: "flake8 Style Checking" ++ shell: bash + # language=bash + run: | +- pip install colorama flake8 flake8-bugbear ++ # A bunch of flake8 plugins... ++ grab_f8_plugins=( ++ "from configparser import ConfigParser;" ++ "config = ConfigParser();" ++ "config.read('tox.ini');" ++ "print(config['flake8_plugins']['deps']);" ++ ) ++ pip install colorama flake8 $(python -c "${grab_f8_plugins[*]}") + python -m flake8 aiosmtpd setup.py housekeep.py release.py + - name: "Docs Checking" + # language=bash +diff --git a/aiosmtpd/__init__.py b/aiosmtpd/__init__.py +index 9c7b938..e96d0ee 100644 +--- a/aiosmtpd/__init__.py ++++ b/aiosmtpd/__init__.py +@@ -1,4 +1,4 @@ + # Copyright 2014-2021 The aiosmtpd Developers + # SPDX-License-Identifier: Apache-2.0 + +-__version__ = "1.5.0a1" ++__version__ = "1.5.0a2" +diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py +index d3345b8..79bdbd0 100644 +--- a/aiosmtpd/controller.py ++++ b/aiosmtpd/controller.py +@@ -85,7 +85,7 @@ class _FakeServer(asyncio.StreamReaderProtocol): + factory() failed to instantiate an SMTP instance. + """ + +- def __init__(self, loop): ++ def __init__(self, loop: asyncio.AbstractEventLoop): + # Imitate what SMTP does + super().__init__( + asyncio.StreamReader(loop=loop), +@@ -93,7 +93,9 @@ class _FakeServer(asyncio.StreamReaderProtocol): + loop=loop, + ) + +- def _client_connected_cb(self, reader, writer): ++ def _client_connected_cb( ++ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ++ ) -> None: + pass + + +@@ -143,7 +145,7 @@ class BaseController(metaclass=ABCMeta): + """Subclasses can override this to customize the handler/server creation.""" + return SMTP(self.handler, **self.SMTP_kwargs) + +- def _factory_invoker(self): ++ def _factory_invoker(self) -> Union[SMTP, _FakeServer]: + """Wraps factory() to catch exceptions during instantiation""" + try: + self.smtpd = self.factory() +@@ -223,7 +225,7 @@ class BaseThreadedController(BaseController, metaclass=ABCMeta): + """ + raise NotImplementedError + +- def _run(self, ready_event: threading.Event): ++ def _run(self, ready_event: threading.Event) -> None: + asyncio.set_event_loop(self.loop) + try: + # Need to do two-step assignments here to ensure IDEs can properly +diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst +index ce627a7..eea911d 100644 +--- a/aiosmtpd/docs/NEWS.rst ++++ b/aiosmtpd/docs/NEWS.rst +@@ -3,8 +3,8 @@ + ################### + + +-1.5.0 (aiosmtpd-next-next) +-========================== ++1.5.0 (aiosmtpd-next) ++===================== + + Added + ----- +@@ -13,6 +13,7 @@ Added + Fixed/Improved + -------------- + * All Controllers now have more rationale design, as they are now composited from a Base + a Mixin ++* A whole bunch of annotations + + + 1.4.2 (2021-03-08) +diff --git a/aiosmtpd/docs/_exts/autoprogramm.py b/aiosmtpd/docs/_exts/autoprogramm.py +index 69088be..c23bd2f 100644 +--- a/aiosmtpd/docs/_exts/autoprogramm.py ++++ b/aiosmtpd/docs/_exts/autoprogramm.py +@@ -32,6 +32,7 @@ import argparse + import builtins + import collections + import os ++import sphinx + + from docutils import nodes + from docutils.parsers.rst import Directive +@@ -39,13 +40,13 @@ from docutils.parsers.rst.directives import unchanged + from docutils.statemachine import StringList + from functools import reduce + from sphinx.util.nodes import nested_parse_with_titles +-from typing import List ++from typing import Any, Dict, List, Optional, Tuple + + + __all__ = ("AutoprogrammDirective", "import_object", "scan_programs", "setup") + + +-def get_subparser_action(parser): ++def get_subparser_action(parser: argparse.ArgumentParser) -> argparse._SubParsersAction: + neg1_action = parser._actions[-1] + + if isinstance(neg1_action, argparse._SubParsersAction): +@@ -56,7 +57,13 @@ def get_subparser_action(parser): + return a + + +-def scan_programs(parser, command=None, maxdepth=0, depth=0, groups=False): ++def scan_programs( ++ parser: argparse.ArgumentParser, ++ command: List[str] = None, ++ maxdepth: int = 0, ++ depth: int = 0, ++ groups: bool = False, ++): + if command is None: + command = [] + +@@ -79,6 +86,7 @@ def scan_programs(parser, command=None, maxdepth=0, depth=0, groups=False): + subp_action = get_subparser_action(parser) + + if subp_action: ++ # noinspection PyUnresolvedReferences + choices = subp_action.choices.items() + + if not ( +@@ -89,11 +97,10 @@ def scan_programs(parser, command=None, maxdepth=0, depth=0, groups=False): + + for cmd, sub in choices: + if isinstance(sub, argparse.ArgumentParser): +- for program in scan_programs(sub, command + [cmd], maxdepth, depth + 1): +- yield program ++ yield from scan_programs(sub, command + [cmd], maxdepth, depth + 1) + + +-def scan_options(actions): ++def scan_options(actions: list): + for arg in actions: + if not (arg.option_strings or isinstance(arg, argparse._SubParsersAction)): + yield format_positional_argument(arg) +@@ -103,13 +110,13 @@ def scan_options(actions): + yield format_option(arg) + + +-def format_positional_argument(arg): ++def format_positional_argument(arg: argparse.Action) -> Tuple[List[str], str]: + desc = (arg.help or "") % {"default": arg.default} + name = arg.metavar or arg.dest + return [name], desc + + +-def format_option(arg): ++def format_option(arg: argparse.Action) -> Tuple[List[str], str]: + desc = (arg.help or "") % {"default": arg.default} + + if not isinstance(arg, (argparse._StoreAction, argparse._AppendAction)): +@@ -131,7 +138,7 @@ def format_option(arg): + return names, desc + + +-def import_object(import_name): ++def import_object(import_name: str) -> Any: + module_name, expr = import_name.split(":", 1) + try: + mod = __import__(module_name) +@@ -151,7 +158,8 @@ def import_object(import_name): + with open(f[0]) as fobj: + codestring = fobj.read() + foo = imp.new_module("foo") +- exec(codestring, foo.__dict__) # nosec ++ # noinspection BuiltinExec ++ exec(codestring, foo.__dict__) # noqa: DUO105 # nosec + + sys.modules["foo"] = foo + mod = __import__("foo") +@@ -163,7 +171,7 @@ def import_object(import_name): + globals_ = builtins + if not isinstance(globals_, dict): + globals_ = globals_.__dict__ +- return eval(expr, globals_, mod.__dict__) # nosec ++ return eval(expr, globals_, mod.__dict__) # noqa: DUO104 # nosec + + + class AutoprogrammDirective(Directive): +@@ -204,13 +212,16 @@ class AutoprogrammDirective(Directive): + + if start_command: + +- def get_start_cmd_parser(p): ++ def get_start_cmd_parser( ++ p: argparse.ArgumentParser, ++ ) -> argparse.ArgumentParser: + looking_for = start_command.pop(0) + action = get_subparser_action(p) + + if not action: + raise ValueError("No actions for command " + looking_for) + ++ # noinspection PyUnresolvedReferences + subp = action.choices[looking_for] + + if start_command: +@@ -263,7 +274,7 @@ class AutoprogrammDirective(Directive): + options_adornment=options_adornment, + ) + +- def run(self): ++ def run(self) -> list: + node = nodes.section() + node.document = self.state.document + result = StringList() +@@ -274,17 +285,17 @@ class AutoprogrammDirective(Directive): + + + def render_rst( +- title, +- options, +- is_program, +- is_subgroup, +- description, +- usage, +- usage_strip, +- usage_codeblock, +- epilog, +- options_title, +- options_adornment, ++ title: str, ++ options: List[Tuple[List[str], str]], ++ is_program: bool, ++ is_subgroup: bool, ++ description: str, ++ usage: Optional[str], ++ usage_strip: bool, ++ usage_codeblock: bool, ++ epilog: str, ++ options_title: str, ++ options_adornment: str, + ): + if usage_strip: + to_strip = title.rsplit(" ", 1)[0] +@@ -310,8 +321,7 @@ def render_rst( + yield ("!" if is_subgroup else "?") * len(title) + yield "" + +- for line in (description or "").splitlines(): +- yield line ++ yield from (description or "").splitlines() + yield "" + + if usage is None: +@@ -340,7 +350,7 @@ def render_rst( + yield line or "" + + +-def setup(app): ++def setup(app: sphinx.application.Sphinx) -> Dict[str, Any]: + app.add_directive("autoprogramm", AutoprogrammDirective) + return { + "version": "0.2a0", +diff --git a/aiosmtpd/docs/conf.py b/aiosmtpd/docs/conf.py +index 6ee2d05..d3273f1 100644 +--- a/aiosmtpd/docs/conf.py ++++ b/aiosmtpd/docs/conf.py +@@ -1,6 +1,7 @@ +-# -*- coding: utf-8 -*- +-# +-# aiosmtpd documentation build configuration file, created by ++# Copyright 2014-2021 The aiosmtpd Developers ++# SPDX-License-Identifier: Apache-2.0 ++ ++# aiosmtpd documentation build configuration file, originally created by + # sphinx-quickstart on Fri Oct 16 12:18:52 2015. + # + # This file is execfile()d with the current directory set to its +@@ -331,5 +332,5 @@ texinfo_documents = [ + # endregion + + +-def setup(app): ++def setup(app): # noqa: ANN001 + app.add_css_file("aiosmtpd.css") +diff --git a/aiosmtpd/docs/proxyprotocol.rst b/aiosmtpd/docs/proxyprotocol.rst +index 30e01b7..eac41b0 100644 +--- a/aiosmtpd/docs/proxyprotocol.rst ++++ b/aiosmtpd/docs/proxyprotocol.rst +@@ -203,7 +203,7 @@ Enums + Valid only for address family of :attr:`AF.INET` or :attr:`AF.INET6` + + .. py:attribute:: rest +- :type: Union[bytes, bytearray] ++ :type: ByteString + + The contents depend on the version of the PROXY header *and* (for version 2) + the address family. +@@ -374,7 +374,7 @@ Enums + .. py:classmethod:: from_raw(raw) -> Optional[ProxyTLV] + + :param raw: The raw bytes containing the TLV Vectors +- :type raw: Union[bytes, bytearray] ++ :type raw: ByteString + :return: A new instance of ProxyTLV, or ``None`` if parsing failed + + This triggers the parsing of raw bytes/bytearray into a ProxyTLV instance. +@@ -387,7 +387,7 @@ Enums + .. py:classmethod:: parse(chunk, partial_ok=True) -> Dict[str, Any] + + :param chunk: The bytes to parse into TLV Vectors +- :type chunk: Union[bytes, bytearray] ++ :type chunk: ByteString + :param partial_ok: If ``True``, return partially-parsed TLV Vectors as is. + If ``False``, (re)raise ``MalformedTLV`` + :type partial_ok: bool +diff --git a/aiosmtpd/docs/smtp.rst b/aiosmtpd/docs/smtp.rst +index 3305079..b647e32 100644 +--- a/aiosmtpd/docs/smtp.rst ++++ b/aiosmtpd/docs/smtp.rst +@@ -499,7 +499,7 @@ aiosmtpd.smtp + + :param challenge: The SMTP AUTH challenge to send to the client. + May be in plaintext, may be in base64. Do NOT prefix with "334 "! +- :type challenge: Union[str, bytes, bytearray] ++ :type challenge: AnyStr + :param encode_to_b64: If true, will perform base64-encoding before sending + the challenge to the client. + :type encode_to_b64: bool +diff --git a/aiosmtpd/handlers.py b/aiosmtpd/handlers.py +index ada1e91..b22821e 100644 +--- a/aiosmtpd/handlers.py ++++ b/aiosmtpd/handlers.py +@@ -10,91 +10,114 @@ your own handling of messages. Implement only the methods you care about. + """ + + import asyncio ++import io + import logging + import mailbox ++import os + import re + import smtplib + import sys + from abc import ABCMeta, abstractmethod +-from email import message_from_bytes, message_from_string ++from argparse import ArgumentParser ++from email.message import Message as Em_Message ++from email.parser import BytesParser, Parser ++from typing import AnyStr, Dict, List, Tuple, Type, TypeVar + + from public import public + +-EMPTYBYTES = b'' +-COMMASPACE = ', ' +-CRLF = b'\r\n' +-NLCRE = re.compile(br'\r\n|\r|\n') +-log = logging.getLogger('mail.debug') ++from aiosmtpd.smtp import SMTP as SMTPServer ++from aiosmtpd.smtp import Envelope as SMTPEnvelope ++from aiosmtpd.smtp import Session as SMTPSession + ++T = TypeVar("T") + +-def _format_peer(peer): ++EMPTYBYTES = b"" ++COMMASPACE = ", " ++CRLF = b"\r\n" ++NLCRE = re.compile(br"\r\n|\r|\n") ++log = logging.getLogger("mail.debug") ++ ++ ++def _format_peer(peer: str) -> str: + # This is a separate function mostly so the test suite can craft a + # reproducible output. +- return 'X-Peer: {!r}'.format(peer) ++ return "X-Peer: {!r}".format(peer) ++ ++ ++def message_from_bytes(s, *args, **kws): ++ return BytesParser(*args, **kws).parsebytes(s) ++ ++ ++def message_from_string(s, *args, **kws): ++ return Parser(*args, **kws).parsestr(s) + + + @public + class Debugging: +- def __init__(self, stream=None): ++ def __init__(self, stream: io.TextIOBase = None): + self.stream = sys.stdout if stream is None else stream + + @classmethod +- def from_cli(cls, parser, *args): ++ def from_cli(cls: Type[T], parser: ArgumentParser, *args) -> T: + error = False + stream = None + if len(args) == 0: + pass + elif len(args) > 1: + error = True +- elif args[0] == 'stdout': ++ elif args[0] == "stdout": + stream = sys.stdout +- elif args[0] == 'stderr': ++ elif args[0] == "stderr": + stream = sys.stderr + else: + error = True + if error: +- parser.error('Debugging usage: [stdout|stderr]') ++ parser.error("Debugging usage: [stdout|stderr]") + return cls(stream) + +- def _print_message_content(self, peer, data): ++ def _print_message_content(self, peer: str, data: AnyStr) -> None: + in_headers = True + for line in data.splitlines(): + # Dump the RFC 2822 headers first. + if in_headers and not line: + print(_format_peer(peer), file=self.stream) + in_headers = False +- if isinstance(data, bytes): ++ if isinstance(line, bytes): + # Avoid spurious 'str on bytes instance' warning. +- line = line.decode('utf-8', 'replace') ++ line = line.decode("utf-8", "replace") + print(line, file=self.stream) + +- async def handle_DATA(self, server, session, envelope): +- print('---------- MESSAGE FOLLOWS ----------', file=self.stream) ++ async def handle_DATA( ++ self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope ++ ) -> str: ++ print("---------- MESSAGE FOLLOWS ----------", file=self.stream) + # Yes, actually test for truthiness since it's possible for either the + # keywords to be missing, or for their values to be empty lists. + add_separator = False + if envelope.mail_options: +- print('mail options:', envelope.mail_options, file=self.stream) ++ print("mail options:", envelope.mail_options, file=self.stream) + add_separator = True + # rcpt_options are not currently support by the SMTP class. + rcpt_options = envelope.rcpt_options +- if any(rcpt_options): # pragma: nocover +- print('rcpt options:', rcpt_options, file=self.stream) ++ if any(rcpt_options): # pragma: nocover ++ print("rcpt options:", rcpt_options, file=self.stream) + add_separator = True + if add_separator: + print(file=self.stream) + self._print_message_content(session.peer, envelope.content) +- print('------------ END MESSAGE ------------', file=self.stream) +- return '250 OK' ++ print("------------ END MESSAGE ------------", file=self.stream) ++ return "250 OK" + + + @public + class Proxy: +- def __init__(self, remote_hostname, remote_port): ++ def __init__(self, remote_hostname: str, remote_port: int): + self._hostname = remote_hostname + self._port = remote_port + +- async def handle_DATA(self, server, session, envelope): ++ async def handle_DATA( ++ self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope ++ ) -> str: + if isinstance(envelope.content, str): + content = envelope.original_content + else: +@@ -107,15 +130,17 @@ class Proxy: + if NLCRE.match(line): + ending = line + break +- peer = session.peer[0].encode('ascii') +- lines.insert(_i, b'X-Peer: %s%s' % (peer, ending)) ++ peer = session.peer[0].encode("ascii") ++ lines.insert(_i, b"X-Peer: " + peer + ending) + data = EMPTYBYTES.join(lines) + refused = self._deliver(envelope.mail_from, envelope.rcpt_tos, data) + # TBD: what to do with refused addresses? +- log.info('we got some refusals: %s', refused) +- return '250 OK' ++ log.info("we got some refusals: %s", refused) ++ return "250 OK" + +- def _deliver(self, mail_from, rcpt_tos, data): ++ def _deliver( ++ self, mail_from: AnyStr, rcpt_tos: List[AnyStr], data: AnyStr ++ ) -> Dict[str, Tuple[int, bytes]]: + refused = {} + try: + s = smtplib.SMTP() +@@ -125,15 +150,15 @@ class Proxy: + finally: + s.quit() + except smtplib.SMTPRecipientsRefused as e: +- log.info('got SMTPRecipientsRefused') ++ log.info("got SMTPRecipientsRefused") + refused = e.recipients + except (OSError, smtplib.SMTPException) as e: +- log.exception('got %s', e.__class__) ++ log.exception("got %s", e.__class__) + # All recipients were refused. If the exception had an associated + # error code, use it. Otherwise, fake it with a non-triggering + # exception code. +- errcode = getattr(e, 'smtp_code', -1) +- errmsg = getattr(e, 'smtp_error', 'ignore') ++ errcode = getattr(e, "smtp_code", -1) ++ errmsg = getattr(e, "smtp_error", "ignore") + for r in rcpt_tos: + refused[r] = (errcode, errmsg) + return refused +@@ -142,75 +167,88 @@ class Proxy: + @public + class Sink: + @classmethod +- def from_cli(cls, parser, *args): ++ def from_cli(cls: Type[T], parser: ArgumentParser, *args) -> T: + if len(args) > 0: +- parser.error('Sink handler does not accept arguments') ++ parser.error("Sink handler does not accept arguments") + return cls() + + + @public + class Message(metaclass=ABCMeta): +- def __init__(self, message_class=None): ++ def __init__(self, message_class: Type[Em_Message] = None): + self.message_class = message_class + +- async def handle_DATA(self, server, session, envelope): +- envelope = self.prepare_message(session, envelope) +- self.handle_message(envelope) +- return '250 OK' ++ async def handle_DATA( ++ self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope ++ ) -> str: ++ message = self.prepare_message(session, envelope) ++ self.handle_message(message) ++ return "250 OK" + +- def prepare_message(self, session, envelope): ++ def prepare_message( ++ self, session: SMTPSession, envelope: SMTPEnvelope ++ ) -> Em_Message: + # If the server was created with decode_data True, then data will be a + # str, otherwise it will be bytes. + data = envelope.content +- if isinstance(data, bytes): ++ message: Em_Message ++ if isinstance(data, (bytes, bytearray)): + message = message_from_bytes(data, self.message_class) +- else: +- assert isinstance(data, str), ( +- 'Expected str or bytes, got {}'.format(type(data))) ++ elif isinstance(data, str): + message = message_from_string(data, self.message_class) +- message['X-Peer'] = str(session.peer) +- message['X-MailFrom'] = envelope.mail_from +- message['X-RcptTo'] = COMMASPACE.join(envelope.rcpt_tos) ++ else: ++ raise TypeError(f"Expected str or bytes, got {type(data)}") ++ assert isinstance(message, Em_Message) ++ message["X-Peer"] = str(session.peer) ++ message["X-MailFrom"] = envelope.mail_from ++ message["X-RcptTo"] = COMMASPACE.join(envelope.rcpt_tos) + return message + + @abstractmethod +- def handle_message(self, message): ++ def handle_message(self, message: Em_Message) -> None: + raise NotImplementedError + + + @public + class AsyncMessage(Message, metaclass=ABCMeta): +- def __init__(self, message_class=None, *, loop=None): ++ def __init__( ++ self, ++ message_class: Type[Em_Message] = None, ++ *, ++ loop: asyncio.AbstractEventLoop = None, ++ ): + super().__init__(message_class) + self.loop = loop or asyncio.get_event_loop() + +- async def handle_DATA(self, server, session, envelope): ++ async def handle_DATA( ++ self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope ++ ) -> str: + message = self.prepare_message(session, envelope) + await self.handle_message(message) +- return '250 OK' ++ return "250 OK" + + @abstractmethod +- async def handle_message(self, message): ++ async def handle_message(self, message: Em_Message) -> None: + raise NotImplementedError + + + @public + class Mailbox(Message): +- def __init__(self, mail_dir, message_class=None): ++ def __init__(self, mail_dir: os.PathLike, message_class: Type[Em_Message] = None): + self.mailbox = mailbox.Maildir(mail_dir) + self.mail_dir = mail_dir + super().__init__(message_class) + +- def handle_message(self, message): ++ def handle_message(self, message: Em_Message) -> None: + self.mailbox.add(message) + +- def reset(self): ++ def reset(self) -> None: + self.mailbox.clear() + + @classmethod +- def from_cli(cls, parser, *args): ++ def from_cli(cls: Type[T], parser: ArgumentParser, *args) -> T: + if len(args) < 1: +- parser.error('The directory for the maildir is required') ++ parser.error("The directory for the maildir is required") + elif len(args) > 1: +- parser.error('Too many arguments for Mailbox handler') ++ parser.error("Too many arguments for Mailbox handler") + return cls(args[0]) +diff --git a/aiosmtpd/lmtp.py b/aiosmtpd/lmtp.py +index 3f13af7..de68808 100644 +--- a/aiosmtpd/lmtp.py ++++ b/aiosmtpd/lmtp.py +@@ -11,14 +11,14 @@ class LMTP(SMTP): + show_smtp_greeting: bool = False + + @syntax('LHLO hostname') +- async def smtp_LHLO(self, arg): ++ async def smtp_LHLO(self, arg: str) -> None: + """The LMTP greeting, used instead of HELO/EHLO.""" + await super().smtp_EHLO(arg) + +- async def smtp_HELO(self, arg): ++ async def smtp_HELO(self, arg: str) -> None: + """HELO is not a valid LMTP command.""" + await self.push('500 Error: command "HELO" not recognized') + +- async def smtp_EHLO(self, arg): ++ async def smtp_EHLO(self, arg: str) -> None: + """EHLO is not a valid LMTP command.""" + await self.push('500 Error: command "EHLO" not recognized') +diff --git a/aiosmtpd/main.py b/aiosmtpd/main.py +index e978c60..2366ae4 100644 +--- a/aiosmtpd/main.py ++++ b/aiosmtpd/main.py +@@ -7,11 +7,12 @@ import os + import signal + import ssl + import sys +-from argparse import ArgumentParser ++from argparse import ArgumentParser, Namespace + from contextlib import suppress + from functools import partial + from importlib import import_module + from pathlib import Path ++from typing import Optional, Sequence, Tuple + + from public import public + +@@ -167,7 +168,7 @@ def _parser() -> ArgumentParser: + return parser + + +-def parseargs(args=None): ++def parseargs(args: Optional[Sequence[str]] = None) -> Tuple[ArgumentParser, Namespace]: + parser = _parser() + parsed = parser.parse_args(args) + # Find the handler class. +@@ -214,7 +215,7 @@ def parseargs(args=None): + + + @public +-def main(args=None): ++def main(args: Optional[Sequence[str]] = None) -> None: + parser, args = parseargs(args=args) + + if args.setuid: # pragma: on-win32 +@@ -285,10 +286,8 @@ def main(args=None): + loop.add_signal_handler(signal.SIGINT, loop.stop) + + log.debug("Starting asyncio loop") +- try: ++ with suppress(KeyboardInterrupt): + loop.run_forever() +- except KeyboardInterrupt: +- pass + server_loop.close() + log.debug("Completed asyncio loop") + loop.run_until_complete(server_loop.wait_closed()) +diff --git a/aiosmtpd/proxy_protocol.py b/aiosmtpd/proxy_protocol.py +index 621098c..27d202a 100644 +--- a/aiosmtpd/proxy_protocol.py ++++ b/aiosmtpd/proxy_protocol.py +@@ -1,6 +1,7 @@ + # Copyright 2014-2021 The aiosmtpd Developers + # SPDX-License-Identifier: Apache-2.0 + ++import contextlib + import logging + import re + import struct +@@ -8,7 +9,7 @@ from collections import deque + from enum import IntEnum + from functools import partial + from ipaddress import IPv4Address, IPv6Address, ip_address +-from typing import Any, AnyStr, Dict, Optional, Tuple, Union ++from typing import Any, AnyStr, ByteString, Dict, Optional, Tuple, Union + + import attr + from public import public +@@ -73,9 +74,10 @@ V2_PARSE_ADDR_FAMPRO = { + """Family & Proto combinations that need address parsing""" + + +-__all__ = [ ++__all__ = ["struct", "partial", "IPv4Address", "IPv6Address"] ++__all__.extend( + k for k in globals().keys() if k.startswith("V1_") or k.startswith("V2_") +-] + ["struct", "partial", "IPv4Address", "IPv6Address"] ++) + + + _NOT_FOUND = object() +@@ -144,10 +146,10 @@ class ProxyTLV(dict): + super().__init__(*args, **kwargs) + self.tlv_loc = _tlv_loc + +- def __getattr__(self, item): ++ def __getattr__(self, item: str) -> Any: + return self.get(item) + +- def __eq__(self, other): ++ def __eq__(self, other: Dict[str, Any]) -> bool: + return super().__eq__(other) + + def same_attribs(self, _raises: bool = False, **kwargs) -> bool: +@@ -175,7 +177,7 @@ class ProxyTLV(dict): + @classmethod + def parse( + cls, +- data: Union[bytes, bytearray], ++ data: ByteString, + partial_ok: bool = True, + strict: bool = False, + ) -> Tuple[Dict[str, Any], Dict[str, int]]: +@@ -189,7 +191,7 @@ class ProxyTLV(dict): + rslt: Dict[str, Any] = {} + tlv_loc: Dict[str, int] = {} + +- def _pars(chunk: Union[bytes, bytearray], *, offset: int): ++ def _pars(chunk: ByteString, *, offset: int) -> None: + i = 0 + while i < len(chunk): + typ = chunk[i] +@@ -228,7 +230,7 @@ class ProxyTLV(dict): + + @classmethod + def from_raw( +- cls, raw: Union[bytes, bytearray], strict: bool = False ++ cls, raw: ByteString, strict: bool = False + ) -> Optional["ProxyTLV"]: + """ + Parses raw bytes for TLV Vectors, decode them and giving them human-readable +@@ -275,7 +277,7 @@ class ProxyData: + dst_addr: Optional[EndpointAddress] = _anoinit(default=None) + src_port: Optional[int] = _anoinit(default=None) + dst_port: Optional[int] = _anoinit(default=None) +- rest: Union[bytes, bytearray] = _anoinit(default=b"") ++ rest: ByteString = _anoinit(default=b"") + """ + Rest of PROXY Protocol data following UNKNOWN (v1) or UNSPEC (v2), or containing + undecoded TLV (v2). If the latter, you can use the ProxyTLV class to parse the +@@ -302,12 +304,10 @@ class ProxyData: + return not (self.error or self.version is None or self.protocol is None) + + @property +- def tlv(self): ++ def tlv(self) -> Optional[ProxyTLV]: + if self._tlv is None: +- try: ++ with contextlib.suppress(MalformedTLV): + self._tlv = ProxyTLV.from_raw(self.rest) +- except MalformedTLV: +- pass + return self._tlv + + def with_error(self, error_msg: str, log_prefix: bool = True) -> "ProxyData": +@@ -340,7 +340,7 @@ class ProxyData: + return False + return True + +- def __bool__(self): ++ def __bool__(self) -> bool: + return self.valid + + +@@ -353,7 +353,7 @@ RE_PORT_NOLEADZERO = re.compile(r"^[1-9]\d{0,4}|0$") + # Reference: https://github.com/haproxy/haproxy/blob/v2.3.0/doc/proxy-protocol.txt + + +-async def _get_v1(reader: AsyncReader, initial=b"") -> ProxyData: ++async def _get_v1(reader: AsyncReader, initial: ByteString = b"") -> ProxyData: + proxy_data = ProxyData(version=1) + proxy_data.whole_raw = bytearray(initial) + +@@ -437,7 +437,7 @@ async def _get_v1(reader: AsyncReader, initial=b"") -> ProxyData: + return proxy_data + + +-async def _get_v2(reader: AsyncReader, initial=b"") -> ProxyData: ++async def _get_v2(reader: AsyncReader, initial: ByteString = b"") -> ProxyData: + proxy_data = ProxyData(version=2) + whole_raw = bytearray() + +diff --git a/aiosmtpd/qa/test_0packaging.py b/aiosmtpd/qa/test_0packaging.py +index 2240762..9dbb115 100644 +--- a/aiosmtpd/qa/test_0packaging.py ++++ b/aiosmtpd/qa/test_0packaging.py +@@ -2,8 +2,10 @@ + # SPDX-License-Identifier: Apache-2.0 + + """Test meta / packaging""" ++ + import re + import subprocess ++from datetime import datetime + from itertools import tee + from pathlib import Path + +@@ -15,6 +17,7 @@ from packaging import version + from aiosmtpd import __version__ + + RE_DUNDERVER = re.compile(r"__version__\s*?=\s*?(['\"])(?P[^'\"]+)\1\s*$") ++RE_VERHEADING = re.compile(r"(?P[0-9.]+)\s*\((?P[^)]+)\)") + + + @pytest.fixture +@@ -23,14 +26,16 @@ def aiosmtpd_version() -> version.Version: + + + class TestVersion: +- def test_pep440(self, aiosmtpd_version): ++ def test_pep440(self, aiosmtpd_version: version.Version): + """Ensure version number compliance to PEP-440""" + assert isinstance( + aiosmtpd_version, version.Version + ), "Version number must comply with PEP-440" + + # noinspection PyUnboundLocalVariable +- def test_ge_master(self, aiosmtpd_version, capsys): ++ def test_ge_master( ++ self, aiosmtpd_version: version.Version, capsys: pytest.CaptureFixture ++ ): + """Ensure version is monotonically increasing""" + reference = "master:aiosmtpd/__init__.py" + cmd = f"git show {reference}".split() +@@ -50,10 +55,11 @@ class TestVersion: + assert aiosmtpd_version >= master_ver, "Version number cannot be < master's" + + +-class TestDocs: +- def test_NEWS_version(self, aiosmtpd_version): +- news_rst = next(Path("..").rglob("*/NEWS.rst")) +- with open(news_rst, "rt") as fin: ++class TestNews: ++ news_rst = list(Path("..").rglob("*/NEWS.rst"))[0] ++ ++ def test_NEWS_version(self, aiosmtpd_version: version.Version): ++ with self.news_rst.open("rt") as fin: + # pairwise() from https://docs.python.org/3/library/itertools.html + a, b = tee(fin) + next(b, None) +@@ -73,3 +79,23 @@ class TestDocs: + f"NEWS.rst is not updated: " + f"{newsver.base_version} < {aiosmtpd_version.base_version}" + ) ++ ++ def test_release_date(self, aiosmtpd_version: version.Version): ++ if aiosmtpd_version.pre is not None: ++ pytest.skip("Not a release version") ++ with self.news_rst.open("rt") as fin: ++ for ln in fin: ++ ln = ln.strip() ++ m = RE_VERHEADING.match(ln) ++ if not m: ++ continue ++ ver = version.Version(m.group("ver")) ++ if ver != aiosmtpd_version: ++ continue ++ try: ++ datetime.strptime(m.group("date"), "%Y-%m-%d") ++ except ValueError: ++ pytest.fail("Release version not dated correctly") ++ break ++ else: ++ pytest.fail("Release version has no NEWS fragment") +diff --git a/aiosmtpd/qa/test_1testsuite.py b/aiosmtpd/qa/test_1testsuite.py +index e61a71d..db20c61 100644 +--- a/aiosmtpd/qa/test_1testsuite.py ++++ b/aiosmtpd/qa/test_1testsuite.py +@@ -19,7 +19,7 @@ RE_ESC = re.compile(rb"(?P\d)\.\d+\.\d+\s") + + # noinspection PyUnresolvedReferences + @pytest.fixture(scope="module", autouse=True) +-def exit_on_fail(request): ++def exit_on_fail(request: pytest.FixtureRequest): + # Behavior of this will be undefined if tests are running in parallel. + # But since parallel running is not practically possible (the ports will conflict), + # then I don't think that will be a problem. +@@ -65,7 +65,9 @@ class TestStatusCodes: + f"{key}: First digit of Enhanced Status Code different from " + f"first digit of Standard Status Code" + ) +- total_correct += 1 ++ # Can't use enumerate(); total_correct does not increase in lockstep with ++ # the loop (there are several "continue"s above) ++ total_correct += 1 # noqa: SIM113 + assert total_correct > 0 + + def test_commands(self): +diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py +index 04a3497..b985b64 100644 +--- a/aiosmtpd/smtp.py ++++ b/aiosmtpd/smtp.py +@@ -24,7 +24,9 @@ from typing import ( + List, + NamedTuple, + Optional, ++ Sequence, + Tuple, ++ TypeVar, + Union, + ) + from warnings import warn +@@ -39,7 +41,7 @@ from aiosmtpd.proxy_protocol import ProxyData, get_proxy + # region #### Custom Data Types ####################################################### + + class _Missing: +- def __repr__(self): ++ def __repr__(self) -> str: + return "MISSING" + + +@@ -59,6 +61,9 @@ AuthenticatorType = Callable[["SMTP", "Session", "Envelope", str, Any], "AuthRes + AuthMechanismType = Callable[["SMTP", List[str]], Awaitable[Any]] + _TriStateType = Union[None, _Missing, bytes] + ++RT = TypeVar("RT") # "ReturnType" ++DecoratorType = Callable[[Callable[..., RT]], Callable[..., RT]] ++ + + # endregion + +@@ -149,7 +154,7 @@ class LoginPassword(NamedTuple): + + @public + class Session: +- def __init__(self, loop): ++ def __init__(self, loop: asyncio.AbstractEventLoop): + self.peer = None + self.ssl = None + self.host_name = None +@@ -172,7 +177,7 @@ class Session: + self.authenticated = None + + @property +- def login_data(self): ++ def login_data(self) -> Any: + """Legacy login_data, usually containing the username""" + log.warning( + "Session.login_data is deprecated and will be removed in version 2.0" +@@ -180,7 +185,7 @@ class Session: + return self._login_data + + @login_data.setter +- def login_data(self, value): ++ def login_data(self, value: Any) -> None: + log.warning( + "Session.login_data is deprecated and will be removed in version 2.0" + ) +@@ -189,7 +194,7 @@ class Session: + + @public + class Envelope: +- def __init__(self): ++ def __init__(self) -> None: + self.mail_from = None + self.mail_options = [] + self.smtp_utf8 = False +@@ -202,12 +207,14 @@ class Envelope: + # This is here to enable debugging output when the -E option is given to the + # unit test suite. In that case, this function is mocked to set the debug + # level on the loop (as if PYTHONASYNCIODEBUG=1 were set). +-def make_loop(): ++def make_loop() -> asyncio.AbstractEventLoop: + return asyncio.get_event_loop() + + + @public +-def syntax(text, extended=None, when: Optional[str] = None): ++def syntax( ++ text: str, extended: str = None, when: Optional[str] = None ++) -> DecoratorType: + """ + A @decorator that provides helptext for (E)SMTP HELP. + Applies for smtp_* methods only! +@@ -217,7 +224,7 @@ def syntax(text, extended=None, when: Optional[str] = None): + :param when: The name of the attribute of SMTP class to check; if the value + of the attribute is false-y then HELP will not be available for the command + """ +- def decorator(f): ++ def decorator(f: Callable[..., RT]) -> Callable[..., RT]: + f.__smtp_syntax__ = text + f.__smtp_syntax_extended__ = extended + f.__smtp_syntax_when__ = when +@@ -226,7 +233,7 @@ def syntax(text, extended=None, when: Optional[str] = None): + + + @public +-def auth_mechanism(actual_name: str): ++def auth_mechanism(actual_name: str) -> DecoratorType: + """ + A @decorator to explicitly specifies the name of the AUTH mechanism implemented by + the function/method this decorates +@@ -234,9 +241,10 @@ def auth_mechanism(actual_name: str): + :param actual_name: Name of AUTH mechanism. Must consists of [A-Z0-9_-] only. + Will be converted to uppercase + """ +- def decorator(f): ++ def decorator(f: Callable[..., RT]) -> Callable[..., RT]: + f.__auth_mechanism_name__ = actual_name + return f ++ + actual_name = actual_name.upper() + if not VALID_AUTHMECH.match(actual_name): + raise ValueError(f"Invalid AUTH mechanism name: {actual_name}") +@@ -249,7 +257,7 @@ def login_always_fail( + return False + + +-def is_int(o): ++def is_int(o: Any) -> bool: + return isinstance(o, int) + + +@@ -267,7 +275,7 @@ def sanitize(text: bytes) -> bytes: + + + @public +-def sanitized_log(func: Callable, msg: AnyStr, *args, **kwargs): ++def sanitized_log(func: Callable[..., None], msg: AnyStr, *args, **kwargs) -> None: + """ + Sanitize args before passing to a logging function. + """ +@@ -305,24 +313,24 @@ class SMTP(asyncio.StreamReaderProtocol): + + def __init__( + self, +- handler, ++ handler: Any, + *, +- data_size_limit=DATA_SIZE_DEFAULT, +- enable_SMTPUTF8=False, +- decode_data=False, +- hostname=None, +- ident=None, ++ data_size_limit: int = DATA_SIZE_DEFAULT, ++ enable_SMTPUTF8: bool = False, ++ decode_data: bool = False, ++ hostname: str = None, ++ ident: str = None, + tls_context: Optional[ssl.SSLContext] = None, +- require_starttls=False, +- timeout=300, +- auth_required=False, +- auth_require_tls=True, ++ require_starttls: bool = False, ++ timeout: float = 300, ++ auth_required: bool = False, ++ auth_require_tls: bool = True, + auth_exclude_mechanism: Optional[Iterable[str]] = None, + auth_callback: AuthCallbackType = None, + command_call_limit: Union[int, Dict[str, int], None] = None, + authenticator: AuthenticatorType = None, + proxy_protocol_timeout: Optional[Union[int, float]] = None, +- loop=None ++ loop: asyncio.AbstractEventLoop = None + ): + self.__ident__ = ident or __ident__ + self.loop = loop if loop else make_loop() +@@ -343,7 +351,7 @@ class SMTP(asyncio.StreamReaderProtocol): + self.tls_context = tls_context + if tls_context: + if (tls_context.verify_mode +- not in {ssl.CERT_NONE, ssl.CERT_OPTIONAL}): ++ not in {ssl.CERT_NONE, ssl.CERT_OPTIONAL}): # noqa: DUO122 + log.warning("tls_context.verify_mode not in {CERT_NONE, " + "CERT_OPTIONAL}; this might cause client " + "connection problems") +@@ -452,13 +460,13 @@ class SMTP(asyncio.StreamReaderProtocol): + else: + raise TypeError("command_call_limit must be int or Dict[str, int]") + +- def _create_session(self): ++ def _create_session(self) -> Session: + return Session(self.loop) + +- def _create_envelope(self): ++ def _create_envelope(self) -> Envelope: + return Envelope() + +- async def _call_handler_hook(self, command, *args): ++ async def _call_handler_hook(self, command: str, *args) -> Any: + hook = self._handle_hooks.get(command) + if hook is None: + return MISSING +@@ -466,7 +474,7 @@ class SMTP(asyncio.StreamReaderProtocol): + return status + + @property +- def max_command_size_limit(self): ++ def max_command_size_limit(self) -> int: + try: + return max(self.command_size_limits.values()) + except ValueError: +@@ -484,7 +492,7 @@ class SMTP(asyncio.StreamReaderProtocol): + if closed.done() and not closed.cancelled(): + closed.exception() + +- def connection_made(self, transport): ++ def connection_made(self, transport: asyncio.transports.Transport) -> None: + # Reset state due to rfc3207 part 4.2. + self._set_rset_state() + self.session = self._create_session() +@@ -513,7 +521,7 @@ class SMTP(asyncio.StreamReaderProtocol): + self._handler_coroutine = self.loop.create_task( + self._handle_client()) + +- def connection_lost(self, error): ++ def connection_lost(self, error: Optional[Exception]) -> None: + log.info('%r connection lost', self.session.peer) + self._timeout_handle.cancel() + # If STARTTLS was issued, then our transport is the SSL protocol +@@ -527,7 +535,7 @@ class SMTP(asyncio.StreamReaderProtocol): + self._handler_coroutine.cancel() + self.transport = None + +- def eof_received(self): ++ def eof_received(self) -> bool: + log.info('%r EOF received', self.session.peer) + self._handler_coroutine.cancel() + if self.session.ssl is not None: +@@ -537,7 +545,7 @@ class SMTP(asyncio.StreamReaderProtocol): + return False + return super().eof_received() + +- def _reset_timeout(self, duration=None): ++ def _reset_timeout(self, duration: float = None) -> None: + if self._timeout_handle is not None: + self._timeout_handle.cancel() + self._timeout_handle = self.loop.call_later( +@@ -552,7 +560,9 @@ class SMTP(asyncio.StreamReaderProtocol): + # up state. + self.transport.close() + +- def _client_connected_cb(self, reader, writer): ++ def _client_connected_cb( ++ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ++ ): + # This is redundant since we subclass StreamReaderProtocol, but I like + # the shorter names. + self._reader = reader +@@ -577,7 +587,7 @@ class SMTP(asyncio.StreamReaderProtocol): + log.debug("%r << %r", self.session.peer, response) + await self._writer.drain() + +- async def handle_exception(self, error): ++ async def handle_exception(self, error: Exception) -> str: + if hasattr(self.event_handler, 'handle_exception'): + status = await self.event_handler.handle_exception(error) + return status +@@ -678,9 +688,11 @@ class SMTP(asyncio.StreamReaderProtocol): + await self.push('500 Error: strict ASCII mode') + # Should we await self.handle_exception()? + continue +- max_sz = (self.command_size_limits[command] +- if self.session.extended_smtp +- else self.command_size_limit) ++ max_sz = ( ++ self.command_size_limits[command] ++ if self.session.extended_smtp ++ else self.command_size_limit ++ ) + if len(line) > max_sz: + await self.push('500 Command line too long') + continue +@@ -720,7 +732,8 @@ class SMTP(asyncio.StreamReaderProtocol): + self.transport.close() + continue + await self.push( +- '500 Error: command "%s" not recognized' % command) ++ f'500 Error: command "{command}" not recognized' ++ ) + continue + + # Received a valid command, reset the timer. +@@ -785,7 +798,7 @@ class SMTP(asyncio.StreamReaderProtocol): + + # SMTP and ESMTP commands + @syntax('HELO hostname') +- async def smtp_HELO(self, hostname): ++ async def smtp_HELO(self, hostname: str): + if not hostname: + await self.push('501 Syntax: HELO hostname') + return +@@ -798,7 +811,7 @@ class SMTP(asyncio.StreamReaderProtocol): + await self.push(status) + + @syntax('EHLO hostname') +- async def smtp_EHLO(self, hostname): ++ async def smtp_EHLO(self, hostname: str): + if not hostname: + await self.push('501 Syntax: EHLO hostname') + return +@@ -806,9 +819,9 @@ class SMTP(asyncio.StreamReaderProtocol): + response = [] + self._set_rset_state() + self.session.extended_smtp = True +- response.append('250-%s' % self.hostname) ++ response.append('250-' + self.hostname) + if self.data_size_limit: +- response.append('250-SIZE %s' % self.data_size_limit) ++ response.append(f'250-SIZE {self.data_size_limit}') + self.command_size_limits['MAIL'] += 26 + if not self._decode_data: + response.append('250-8BITMIME') +@@ -848,12 +861,12 @@ class SMTP(asyncio.StreamReaderProtocol): + await self.push(r) + + @syntax('NOOP [ignored]') +- async def smtp_NOOP(self, arg): ++ async def smtp_NOOP(self, arg: str): + status = await self._call_handler_hook('NOOP', arg) + await self.push('250 OK' if status is MISSING else status) + + @syntax('QUIT') +- async def smtp_QUIT(self, arg): ++ async def smtp_QUIT(self, arg: str): + if arg: + await self.push('501 Syntax: QUIT') + else: +@@ -863,7 +876,7 @@ class SMTP(asyncio.StreamReaderProtocol): + self.transport.close() + + @syntax('STARTTLS', when='tls_context') +- async def smtp_STARTTLS(self, arg): ++ async def smtp_STARTTLS(self, arg: str): + if arg: + await self.push('501 Syntax: STARTTLS') + return +@@ -1032,7 +1045,7 @@ class SMTP(asyncio.StreamReaderProtocol): + encode_to_b64=False, + ) + +- def _authenticate(self, mechanism, auth_data) -> AuthResult: ++ def _authenticate(self, mechanism: str, auth_data: Any) -> AuthResult: + if self._authenticator is not None: + # self.envelope is likely still empty, but we'll pass it anyways to + # make the invocation similar to the one in _call_handler_hook +@@ -1093,7 +1106,7 @@ class SMTP(asyncio.StreamReaderProtocol): + assert password is not None + return self._authenticate("PLAIN", LoginPassword(login, password)) + +- async def auth_LOGIN(self, _, args: List[str]): ++ async def auth_LOGIN(self, _, args: List[str]) -> AuthResult: + login: _TriStateType + if len(args) == 1: + # Client sent only "AUTH LOGIN" +@@ -1117,13 +1130,13 @@ class SMTP(asyncio.StreamReaderProtocol): + + return self._authenticate("LOGIN", LoginPassword(login, password)) + +- def _strip_command_keyword(self, keyword, arg): ++ def _strip_command_keyword(self, keyword: str, arg: str) -> Optional[str]: + keylen = len(keyword) + if arg[:keylen].upper() == keyword: + return arg[keylen:].strip() + return None + +- def _getaddr(self, arg) -> Tuple[Optional[str], Optional[str]]: ++ def _getaddr(self, arg: str) -> Tuple[Optional[str], Optional[str]]: + """ + Try to parse address given in SMTP command. + +@@ -1145,7 +1158,9 @@ class SMTP(asyncio.StreamReaderProtocol): + return None, None + return address, rest + +- def _getparams(self, params): ++ def _getparams( ++ self, params: Sequence[str] ++ ) -> Optional[Dict[str, Union[str, bool]]]: + # Return params as dictionary. Return None if not all parameters + # appear to be syntactically valid according to RFC 1869. + result = {} +@@ -1156,7 +1171,8 @@ class SMTP(asyncio.StreamReaderProtocol): + result[param] = value if eq else True + return result + +- def _syntax_available(self, method): ++ # noinspection PyUnresolvedReferences ++ def _syntax_available(self, method: Callable) -> bool: + if not hasattr(method, '__smtp_syntax__'): + return False + if method.__smtp_syntax_when__: +@@ -1193,7 +1209,7 @@ class SMTP(asyncio.StreamReaderProtocol): + if arg: + address, params = self._getaddr(arg) + if address is None: +- await self.push('502 Could not VRFY %s' % arg) ++ await self.push('502 Could not VRFY ' + arg) + else: + status = await self._call_handler_hook('VRFY', address) + await self.push( +@@ -1314,7 +1330,7 @@ class SMTP(asyncio.StreamReaderProtocol): + await self.push(status) + + @syntax('RSET') +- async def smtp_RSET(self, arg): ++ async def smtp_RSET(self, arg: str): + if arg: + await self.push('501 Syntax: RSET') + return +@@ -1458,5 +1474,5 @@ class SMTP(asyncio.StreamReaderProtocol): + await self.push('250 OK' if status is MISSING else status) + + # Commands that have not been implemented. +- async def smtp_EXPN(self, arg): ++ async def smtp_EXPN(self, arg: str): + await self.push('502 EXPN not implemented') +diff --git a/aiosmtpd/testing/helpers.py b/aiosmtpd/testing/helpers.py +index 7fa62a2..2328704 100644 +--- a/aiosmtpd/testing/helpers.py ++++ b/aiosmtpd/testing/helpers.py +@@ -12,7 +12,7 @@ import time + from smtplib import SMTP as SMTP_Client + from typing import List + +-from aiosmtpd.smtp import Envelope ++from aiosmtpd.smtp import Envelope, Session, SMTP + + ASYNCIO_CATCHUP_DELAY = float(os.environ.get("ASYNCIO_CATCHUP_DELAY", 0.1)) + """ +@@ -52,12 +52,14 @@ class ReceivingHandler: + def __init__(self): + self.box = [] + +- async def handle_DATA(self, server, session, envelope): ++ async def handle_DATA( ++ self, server: SMTP, session: Session, envelope: Envelope ++ ) -> str: + self.box.append(envelope) + return "250 OK" + + +-def catchup_delay(delay=ASYNCIO_CATCHUP_DELAY): ++def catchup_delay(delay: float = ASYNCIO_CATCHUP_DELAY): + """ + Sleep for awhile to give asyncio's event loop time to catch up. + """ +@@ -65,7 +67,7 @@ def catchup_delay(delay=ASYNCIO_CATCHUP_DELAY): + + + def send_recv( +- sock: socket.socket, data: bytes, end: bytes = b"\r\n", timeout=0.1 ++ sock: socket.socket, data: bytes, end: bytes = b"\r\n", timeout: float = 0.1 + ) -> bytes: + sock.send(data + end) + slist = [sock] +diff --git a/aiosmtpd/tests/conftest.py b/aiosmtpd/tests/conftest.py +index d0a6cd3..859d5ef 100644 +--- a/aiosmtpd/tests/conftest.py ++++ b/aiosmtpd/tests/conftest.py +@@ -8,10 +8,11 @@ import ssl + from contextlib import suppress + from functools import wraps + from smtplib import SMTP as SMTPClient +-from typing import Generator, NamedTuple, Optional, Type ++from typing import Any, Callable, Generator, NamedTuple, Optional, Type, TypeVar + + import pytest + from pkg_resources import resource_filename ++from pytest_mock import MockFixture + + from aiosmtpd.controller import Controller + from aiosmtpd.handlers import Sink +@@ -50,6 +51,9 @@ class HostPort(NamedTuple): + port: int = 8025 + + ++RT = TypeVar("RT") # "ReturnType" ++ ++ + # endregion + + +@@ -79,15 +83,13 @@ SERVER_KEY = resource_filename("aiosmtpd.tests.certs", "server.key") + + # autouse=True and scope="session" automatically apply this fixture to ALL test cases + @pytest.fixture(autouse=True, scope="session") +-def cache_fqdn(session_mocker): ++def cache_fqdn(session_mocker: MockFixture): + """ + This fixture "caches" the socket.getfqdn() call. VERY necessary to prevent + situations where quick repeated getfqdn() causes extreme slowdown. Probably due to + the DNS server thinking it was an attack or something. + """ + session_mocker.patch("socket.getfqdn", return_value=Global.FQDN) +- # +- yield + + + # endregion +@@ -97,7 +99,7 @@ def cache_fqdn(session_mocker): + + + @pytest.fixture +-def get_controller(request): ++def get_controller(request: pytest.FixtureRequest) -> Callable[..., Controller]: + """ + Provides a function that will return an instance of a controller. + +@@ -122,7 +124,7 @@ def get_controller(request): + markerdata = {} + + def getter( +- handler, ++ handler: Any, + class_: Optional[Type[Controller]] = None, + **server_kwargs, + ) -> Controller: +@@ -154,7 +156,7 @@ def get_controller(request): + + + @pytest.fixture +-def get_handler(request): ++def get_handler(request: pytest.FixtureRequest) -> Callable: + """ + Provides a function that will return an instance of + a :ref:`handler class `. +@@ -179,7 +181,7 @@ def get_handler(request): + else: + markerdata = {} + +- def getter(*args, **kwargs): ++ def getter(*args, **kwargs) -> Any: + if marker: + class_ = markerdata.pop("class_", default_class) + # *args overrides args_ in handler_data() +@@ -209,18 +211,22 @@ def temp_event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: + + + @pytest.fixture +-def autostop_loop(temp_event_loop) -> Generator[asyncio.AbstractEventLoop, None, None]: ++def autostop_loop( ++ temp_event_loop: asyncio.AbstractEventLoop, ++) -> asyncio.AbstractEventLoop: + # Create a new event loop, and arrange for that loop to end almost + # immediately. This will allow the calls to main() in these tests to + # also exit almost immediately. Otherwise, the foreground test + # process will hang. + temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop) + # +- yield temp_event_loop ++ return temp_event_loop + + + @pytest.fixture +-def plain_controller(get_handler, get_controller) -> Generator[Controller, None, None]: ++def plain_controller( ++ get_handler: Callable, get_controller: Callable ++) -> Generator[Controller, None, None]: + """ + Returns a Controller that, by default, gets invoked with no optional args. + Hence the moniker "plain". +@@ -246,7 +252,7 @@ def plain_controller(get_handler, get_controller) -> Generator[Controller, None, + + @pytest.fixture + def nodecode_controller( +- get_handler, get_controller ++ get_handler: Callable, get_controller: Callable + ) -> Generator[Controller, None, None]: + """ + Same as :fixture:`plain_controller`, +@@ -268,7 +274,7 @@ def nodecode_controller( + + @pytest.fixture + def decoding_controller( +- get_handler, get_controller ++ get_handler: Callable, get_controller: Callable + ) -> Generator[Controller, None, None]: + handler = get_handler() + controller = get_controller(handler, decode_data=True) +@@ -285,7 +291,7 @@ def decoding_controller( + + + @pytest.fixture +-def client(request) -> Generator[SMTPClient, None, None]: ++def client(request: pytest.FixtureRequest) -> Generator[SMTPClient, None, None]: + """ + Generic SMTP Client, + will connect to the ``host:port`` defined in ``Global.SrvAddr`` +@@ -302,7 +308,7 @@ def client(request) -> Generator[SMTPClient, None, None]: + + + @pytest.fixture +-def ssl_context_server() -> Generator[ssl.SSLContext, None, None]: ++def ssl_context_server() -> ssl.SSLContext: + """ + Provides a server-side SSL Context + """ +@@ -310,11 +316,11 @@ def ssl_context_server() -> Generator[ssl.SSLContext, None, None]: + context.check_hostname = False + context.load_cert_chain(SERVER_CRT, SERVER_KEY) + # +- yield context ++ return context + + + @pytest.fixture +-def ssl_context_client() -> Generator[ssl.SSLContext, None, None]: ++def ssl_context_client() -> ssl.SSLContext: + """ + Provides a client-side SSL Context + """ +@@ -322,14 +328,14 @@ def ssl_context_client() -> Generator[ssl.SSLContext, None, None]: + context.check_hostname = False + context.load_verify_locations(SERVER_CRT) + # +- yield context ++ return context + + + # Please keep the scope as "module"; setting it as "function" (the default) somehow + # causes the 'hidden' exception to be detected when the loop starts over in the next + # test case, defeating the silencing. + @pytest.fixture(scope="module") +-def silence_event_loop_closed(): ++def silence_event_loop_closed() -> bool: + """ + Mostly used to suppress "unhandled exception" error due to + ``_ProactorBasePipeTransport`` raising an exception when doing ``__del__`` +@@ -341,9 +347,9 @@ def silence_event_loop_closed(): + return True + + # From: https://github.com/aio-libs/aiohttp/issues/4324#issuecomment-733884349 +- def silencer(func): ++ def silencer(func: Callable[..., RT]) -> Callable[..., RT]: + @wraps(func) +- def wrapper(self, *args, **kwargs): ++ def wrapper(self: Any, *args, **kwargs) -> RT: + try: + return func(self, *args, **kwargs) + except RuntimeError as e: +diff --git a/aiosmtpd/tests/test_handlers.py b/aiosmtpd/tests/test_handlers.py +index 51e06ce..35bd661 100644 +--- a/aiosmtpd/tests/test_handlers.py ++++ b/aiosmtpd/tests/test_handlers.py +@@ -3,6 +3,7 @@ + + import logging + import sys ++from email.message import Message as Em_Message + from io import StringIO + from mailbox import Maildir + from operator import itemgetter +@@ -10,14 +11,16 @@ from pathlib import Path + from smtplib import SMTPDataError, SMTPRecipientsRefused + from textwrap import dedent + from types import SimpleNamespace +-from typing import AnyStr, Generator, Type, TypeVar, Union ++from typing import AnyStr, Callable, Generator, Type, TypeVar, Union + + import pytest + + from aiosmtpd.controller import Controller + from aiosmtpd.handlers import AsyncMessage, Debugging, Mailbox, Proxy, Sink ++from aiosmtpd.handlers import Message as AbstractMessageHandler + from aiosmtpd.smtp import SMTP as Server + from aiosmtpd.smtp import Session as ServerSession ++from aiosmtpd.smtp import Envelope + from aiosmtpd.testing.statuscodes import SMTP_STATUS_CODES as S + from aiosmtpd.testing.statuscodes import StatusCode + +@@ -54,7 +57,7 @@ class FakeParser: + + message: AnyStr = None + +- def error(self, message): ++ def error(self, message: AnyStr): + self.message = message + raise SystemExit + +@@ -63,16 +66,23 @@ class DataHandler: + content: AnyStr = None + original_content: bytes = None + +- async def handle_DATA(self, server, session, envelope): ++ async def handle_DATA( ++ self, server: Server, session: ServerSession, envelope: Envelope ++ ) -> str: + self.content = envelope.content + self.original_content = envelope.original_content + return S.S250_OK.to_str() + + ++class MessageHandler(AbstractMessageHandler): ++ def handle_message(self, message: Em_Message) -> None: ++ pass ++ ++ + class AsyncMessageHandler(AsyncMessage): +- handled_message = None ++ handled_message: Em_Message = None + +- async def handle_message(self, message): ++ async def handle_message(self, message: Em_Message) -> None: + self.handled_message = message + + +@@ -209,14 +219,13 @@ def debugging_controller(get_controller) -> Generator[Controller, None, None]: + + + @pytest.fixture +-def temp_maildir(tmp_path: Path) -> Generator[Path, None, None]: +- maildir_path = tmp_path / "maildir" +- yield maildir_path ++def temp_maildir(tmp_path: Path) -> Path: ++ return tmp_path / "maildir" + + + @pytest.fixture + def mailbox_controller( +- temp_maildir, get_controller ++ temp_maildir, get_controller + ) -> Generator[Controller, None, None]: + handler = Mailbox(temp_maildir) + controller = get_controller(handler) +@@ -229,7 +238,7 @@ def mailbox_controller( + + + @pytest.fixture +-def with_fake_parser(): ++def with_fake_parser() -> Callable: + """ + Gets a function that will instantiate a handler_class using the class's + from_cli() @classmethod, using FakeParser as the parser. +@@ -250,7 +259,7 @@ def with_fake_parser(): + handler = SimpleNamespace(fparser=parser, exception=type(e)) + return handler + +- yield handler_initer ++ return handler_initer + + + @pytest.fixture +@@ -435,6 +444,43 @@ class TestDebugging: + + + class TestMessage: ++ @pytest.mark.parametrize( ++ "content", ++ [ ++ b"", ++ bytearray(), ++ "", ++ ], ++ ids=["bytes", "bytearray", "str"] ++ ) ++ def test_prepare_message(self, temp_event_loop, content): ++ sess_ = ServerSession(temp_event_loop) ++ enve_ = Envelope() ++ handler = MessageHandler() ++ enve_.content = content ++ msg = handler.prepare_message(sess_, enve_) ++ assert isinstance(msg, Em_Message) ++ assert msg.keys() == ['X-Peer', 'X-MailFrom', 'X-RcptTo'] ++ assert msg.get_payload() == "" ++ ++ @pytest.mark.parametrize( ++ ("content", "expectre"), ++ [ ++ (None, r"Expected str or bytes, got "), ++ ([], r"Expected str or bytes, got "), ++ ({}, r"Expected str or bytes, got "), ++ ((), r"Expected str or bytes, got "), ++ ], ++ ids=("None", "List", "Dict", "Tuple") ++ ) ++ def test_prepare_message_err(self, temp_event_loop, content, expectre): ++ sess_ = ServerSession(temp_event_loop) ++ enve_ = Envelope() ++ handler = MessageHandler() ++ enve_.content = content ++ with pytest.raises(TypeError, match=expectre): ++ _ = handler.prepare_message(sess_, enve_) ++ + @handler_data(class_=DataHandler) + def test_message(self, plain_controller, client): + handler = plain_controller.handler +@@ -585,11 +631,8 @@ class TestMailbox: + # Check the messages in the mailbox. + mailbox = Maildir(temp_maildir) + messages = sorted(mailbox, key=itemgetter("message-id")) +- assert list(message["message-id"] for message in messages) == [ +- "", +- "", +- "", +- ] ++ expect = ["", "", ""] ++ assert [message["message-id"] for message in messages] == expect + + def test_mailbox_reset(self, temp_maildir, mailbox_controller, client): + client.sendmail( +@@ -766,7 +809,6 @@ class TestProxyMocked: + def patch_smtp_oserror(self, mocker): + mock = mocker.patch("aiosmtpd.handlers.smtplib.SMTP") + mock().sendmail.side_effect = OSError +- yield + + def test_oserror( + self, caplog, patch_smtp_oserror, proxy_decoding_controller, client +@@ -804,13 +846,13 @@ class TestHooks: + + def test_hook_EHLO_deprecated_warning(self): + with pytest.warns( +- DeprecationWarning, +- match=( +- # Is a regex; escape regex special chars if necessary +- r"Use the 5-argument handle_EHLO\(\) hook instead of the " +- r"4-argument handle_EHLO\(\) hook; support for the 4-argument " +- r"handle_EHLO\(\) hook will be removed in version 2.0" +- ) ++ DeprecationWarning, ++ match=( ++ # Is a regex; escape regex special chars if necessary ++ r"Use the 5-argument handle_EHLO\(\) hook instead of the " ++ r"4-argument handle_EHLO\(\) hook; support for the 4-argument " ++ r"handle_EHLO\(\) hook will be removed in version 2.0" ++ ), + ): + _ = Server(EHLOHandlerDeprecated()) + +diff --git a/aiosmtpd/tests/test_main.py b/aiosmtpd/tests/test_main.py +index 36992f3..e6b3868 100644 +--- a/aiosmtpd/tests/test_main.py ++++ b/aiosmtpd/tests/test_main.py +@@ -7,11 +7,13 @@ import multiprocessing as MP + import os + import time + from contextlib import contextmanager ++from multiprocessing.synchronize import Event as MP_Event + from smtplib import SMTP as SMTPClient + from smtplib import SMTP_SSL + from typing import Generator + + import pytest ++from pytest_mock import MockFixture + + from aiosmtpd import __version__ + from aiosmtpd.handlers import Debugging +@@ -33,7 +35,7 @@ MAIL_LOG = logging.getLogger("mail.log") + + + class FromCliHandler: +- def __init__(self, called): ++ def __init__(self, called: bool): + self.called = called + + @classmethod +@@ -63,14 +65,12 @@ def nobody_uid() -> Generator[int, None, None]: + + + @pytest.fixture +-def setuid(mocker): ++def setuid(mocker: MockFixture): + if not HAS_SETUID: + pytest.skip("setuid is unavailable") + mocker.patch("aiosmtpd.main.pwd", None) + mocker.patch("os.setuid", side_effect=PermissionError) + mocker.patch("aiosmtpd.main.partial", side_effect=RuntimeError) +- # +- yield + + + # endregion +@@ -78,7 +78,7 @@ def setuid(mocker): + # region ##### Helper Funcs ########################################################### + + +-def watch_for_tls(ready_flag, retq: MP.Queue): ++def watch_for_tls(ready_flag: MP_Event, retq: MP.Queue): + has_tls = False + req_tls = False + ready_flag.set() +@@ -100,7 +100,7 @@ def watch_for_tls(ready_flag, retq: MP.Queue): + retq.put(req_tls) + + +-def watch_for_smtps(ready_flag, retq: MP.Queue): ++def watch_for_smtps(ready_flag: MP_Event, retq: MP.Queue): + has_smtps = False + ready_flag.set() + start = time.monotonic() +@@ -276,7 +276,7 @@ class TestParseArgs: + ) + + @pytest.mark.parametrize( +- "args, exp_host, exp_port", ++ ("args", "exp_host", "exp_port"), + [ + ((), "localhost", 8025), + (("-l", "foo:25"), "foo", 25), +@@ -333,7 +333,7 @@ class TestParseArgs: + assert args.requiretls is False + + @pytest.mark.parametrize( +- "certfile, keyfile, expect", ++ ("certfile", "keyfile", "expect"), + [ + ("x", "x", "Cert file x not found"), + (SERVER_CRT, "x", "Key file x not found"), +diff --git a/aiosmtpd/tests/test_proxyprotocol.py b/aiosmtpd/tests/test_proxyprotocol.py +index bf7f939..ad9dc9a 100644 +--- a/aiosmtpd/tests/test_proxyprotocol.py ++++ b/aiosmtpd/tests/test_proxyprotocol.py +@@ -10,12 +10,12 @@ import socket + import struct + import time + from base64 import b64decode +-from contextlib import contextmanager ++from contextlib import contextmanager, suppress + from functools import partial + from ipaddress import IPv4Address, IPv6Address + from smtplib import SMTP as SMTPClient + from smtplib import SMTPServerDisconnected +-from typing import Any, Dict, List, Optional ++from typing import Any, Callable, Dict, List, Optional + + import pytest + from pytest_mock import MockFixture +@@ -35,6 +35,7 @@ from aiosmtpd.proxy_protocol import ( + ) + from aiosmtpd.smtp import SMTP as SMTPServer + from aiosmtpd.smtp import Session as SMTPSession ++from aiosmtpd.smtp import Envelope as SMTPEnvelope + from aiosmtpd.tests.conftest import Global, controller_data, handler_data + + DEFAULT_AUTOCANCEL = 0.1 +@@ -94,13 +95,19 @@ HANDSHAKES = { + + + class ProxyPeekerHandler(Sink): +- def __init__(self, retval=True): ++ def __init__(self, retval: bool = True): + self.called = False + self.sessions: List[SMTPSession] = [] + self.proxy_datas: List[ProxyData] = [] + self.retval = retval + +- async def handle_PROXY(self, server, session, envelope, proxy_data): ++ async def handle_PROXY( ++ self, ++ server: SMTPServer, ++ session: SMTPSession, ++ envelope: SMTPEnvelope, ++ proxy_data: ProxyData, ++ ) -> bool: + self.called = True + self.sessions.append(session) + self.proxy_datas.append(proxy_data) +@@ -113,7 +120,9 @@ def does_not_raise(): + + + @pytest.fixture +-def setup_proxy_protocol(mocker: MockFixture, temp_event_loop): ++def setup_proxy_protocol( ++ mocker: MockFixture, temp_event_loop: asyncio.AbstractEventLoop ++) -> Callable: + proxy_timeout = 1.0 + responses = [] + transport = mocker.Mock() +@@ -129,16 +138,14 @@ def setup_proxy_protocol(mocker: MockFixture, temp_event_loop): + + def runner(stop_after: float = DEFAULT_AUTOCANCEL): + loop.call_later(stop_after, protocol._handler_coroutine.cancel) +- try: ++ with suppress(asyncio.CancelledError): + loop.run_until_complete(protocol._handler_coroutine) +- except asyncio.CancelledError: +- pass + + test_obj.protocol = protocol + test_obj.runner = runner + test_obj.transport = transport + +- yield getter ++ return getter + + + class _TestProxyProtocolCommon: +@@ -303,7 +310,7 @@ class TestProxyTLV: + (None, "wrongname"), + ], + ) +- def test_backmap(self, typename, typeint): ++ def test_backmap(self, typename: str, typeint: int): + assert ProxyTLV.name_to_num(typename) == typeint + + def test_parse_partial(self): +@@ -384,14 +391,23 @@ class TestModule: + return emit + + @parametrize("handshake", HANDSHAKES.values(), ids=HANDSHAKES.keys()) +- def test_get(self, caplog, temp_event_loop, handshake): ++ def test_get( ++ self, ++ caplog: pytest.LogCaptureFixture, ++ temp_event_loop: asyncio.AbstractEventLoop, ++ handshake: bytes, ++ ): + caplog.set_level(logging.DEBUG) + mock_reader = self.MockAsyncReader(handshake) + reslt = temp_event_loop.run_until_complete(get_proxy(mock_reader)) + assert isinstance(reslt, ProxyData) + assert reslt.valid + +- def test_get_cut_v1(self, caplog, temp_event_loop): ++ def test_get_cut_v1( ++ self, ++ caplog: pytest.LogCaptureFixture, ++ temp_event_loop: asyncio.AbstractEventLoop, ++ ): + caplog.set_level(logging.DEBUG) + mock_reader = self.MockAsyncReader(GOOD_V1_HANDSHAKE[0:20]) + reslt = temp_event_loop.run_until_complete(get_proxy(mock_reader)) +@@ -401,7 +417,11 @@ class TestModule: + expect = ("mail.debug", 30, "PROXY error: PROXYv1 malformed") + assert expect in caplog.record_tuples + +- def test_get_cut_v2(self, caplog, temp_event_loop): ++ def test_get_cut_v2( ++ self, ++ caplog: pytest.LogCaptureFixture, ++ temp_event_loop: asyncio.AbstractEventLoop, ++ ): + caplog.set_level(logging.DEBUG) + mock_reader = self.MockAsyncReader(TEST_V2_DATA1_EXACT[0:20]) + reslt = temp_event_loop.run_until_complete(get_proxy(mock_reader)) +@@ -412,7 +432,11 @@ class TestModule: + expect = ("mail.debug", 30, expect_msg) + assert expect in caplog.record_tuples + +- def test_get_invalid_sig(self, caplog, temp_event_loop): ++ def test_get_invalid_sig( ++ self, ++ caplog: pytest.LogCaptureFixture, ++ temp_event_loop: asyncio.AbstractEventLoop, ++ ): + caplog.set_level(logging.DEBUG) + mock_reader = self.MockAsyncReader(b"PROXI TCP4 1.2.3.4 5.6.7.8 9 10\r\n") + reslt = temp_event_loop.run_until_complete(get_proxy(mock_reader)) +@@ -451,7 +475,7 @@ class TestGetV1(_TestProxyProtocolCommon): + assert self.transport.close.called + + @parametrize("patt", PUBLIC_V1_PATTERNS.values(), ids=PUBLIC_V1_PATTERNS.keys()) +- def test_valid_patterns(self, setup_proxy_protocol, patt: bytes): ++ def test_valid_patterns(self, setup_proxy_protocol: Callable, patt: bytes): + if not patt.endswith(b"\r\n"): + patt += b"\r\n" + setup_proxy_protocol(self) +@@ -1004,7 +1028,7 @@ class TestWithController: + # Try resending the handshake. Should also fail (because connection has + # been closed by the server. + # noinspection PyTypeChecker +- with pytest.raises(OSError) as exc_info: ++ with pytest.raises(OSError) as exc_info: # noqa: PT011 + sock.send(handshake) + resp = sock.recv(4096) + if resp == b"": +@@ -1041,7 +1065,7 @@ class TestWithController: + # Try resending the handshake. Should also fail (because connection has + # been closed by the server. + # noinspection PyTypeChecker +- with pytest.raises(OSError) as exc_info: ++ with pytest.raises(OSError) as exc_info: # noqa: PT011 + sock.send(handshake) + resp = sock.recv(4096) + if resp == b"": +@@ -1094,8 +1118,7 @@ class TestHandlerAcceptReject: + sock.sendall(handshake) + resp = sock.recv(4096) + assert oper(resp, b"") +- with expect: +- with SMTPClient() as client: +- client.sock = sock +- code, mesg = client.ehlo("example.org") +- assert code == 250 ++ with expect, SMTPClient() as client: ++ client.sock = sock ++ code, mesg = client.ehlo("example.org") ++ assert code == 250 +diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py +index 41225dc..5e27070 100644 +--- a/aiosmtpd/tests/test_server.py ++++ b/aiosmtpd/tests/test_server.py +@@ -10,6 +10,7 @@ import socket + import time + from contextlib import ExitStack + from functools import partial ++from threading import Event + from pathlib import Path + from smtplib import SMTP as SMTPClient, SMTPServerDisconnected + from tempfile import mkdtemp +@@ -40,7 +41,7 @@ class SlowStartController(Controller): + kwargs.setdefault("ready_timeout", 0.5) + super().__init__(*args, **kwargs) + +- def _run(self, ready_event): ++ def _run(self, ready_event: Event): + time.sleep(self.ready_timeout * 1.5) + super()._run(ready_event) + +@@ -88,7 +89,7 @@ def safe_socket_dir() -> Generator[Path, None, None]: + # + yield tmpdir + # +- plist = [p for p in tmpdir.rglob("*")] ++ plist = list(tmpdir.rglob("*")) + for p in reversed(plist): + if p.is_dir(): + p.rmdir() +@@ -97,7 +98,7 @@ def safe_socket_dir() -> Generator[Path, None, None]: + tmpdir.rmdir() + + +-def assert_smtp_socket(controller: UnixSocketMixin): ++def assert_smtp_socket(controller: UnixSocketMixin) -> bool: + assert Path(controller.unix_socket).exists() + sockfile = controller.unix_socket + ssl_context = controller.ssl_context +@@ -134,6 +135,7 @@ def assert_smtp_socket(controller: UnixSocketMixin): + catchup_delay() + resp = sock.recv(1024) + assert resp.startswith(b"221") ++ return True + + + class TestServer: +@@ -207,8 +209,9 @@ class TestController: + contr2 = Controller( + Sink(), hostname=Global.SrvAddr.host, port=Global.SrvAddr.port + ) ++ expectedre = r"error while attempting to bind on address" + try: +- with pytest.raises(socket.error): ++ with pytest.raises(socket.error, match=expectedre): + contr2.start() + finally: + contr2.stop() +@@ -526,6 +529,7 @@ class TestUnthreaded: + assert temp_event_loop.is_closed() is False + + ++@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") + class TestFactory: + def test_normal_situation(self): + cont = Controller(Sink()) +@@ -537,8 +541,7 @@ class TestFactory: + finally: + cont.stop() + +- @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +- def test_unknown_args_direct(self, silence_event_loop_closed): ++ def test_unknown_args_direct(self, silence_event_loop_closed: bool): + unknown = "this_is_an_unknown_kwarg" + cont = Controller(Sink(), ready_timeout=0.3, **{unknown: True}) + expectedre = r"__init__.. got an unexpected keyword argument '" + unknown + r"'" +@@ -553,8 +556,7 @@ class TestFactory: + @pytest.mark.filterwarnings( + "ignore:server_kwargs will be removed:DeprecationWarning" + ) +- @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +- def test_unknown_args_inkwargs(self, silence_event_loop_closed): ++ def test_unknown_args_inkwargs(self, silence_event_loop_closed: bool): + unknown = "this_is_an_unknown_kwarg" + cont = Controller(Sink(), ready_timeout=0.3, server_kwargs={unknown: True}) + expectedre = r"__init__.. got an unexpected keyword argument '" + unknown + r"'" +@@ -565,8 +567,7 @@ class TestFactory: + finally: + cont.stop() + +- @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +- def test_factory_none(self, mocker: MockFixture, silence_event_loop_closed): ++ def test_factory_none(self, mocker: MockFixture, silence_event_loop_closed: bool): + # Hypothetical situation where factory() did not raise an Exception + # but returned None instead + mocker.patch("aiosmtpd.controller.SMTP", return_value=None) +@@ -579,8 +580,9 @@ class TestFactory: + finally: + cont.stop() + +- @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +- def test_noexc_smtpd_missing(self, mocker, silence_event_loop_closed): ++ def test_noexc_smtpd_missing( ++ self, mocker: MockFixture, silence_event_loop_closed: bool ++ ): + # Hypothetical situation where factory() failed but no + # Exception was generated. + cont = Controller(Sink()) +diff --git a/aiosmtpd/tests/test_smtp.py b/aiosmtpd/tests/test_smtp.py +index 6fd8bfb..0fb3a15 100644 +--- a/aiosmtpd/tests/test_smtp.py ++++ b/aiosmtpd/tests/test_smtp.py +@@ -9,6 +9,7 @@ import logging + import socket + import time + import warnings ++from asyncio.transports import Transport + from base64 import b64encode + from contextlib import suppress + from smtplib import ( +@@ -19,7 +20,7 @@ from smtplib import ( + SMTPServerDisconnected, + ) + from textwrap import dedent +-from typing import Any, AnyStr, Callable, Generator, List, Tuple ++from typing import cast, Any, AnyStr, Callable, Generator, List, Tuple + + import pytest + from pytest_mock import MockFixture +@@ -62,10 +63,7 @@ MAIL_LOG.setLevel(logging.DEBUG) + + + def auth_callback(mechanism, login, password) -> bool: +- if login and login.decode() == "goodlogin": +- return True +- else: +- return False ++ return login and login.decode() == "goodlogin" + + + def assert_nopassleak(passwd: str, record_tuples: List[Tuple[str, int, str]]): +@@ -87,7 +85,7 @@ class UndescribableError(Exception): + class ErrorSMTP(Server): + exception_type = ValueError + +- async def smtp_HELO(self, hostname): ++ async def smtp_HELO(self, hostname: str): + raise self.exception_type("test") + + +@@ -136,8 +134,13 @@ class PeekerHandler: + return AuthResult(success=True, auth_data=login_data) + + async def handle_MAIL( +- self, server, session: SMTPSession, envelope, address, mail_options +- ): ++ self, ++ server: Server, ++ session: SMTPSession, ++ envelope: SMTPEnvelope, ++ address: str, ++ mail_options: dict, ++ ) -> str: + self.sess = session + return S.S250_OK.to_str() + +@@ -157,7 +160,7 @@ class PeekerHandler: + async def auth_DONT(self, server, args): + return MISSING + +- async def auth_WITH_UNDERSCORE(self, server: Server, args): ++ async def auth_WITH_UNDERSCORE(self, server: Server, args) -> str: + """ + Be careful when using this AUTH mechanism; log_client_response is set to + True, and this will raise some severe warnings. +@@ -180,7 +183,9 @@ class StoreEnvelopeOnVRFYHandler: + + envelope = None + +- async def handle_VRFY(self, server, session, envelope, addr): ++ async def handle_VRFY( ++ self, server: Server, session: SMTPSession, envelope: SMTPEnvelope, addr: str ++ ) -> str: + self.envelope = envelope + return S.S250_OK.to_str() + +@@ -189,10 +194,10 @@ class ErroringHandler: + error = None + custom_response = False + +- async def handle_DATA(self, server, session, envelope): ++ async def handle_DATA(self, server, session, envelope) -> str: + return "499 Could not accept the message" + +- async def handle_exception(self, error): ++ async def handle_exception(self, error) -> str: + self.error = error + if not self.custom_response: + return "500 ErroringHandler handling error" +@@ -215,7 +220,7 @@ class ErroringHandlerConnectionLost: + class ErroringErrorHandler: + error = None + +- async def handle_exception(self, error): ++ async def handle_exception(self, error: Exception): + self.error = error + raise ValueError("ErroringErrorHandler test") + +@@ -223,13 +228,19 @@ class ErroringErrorHandler: + class UndescribableErrorHandler: + error = None + +- async def handle_exception(self, error): ++ async def handle_exception(self, error: Exception): + self.error = error + raise UndescribableError() + + + class SleepingHeloHandler: +- async def handle_HELO(self, server, session, envelope, hostname): ++ async def handle_HELO( ++ self, ++ server: Server, ++ session: SMTPSession, ++ envelope: SMTPEnvelope, ++ hostname: str, ++ ) -> str: + await asyncio.sleep(0.01) + session.host_name = hostname + return "250 {}".format(server.hostname) +@@ -267,8 +278,7 @@ class CustomIdentController(Controller): + ident: bytes = b"Identifying SMTP v2112" + + def factory(self): +- server = Server(self.handler, ident=self.ident.decode()) +- return server ++ return Server(self.handler, ident=self.ident.decode()) + + + # endregion +@@ -278,18 +288,19 @@ class CustomIdentController(Controller): + + + @pytest.fixture +-def transport_resp(mocker: MockFixture): ++def transport_resp(mocker: MockFixture) -> Tuple[Transport, list]: + responses = [] + mocked = mocker.Mock() + mocked.write = responses.append + # +- yield mocked, responses ++ return cast(Transport, mocked), responses + + + @pytest.fixture + def get_protocol( +- temp_event_loop, transport_resp +-) -> Generator[Callable[..., Server], None, None]: ++ temp_event_loop: asyncio.AbstractEventLoop, ++ transport_resp: Any, ++) -> Callable[..., Server]: + transport, _ = transport_resp + + def getter(*args, **kwargs) -> Server: +@@ -297,14 +308,16 @@ def get_protocol( + proto.connection_made(transport) + return proto + +- yield getter ++ return getter + + + # region #### Fixtures: Controllers ################################################## + + + @pytest.fixture +-def auth_peeker_controller(get_controller) -> Generator[Controller, None, None]: ++def auth_peeker_controller( ++ get_controller: Callable[..., Controller] ++) -> Generator[Controller, None, None]: + handler = PeekerHandler() + controller = get_controller( + handler, +@@ -324,7 +337,7 @@ def auth_peeker_controller(get_controller) -> Generator[Controller, None, None]: + + @pytest.fixture + def authenticator_peeker_controller( +- get_controller, ++ get_controller: Callable[..., Controller] + ) -> Generator[Controller, None, None]: + handler = PeekerHandler() + controller = get_controller( +@@ -345,7 +358,8 @@ def authenticator_peeker_controller( + + @pytest.fixture + def decoding_authnotls_controller( +- get_handler, get_controller ++ get_handler: Callable, ++ get_controller: Callable[..., Controller] + ) -> Generator[Controller, None, None]: + handler = get_handler() + controller = get_controller( +@@ -368,7 +382,7 @@ def decoding_authnotls_controller( + + + @pytest.fixture +-def error_controller(get_handler) -> Generator[ErrorController, None, None]: ++def error_controller(get_handler: Callable) -> Generator[ErrorController, None, None]: + handler = get_handler() + controller = ErrorController(handler) + controller.start() +@@ -417,10 +431,8 @@ class TestProtocol: + ] + ) + ) +- try: ++ with suppress(asyncio.CancelledError): + temp_event_loop.run_until_complete(protocol._handler_coroutine) +- except asyncio.CancelledError: +- pass + _, responses = transport_resp + assert responses[5] == S.S250_OK.to_bytes() + b"\r\n" + assert len(handler.box) == 1 +@@ -441,10 +453,8 @@ class TestProtocol: + ] + ) + ) +- try: ++ with suppress(asyncio.CancelledError): + temp_event_loop.run_until_complete(protocol._handler_coroutine) +- except asyncio.CancelledError: +- pass + _, responses = transport_resp + assert responses[5] == S.S250_OK.to_bytes() + b"\r\n" + assert len(handler.box) == 1 +@@ -986,19 +996,19 @@ class TestAuthMechanisms(_CommonMethods): + @pytest.fixture + def do_auth_plain1( + self, client +- ) -> Generator[Callable[[str], Tuple[int, bytes]], None, None]: ++ ) -> Callable[[str], Tuple[int, bytes]]: + self._ehlo(client) + + def do(param: str) -> Tuple[int, bytes]: + return client.docmd("AUTH PLAIN " + param) + + do.client = client +- yield do ++ return do + + @pytest.fixture + def do_auth_login3( + self, client +- ) -> Generator[Callable[[str], Tuple[int, bytes]], None, None]: ++ ) -> Callable[[str], Tuple[int, bytes]]: + self._ehlo(client) + resp = client.docmd("AUTH LOGIN") + assert resp == S.S334_AUTH_USERNAME +@@ -1007,7 +1017,7 @@ class TestAuthMechanisms(_CommonMethods): + return client.docmd(param) + + do.client = client +- yield do ++ return do + + def test_ehlo(self, client): + code, mesg = client.ehlo("example.com") +@@ -1119,11 +1129,11 @@ class TestAuthMechanisms(_CommonMethods): + assert_nopassleak(PW, caplog.record_tuples) + + @pytest.fixture +- def client_auth_plain2(self, client) -> Generator[SMTPClient, None, None]: ++ def client_auth_plain2(self, client) -> SMTPClient: + self._ehlo(client) + resp = client.docmd("AUTH PLAIN") + assert resp == S.S334_AUTH_EMPTYPROMPT +- yield client ++ return client + + def test_plain2_good_credentials( + self, caplog, auth_peeker_controller, client_auth_plain2 +@@ -1965,7 +1975,8 @@ class TestAuthArgs: + ], + ) + def test_authmechname_decorator_badname(self, name): +- with pytest.raises(ValueError): ++ expectre = r"Invalid AUTH mechanism name" ++ with pytest.raises(ValueError, match=expectre): + auth_mechanism(name) + + +diff --git a/aiosmtpd/tests/test_starttls.py b/aiosmtpd/tests/test_starttls.py +index 6bb2cbd..5e0a180 100644 +--- a/aiosmtpd/tests/test_starttls.py ++++ b/aiosmtpd/tests/test_starttls.py +@@ -12,6 +12,7 @@ import pytest + from aiosmtpd.controller import Controller + from aiosmtpd.handlers import Sink + from aiosmtpd.smtp import SMTP as Server ++from aiosmtpd.smtp import Envelope + from aiosmtpd.smtp import Session as Sess_ + from aiosmtpd.smtp import TLSSetupException + from aiosmtpd.testing.helpers import ReceivingHandler, catchup_delay +@@ -31,14 +32,18 @@ class EOFingHandler: + ssl_existed = None + result = None + +- async def handle_NOOP(self, server: Server, session: Sess_, envelope, arg): ++ async def handle_NOOP( ++ self, server: Server, session: Sess_, envelope: Envelope, arg: str ++ ) -> str: + self.ssl_existed = session.ssl is not None + self.result = server.eof_received() + return "250 OK" + + + class HandshakeFailingHandler: +- def handle_STARTTLS(self, server, session, envelope): ++ def handle_STARTTLS( ++ self, server: Server, session: Sess_, envelope: Envelope ++ ) -> bool: + return False + + +@@ -198,7 +203,7 @@ class TestStartTLS: + class ExceptionCaptureHandler: + error = None + +- async def handle_exception(self, error): ++ async def handle_exception(self, error: Exception) -> str: + self.error = error + return "500 ExceptionCaptureHandler handling error" + +@@ -354,7 +359,7 @@ class TestRequireTLSAUTH: + class TestTLSContext: + def test_verify_mode_nochange(self, ssl_context_server): + context = ssl_context_server +- for mode in (ssl.CERT_NONE, ssl.CERT_OPTIONAL): ++ for mode in (ssl.CERT_NONE, ssl.CERT_OPTIONAL): # noqa: DUO122 + context.verify_mode = mode + _ = Server(Sink(), tls_context=context) + assert context.verify_mode == mode +@@ -370,10 +375,10 @@ class TestTLSContext: + + def test_nocertreq_chkhost_warn(self, caplog, ssl_context_server): + context = ssl_context_server +- context.verify_mode = ssl.CERT_OPTIONAL ++ context.verify_mode = ssl.CERT_OPTIONAL # noqa: DUO122 + context.check_hostname = True + _ = Server(Sink(), tls_context=context) +- assert context.verify_mode == ssl.CERT_OPTIONAL ++ assert context.verify_mode == ssl.CERT_OPTIONAL # noqa: DUO122 + logmsg = caplog.record_tuples[0][-1] + assert "tls_context.check_hostname == True" in logmsg + assert "might cause client connection problems" in logmsg +diff --git a/housekeep.py b/housekeep.py +index 88dddd5..92b8cb6 100644 +--- a/housekeep.py ++++ b/housekeep.py +@@ -69,7 +69,8 @@ TERM_WIDTH, TERM_HEIGHT = shutil.get_terminal_size() + def deldir(targ: Path, verbose: bool = True): + if not targ.exists(): + return +- for i, pp in enumerate(reversed(sorted(targ.rglob("*"))), start=1): ++ rev_items = sorted(targ.rglob("*"), reverse=True) ++ for i, pp in enumerate(rev_items, start=1): + if pp.is_symlink(): + pp.unlink() + elif pp.is_file(): +diff --git a/setup.cfg b/setup.cfg +index 7cfbf7f..6638b75 100644 +--- a/setup.cfg ++++ b/setup.cfg +@@ -66,4 +66,54 @@ source-dir = aiosmtpd/docs + [flake8] + jobs = 1 + max-line-length = 88 +-ignore = E123, E133, W503, W504, W293, E203 ++# "E,F,W,C90" are flake8 defaults ++# For others, take a gander at tox.ini to see which prefix provided by who ++select = E,F,W,C90,C4,MOD,JS,PIE,PT,SIM,ECE,C801,DUO,TAE,ANN,YTT,N400 ++ignore = ++ # black conflicts with E123 & E133 ++ E123 ++ E133 ++ # W503 conflicts with PEP8... ++ W503 ++ # W293 is a bit too noisy. Many files have been edited using editors that do not remove spaces from blank lines. ++ W293 ++ # Sometimes spaces around colons improve readability ++ E203 ++ # Sometimes we prefer the func()-based creation, not literal, for readability ++ C408 ++ # Sometimes we need to catch Exception broadly ++ PIE786 ++ # We don't really care about pytest.fixture vs pytest.fixture() ++ PT001 ++ # Good idea, but too many changes. Remove this in the future, and create separate PR ++ PT004 ++ # Sometimes exception needs to be explicitly raised in special circumstances, needing additional lines of code ++ PT012 ++ # I still can't grok the need to annotate "self" or "cls" ... ++ ANN101 ++ ANN102 ++ # I don't think forcing annotation for *args and **kwargs is a wise idea... ++ ANN002 ++ ANN003 ++ # We have too many "if..elif..else: raise" structures that does not convert well to "error-first" design ++ SIM106 ++per-file-ignores = ++ aiosmtpd/tests/test_*:ANN001 ++ aiosmtpd/tests/test_proxyprotocol.py:DUO102 ++ aiosmtpd/docs/_exts/autoprogramm.py:C801 ++# flake8-coding ++no-accept-encodings = True ++# flake8-copyright ++copyright-check = True ++# The number below was determined empirically by bisecting from 100 until no copyright-unnecessary files appear ++copyright-min-file-size = 44 ++copyright-author = The aiosmtpd Developers ++# flake8-annotations-complexity ++max-annotations-complexity = 4 ++# flake8-annotations-coverage ++min-coverage-percents = 12 ++# flake8-annotations ++mypy-init-return = True ++suppress-none-returning = True ++suppress-dummy-args = True ++allow-untyped-defs = True +diff --git a/tox.ini b/tox.ini +index 17d246e..eb2f4f6 100644 +--- a/tox.ini ++++ b/tox.ini +@@ -10,11 +10,14 @@ envdir = + py37: {toxworkdir}/3.7 + py38: {toxworkdir}/3.8 + py39: {toxworkdir}/3.9 ++ py310: {toxworkdir}/3.10 + pypy3: {toxworkdir}/pypy3 + py: {toxworkdir}/py + commands = + python housekeep.py prep +- !diffcov: bandit -c bandit.yml -r aiosmtpd ++ # Bandit is not needed on diffcov, and seems to be incompatible with 310 ++ # So, run only if "not (310 or diffcov)" ==> "(not 310) and (not diffcov)" ++ !py310-!diffcov: bandit -c bandit.yml -r aiosmtpd + nocov: pytest --verbose -p no:cov --tb=short {posargs} + cov: pytest --cov --cov-report=xml --cov-report=html --cov-report=term --tb=short {posargs} + diffcov: diff-cover _dump/coverage-{env:INTERP}.xml --html-report htmlcov/diffcov-{env:INTERP}.html +@@ -24,6 +27,7 @@ commands = + #sitepackages = True + usedevelop = True + deps = ++ # do NOT make these conditional, that way we can reuse same envdir for nocov+cov+diffcov + bandit + colorama + coverage[toml] +@@ -43,6 +47,7 @@ setenv = + py37: INTERP=py37 + py38: INTERP=py38 + py39: INTERP=py39 ++ py310: INTERP=py310 + pypy3: INTERP=pypy3 + py: INTERP=py + passenv = +@@ -51,18 +56,62 @@ passenv = + CI + GITHUB* + ++[flake8_plugins] ++# This is a pseudo-section that feeds into [testenv:qa] and GA ++# Snippets of letters above these plugins are tests that need to be "select"-ed in flake8 config (in ++# setup.cfg) to activate the respective plugins. If no snippet is given, that means the plugin is ++# always active. ++deps = ++ flake8-bugbear ++ flake8-builtins ++ flake8-coding ++ # C4 ++ flake8-comprehensions ++ # JS ++ flake8-multiline-containers ++ # PIE ++ flake8-pie ++ # MOD ++ flake8-printf-formatting ++ # PT ++ flake8-pytest-style ++ # SIM ++ flake8-simplify ++ # Cognitive Complexity looks like a good idea, but to fix the complaints... it will be an epic effort. ++ # So we disable it for now and reenable when we're ready, probably just before 2.0 ++ # # CCR ++ # flake8-cognitive-complexity ++ # ECE ++ flake8-expression-complexity ++ # C801 ++ flake8-copyright ++ # DUO ++ dlint ++ # TAE ++ flake8-annotations-complexity ++ # TAE ++ flake8-annotations-coverage ++ # ANN ++ flake8-annotations ++ # YTT ++ flake8-2020 ++ # N400 ++ flake8-broken-line ++ + [testenv:qa] + basepython = python3 + envdir = {toxworkdir}/qa + commands = + python housekeep.py prep ++ # The next line lists enabled plugins ++ python -m flake8 --version + python -m flake8 aiosmtpd setup.py housekeep.py release.py + check-manifest -v + pytest -v --tb=short aiosmtpd/qa + deps = + colorama + flake8 +- flake8-bugbear ++ {[flake8_plugins]deps} + pytest + check-manifest + +@@ -79,11 +128,13 @@ deps: + # - .github/workflows/unit-testing-and-coverage.yml + # - aiosmtpd/docs/RTD-requirements.txt + colorama +- pytest + sphinx + sphinx-autofixture + sphinx_rtd_theme + pickle5 ; python_version < '3.8' ++ # The below used as deps, need to be installed so autofixture work properly ++ pytest ++ pytest-mock + + [testenv:static] + basepython = python3 +-- +2.32.0 + diff --git a/0003-URGENT-Fix-RTD-docs-gen.patch b/0003-URGENT-Fix-RTD-docs-gen.patch new file mode 100644 index 0000000..53c6009 --- /dev/null +++ b/0003-URGENT-Fix-RTD-docs-gen.patch @@ -0,0 +1,24 @@ +From b50563035ebf72502e25488367b46fccce5d6991 Mon Sep 17 00:00:00 2001 +From: Pandu E POLUAN +Date: Wed, 24 Mar 2021 11:03:53 +0700 +Subject: [PATCH 3/4] URGENT: Fix RTD docs gen + +--- + aiosmtpd/docs/RTD-requirements.txt | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/aiosmtpd/docs/RTD-requirements.txt b/aiosmtpd/docs/RTD-requirements.txt +index 42c1f7b..cfdaa48 100644 +--- a/aiosmtpd/docs/RTD-requirements.txt ++++ b/aiosmtpd/docs/RTD-requirements.txt +@@ -4,6 +4,7 @@ sphinx-autofixture + sphinx_rtd_theme + # Required by Sphinx.autodoc + pytest>=6.0 ++pytest-mock + + # aiosmtpd deps + atpublic +-- +2.32.0 + diff --git a/0004-Make-Sphinx-RTD-deps-SSOT.patch b/0004-Make-Sphinx-RTD-deps-SSOT.patch new file mode 100644 index 0000000..72966bc --- /dev/null +++ b/0004-Make-Sphinx-RTD-deps-SSOT.patch @@ -0,0 +1,120 @@ +From 215b854447e2567bbc5e3665d9a648d7b1fa2c82 Mon Sep 17 00:00:00 2001 +From: Pandu POLUAN +Date: Wed, 24 Mar 2021 12:14:03 +0700 +Subject: [PATCH 4/4] Make Sphinx/RTD deps SSOT + +Previously we can accidentally forgot to sync between tox.ini, GA yml, +and RTD-requirements.txt. + +Now tox.ini and GA yml actually refers to RTD-requirements.txt, so we +have achieved SSOT (Single Source Of Truth) for Sphinx/RTD deps. +--- + .github/workflows/unit-testing-and-coverage.yml | 7 +++++-- + aiosmtpd/docs/RTD-requirements.txt | 11 +++++++---- + aiosmtpd/docs/conf.py | 7 ++++--- + tox.ini | 11 +---------- + 4 files changed, 17 insertions(+), 19 deletions(-) + +diff --git a/.github/workflows/unit-testing-and-coverage.yml b/.github/workflows/unit-testing-and-coverage.yml +index ebc2248..eb8daa1 100644 +--- a/.github/workflows/unit-testing-and-coverage.yml ++++ b/.github/workflows/unit-testing-and-coverage.yml +@@ -37,6 +37,8 @@ jobs: + run: | + python -m pip install --upgrade pip setuptools wheel + python setup.py develop ++ # Common deps ++ pip install colorama + - name: "flake8 Style Checking" + shell: bash + # language=bash +@@ -48,12 +50,13 @@ jobs: + "config.read('tox.ini');" + "print(config['flake8_plugins']['deps']);" + ) +- pip install colorama flake8 $(python -c "${grab_f8_plugins[*]}") ++ pip install flake8 $(python -c "${grab_f8_plugins[*]}") + python -m flake8 aiosmtpd setup.py housekeep.py release.py + - name: "Docs Checking" + # language=bash + run: | +- pip install colorama pytest pytest-mock sphinx sphinx-autofixture sphinx_rtd_theme ++ # Prepare sphinx and the deps for sphinx extensions ++ pip install -r aiosmtpd/docs/RTD-requirements.txt + sphinx-build --color -b doctest -d build/.doctree aiosmtpd/docs build/doctest + sphinx-build --color -b html -d build/.doctree aiosmtpd/docs build/html + sphinx-build --color -b man -d build/.doctree aiosmtpd/docs build/man +diff --git a/aiosmtpd/docs/RTD-requirements.txt b/aiosmtpd/docs/RTD-requirements.txt +index cfdaa48..e26dc75 100644 +--- a/aiosmtpd/docs/RTD-requirements.txt ++++ b/aiosmtpd/docs/RTD-requirements.txt +@@ -1,11 +1,14 @@ +-# Sphinx deps +-sphinx>=2.1 ++### Sphinx deps ++pickle5 ; python_version < '3.8' ++# Sync the ver limit below with conf.py ++sphinx>=3.2 + sphinx-autofixture + sphinx_rtd_theme +-# Required by Sphinx.autodoc ++ ++### Required by Sphinx.autodoc + pytest>=6.0 + pytest-mock + +-# aiosmtpd deps ++### aiosmtpd deps + atpublic + attrs +diff --git a/aiosmtpd/docs/conf.py b/aiosmtpd/docs/conf.py +index d3273f1..689e4a7 100644 +--- a/aiosmtpd/docs/conf.py ++++ b/aiosmtpd/docs/conf.py +@@ -50,6 +50,8 @@ syspath_insert(repo_root / "aiosmtpd") + # :classmethod: needs Sphinx>=2.1 + # :noindex: needs Sphinx>=3.2 + needs_sphinx = "3.2" ++# If you change the above, don't forget to change the version limit in ++# `RTD-requirements.txt` + + # Add any Sphinx extension module names here, as strings. They can be + # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +@@ -62,9 +64,8 @@ extensions = [ + "autoprogramm", + "sphinx_rtd_theme" + ] +-# IMPORTANT: If you edit this, also edit the following: +-# - aiosmtpd/docs/RTD-requirements.txt +-# - tox.ini ++# IMPORTANT: If you edit the above list, check if you need to edit the deps list ++# in `RTD-requirements.txt` + + # Add any paths that contain templates here, relative to this directory. + templates_path = ["_templates"] +diff --git a/tox.ini b/tox.ini +index eb2f4f6..e5ac6a3 100644 +--- a/tox.ini ++++ b/tox.ini +@@ -124,17 +124,8 @@ commands = + sphinx-build --color -b html -d build/.doctree aiosmtpd/docs build/html + sphinx-build --color -b man -d build/.doctree aiosmtpd/docs build/man + deps: +- # IMPORTANT: If you edit this, also edit the files: +- # - .github/workflows/unit-testing-and-coverage.yml +- # - aiosmtpd/docs/RTD-requirements.txt + colorama +- sphinx +- sphinx-autofixture +- sphinx_rtd_theme +- pickle5 ; python_version < '3.8' +- # The below used as deps, need to be installed so autofixture work properly +- pytest +- pytest-mock ++ -raiosmtpd/docs/RTD-requirements.txt + + [testenv:static] + basepython = python3 +-- +2.32.0 + diff --git a/1.2.2.tar.gz b/1.2.2.tar.gz deleted file mode 100644 index 7a2b525..0000000 Binary files a/1.2.2.tar.gz and /dev/null differ diff --git a/1.4.2.tar.gz b/1.4.2.tar.gz new file mode 100644 index 0000000..65d42fb Binary files /dev/null and b/1.4.2.tar.gz differ diff --git a/284.patch b/284.patch new file mode 100644 index 0000000..89e8d7a --- /dev/null +++ b/284.patch @@ -0,0 +1,46 @@ +From e302182240ea59f4cf65c7d4b128be29417f33a2 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Miro=20Hron=C4=8Dok?= +Date: Thu, 23 Sep 2021 15:55:02 +0200 +Subject: [PATCH] Avoid SSLError: Cannot create a client socket with a + PROTOCOL_TLS_SERVER context + +When we build mailman3 in Fedora with Python 3.10.0rc2, +we see the following problem: + + Traceback (most recent call last): + File "/builddir/build/BUILD/mailman-3.3.4/src/mailman/testing/layers.py", line 297, in setUp + cls.smtpd.start() + File "/builddir/build/BUILD/mailman-3.3.4/src/mailman/testing/mta.py", line 177, in start + super().start() + File "/usr/lib/python3.10/site-packages/aiosmtpd/controller.py", line 288, in start + self._trigger_server() + File "/usr/lib/python3.10/site-packages/aiosmtpd/controller.py", line 481, in _trigger_server + InetMixin._trigger_server(self) + File "/usr/lib/python3.10/site-packages/aiosmtpd/controller.py", line 428, in _trigger_server + s = stk.enter_context(self.ssl_context.wrap_socket(s)) + File "/usr/lib64/python3.10/ssl.py", line 512, in wrap_socket + return self.sslsocket_class._create( + File "/usr/lib64/python3.10/ssl.py", line 1061, in _create + self._sslobj = self._context._wrap_socket( + ssl.SSLError: Cannot create a client socket with a PROTOCOL_TLS_SERVER context (_ssl.c:801) + +This makes the problem go away. + +Disclaimer: I have no idea what I'm doing here. +--- + aiosmtpd/controller.py | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py +index 79bdbd04..30fd4a11 100644 +--- a/aiosmtpd/controller.py ++++ b/aiosmtpd/controller.py +@@ -424,7 +424,7 @@ def _trigger_server(self): + hostname = self.hostname or self._localhost + with ExitStack() as stk: + s = stk.enter_context(create_connection((hostname, self.port), 1.0)) +- if self.ssl_context: ++ if self.ssl_context and self.ssl_context.protocol != ssl.PROTOCOL_TLS_SERVER: + s = stk.enter_context(self.ssl_context.wrap_socket(s)) + s.recv(1024) + diff --git a/python-aiosmtpd.spec b/python-aiosmtpd.spec index 10f27f5..0623b3e 100644 --- a/python-aiosmtpd.spec +++ b/python-aiosmtpd.spec @@ -1,11 +1,17 @@ %global _empty_manifest_terminate_build 0 Name: python-aiosmtpd -Version: 1.2.2 +Version: 1.4.2 Release: 1 Summary: aiosmtpd - asyncio based SMTP server License: Apache 2.0 URL: https://github.com/aio-libs/aiosmtpd Source0: https://github.com/aio-libs/aiosmtpd/archive/%{version}.tar.gz +Patch0001: 0001-Implement-Unthreaded-Controller-256.patch +Patch0002: 0002-Code-Hygiene-259.patch +Patch0003: 0003-URGENT-Fix-RTD-docs-gen.patch +Patch0004: 0004-Make-Sphinx-RTD-deps-SSOT.patch +Patch0005: %{url}/pull/284.patch + BuildArch: noarch %description @@ -32,7 +38,7 @@ Provides: python3-aiosmtpd-doc Development documents and examples for aiosmtpd. %prep -%autosetup -n aiosmtpd-%{version} +%autosetup -p1 -n aiosmtpd-%{version} %build %py3_build @@ -72,5 +78,8 @@ mv %{buildroot}/doclist.lst . %{_pkgdocdir} %changelog +* Tue Jun 07 2022 SimpleUpdate Robot - 1.4.2-1 +- Upgrade to version 1.4.2 + * Thu Dec 17 2020 Python_Bot - Package Spec generated