diff --git a/0001-Implement-Unthreaded-Controller-256.patch b/0001-Implement-Unthreaded-Controller-256.patch deleted file mode 100644 index f3dc2b0914150460b29690d32f12eb386c8e0102..0000000000000000000000000000000000000000 --- a/0001-Implement-Unthreaded-Controller-256.patch +++ /dev/null @@ -1,2009 +0,0 @@ -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/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 deleted file mode 100644 index add742c64ab7a20842d10cff5aa7385059e3e576..0000000000000000000000000000000000000000 --- a/0002-Code-Hygiene-259.patch +++ /dev/null @@ -1,2725 +0,0 @@ -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/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 deleted file mode 100644 index 53c60090e95fa6d32248ec98d0a66c8070746506..0000000000000000000000000000000000000000 --- a/0003-URGENT-Fix-RTD-docs-gen.patch +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 72966bcebe0bcadc024c6e9f4fd5ec2fc1f15287..0000000000000000000000000000000000000000 --- a/0004-Make-Sphinx-RTD-deps-SSOT.patch +++ /dev/null @@ -1,120 +0,0 @@ -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.4.2.tar.gz b/1.4.2.tar.gz deleted file mode 100644 index 65d42fbc7e3abdb0d5bc007867a4f81232aa27e8..0000000000000000000000000000000000000000 Binary files a/1.4.2.tar.gz and /dev/null differ diff --git a/284.patch b/284.patch deleted file mode 100644 index 89e8d7a7ec7c3623651861a0a55974f9d493ec23..0000000000000000000000000000000000000000 --- a/284.patch +++ /dev/null @@ -1,46 +0,0 @@ -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/CVE-2024-27305.patch b/CVE-2024-27305.patch deleted file mode 100644 index c4adcec1800edf71b77d9c9c4c4fdcef0c663372..0000000000000000000000000000000000000000 --- a/CVE-2024-27305.patch +++ /dev/null @@ -1,160 +0,0 @@ -From 24b6c79c8921cf1800e27ca144f4f37023982bbb Mon Sep 17 00:00:00 2001 -From: Login <84237895+The-Login@users.noreply.github.com> -Date: Sat, 2 Mar 2024 15:55:13 +0100 -Subject: [PATCH] Merge pull request from GHSA-pr2m-px7j-xg65 -MIME-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: 8bit - -Origin: https://github.com/aio-libs/aiosmtpd/commit/24b6c79c8921cf1800e27ca144f4f37023982bbb - -* SMTP Smuggling Fix - -Adapted adherence to RFC 5321 ยง 2.3.8 to fix SMTP smuggling issues (https://www.rfc-editor.org/rfc/rfc5321#section-2.3.8) - -* Apply suggestions from code review - -Co-authored-by: Sam Bull - -* Add files via upload - -* Update test_smtpsmuggling.py - ---------- - -Co-authored-by: Sam Bull ---- - aiosmtpd/smtp.py | 11 ++-- - aiosmtpd/tests/test_smtpsmuggling.py | 79 ++++++++++++++++++++++++++++ - 2 files changed, 85 insertions(+), 5 deletions(-) - create mode 100644 aiosmtpd/tests/test_smtpsmuggling.py - -diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py -index 39e70d8b..00902c1e 100644 ---- a/aiosmtpd/smtp.py -+++ b/aiosmtpd/smtp.py -@@ -87,7 +87,7 @@ class _DataState(enum.Enum): - EMPTY_BARR = bytearray() - EMPTYBYTES = b'' - MISSING = _Missing() --NEWLINE = '\n' -+NEWLINE = '\r\n' - VALID_AUTHMECH = re.compile(r"[A-Z0-9_-]+\Z") - - # https://tools.ietf.org/html/rfc3207.html#page-3 -@@ -1427,9 +1427,10 @@ async def smtp_DATA(self, arg: str) -> None: - # Since eof_received cancels this coroutine, - # readuntil() can never raise asyncio.IncompleteReadError. - try: -- line: bytes = await self._reader.readuntil() -+ # https://datatracker.ietf.org/doc/html/rfc5321#section-2.3.8 -+ line: bytes = await self._reader.readuntil(b'\r\n') - log.debug('DATA readline: %s', line) -- assert line.endswith(b'\n') -+ assert line.endswith(b'\r\n') - except asyncio.CancelledError: - # The connection got reset during the DATA command. - log.info('Connection lost during DATA') -@@ -1446,7 +1447,7 @@ async def smtp_DATA(self, arg: str) -> None: - data *= 0 - # Drain the stream anyways - line = await self._reader.read(e.consumed) -- assert not line.endswith(b'\n') -+ assert not line.endswith(b'\r\n') - # A lone dot in a line signals the end of DATA. - if not line_fragments and line == b'.\r\n': - break -@@ -1458,7 +1459,7 @@ async def smtp_DATA(self, arg: str) -> None: - # Discard data immediately to prevent memory pressure - data *= 0 - line_fragments.append(line) -- if line.endswith(b'\n'): -+ if line.endswith(b'\r\n'): - # Record data only if state is "NOMINAL" - if state == _DataState.NOMINAL: - line = EMPTY_BARR.join(line_fragments) -diff --git a/aiosmtpd/tests/test_smtpsmuggling.py b/aiosmtpd/tests/test_smtpsmuggling.py -new file mode 100644 -index 00000000..b5d37851 ---- /dev/null -+++ b/aiosmtpd/tests/test_smtpsmuggling.py -@@ -0,0 +1,79 @@ -+# Copyright 2014-2021 The aiosmtpd Developers -+# SPDX-License-Identifier: Apache-2.0 -+ -+"""Test SMTP smuggling.""" -+ -+from email.mime.text import MIMEText -+from smtplib import SMTP, SMTP_SSL -+from typing import Generator, Union -+ -+import pytest -+import smtplib -+ -+from aiosmtpd.controller import Controller -+from aiosmtpd.testing.helpers import ReceivingHandler -+from aiosmtpd.testing.statuscodes import SMTP_STATUS_CODES as S -+ -+from aiosmtpd.smtp import SMTP as Server -+from aiosmtpd.smtp import Session as ServerSession -+from aiosmtpd.smtp import Envelope -+ -+from .conftest import Global, controller_data, handler_data -+ -+from aiosmtpd.testing.helpers import ( -+ ReceivingHandler -+) -+ -+def new_data(self, msg): -+ self.putcmd("data") -+ -+ (code, repl) = self.getreply() -+ if self.debuglevel > 0: -+ self._print_debug('data:', (code, repl)) -+ if code != 354: -+ raise SMTPDataError(code, repl) -+ else: -+ ##### Patching input encoding so we can send raw messages -+ #if isinstance(msg, str): -+ # msg = smtplib._fix_eols(msg).encode('ascii') -+ #q = smtplib._quote_periods(msg) -+ #if q[-2:] != smtplib.bCRLF: -+ # q = q + smtplib.bCRLF -+ #q = q + b"." + smtplib.bCRLF -+ q = msg -+ self.send(q) -+ (code, msg) = self.getreply() -+ if self.debuglevel > 0: -+ self._print_debug('data:', (code, msg)) -+ return (code, msg) -+ -+def return_unchanged(data): -+ return data -+ -+class TestSmuggling: -+ @handler_data(class_=ReceivingHandler) -+ def test_smtp_smuggling(self, plain_controller, client): -+ smtplib._fix_eols = return_unchanged -+ smtplib._quote_periods = return_unchanged -+ smtplib.SMTP.data = new_data -+ -+ handler = plain_controller.handler -+ sender = "sender@example.com" -+ recipients = ["rcpt1@example.com"] -+ resp = client.helo("example.com") -+ assert resp == S.S250_FQDN -+ # Trying SMTP smuggling with a fake \n.\r\n end-of-data sequence. -+ message_data = b"""\ -+From: Anne Person \r\n\ -+To: Bart Person \r\n\ -+Subject: A test\r\n\ -+Message-ID: \r\n\ -+\r\n\ -+Testing\ -+\n.\r\n\ -+NO SMUGGLING -+\r\n.\r\n\ -+""" -+ results = client.sendmail(sender, recipients, message_data) -+ client.quit() -+ assert b"NO SMUGGLING" in handler.box[0].content diff --git a/aiosmtpd-1.4.6.tar.gz b/aiosmtpd-1.4.6.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..6eeadfcc7d04d85fcd144d62eafb7a1a050dfe5e Binary files /dev/null and b/aiosmtpd-1.4.6.tar.gz differ diff --git a/python-aiosmtpd.spec b/python-aiosmtpd.spec index 1bd6b964bb2c9e723f4fcab5aca57526531afe70..3014f85bd0af50ffbcbc266de00e94099e830bb4 100644 --- a/python-aiosmtpd.spec +++ b/python-aiosmtpd.spec @@ -1,17 +1,11 @@ %global _empty_manifest_terminate_build 0 Name: python-aiosmtpd -Version: 1.4.2 -Release: 2 +Version: 1.4.6 +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 -Patch0006: CVE-2024-27305.patch +Source0: %{url}/releases/download/v%{version}/aiosmtpd-%{version}.tar.gz BuildArch: noarch @@ -79,6 +73,10 @@ mv %{buildroot}/doclist.lst . %{_pkgdocdir} %changelog +* Wed May 29 2024 yaoxin - 1.4.6-1 +- Update to 1.4.6 (bsc#1224467, CVE-2024-34083): +- STARTTLS is now fully enforced if used. + * Wed Mar 13 2024 wangkai <13474090681@163.com> - 1.4.2-2 - Fix CVE-2024-27305