Repository: aio-libs/aiosmtpd

Issue:
testing: Allow running pytype using tox for local testing
Currently, the qa job in Github Action used for CI doesn't match the 'qa' tox testenv and specifically skips the type checking using `pytype`.

If there isn't a reason to _not_ run pytype locally, we should probably bridge that gap and add pytype for qa jobs and _maybe_ consider using tox as the source of truth and run tests using that even in CI?

Comments:

Patch:
diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py
index 075d74b..8a336d9 100644
--- a/aiosmtpd/controller.py
+++ b/aiosmtpd/controller.py
@@ -79,6 +79,24 @@ def get_localhost() -> Literal["::1", "127.0.0.1"]:
         raise
 
 
+def _server_to_client_ssl_ctx(server_ctx: ssl.SSLContext) -> ssl.SSLContext:
+    """
+    Given an SSLContext object with TLS_SERVER_PROTOCOL return a client
+    context that can connect to the server.
+    """
+    client_ctx = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
+    client_ctx.options = server_ctx.options
+    client_ctx.check_hostname = False
+    # We do not verify the ssl cert for the server here simply because this
+    # is a local connection to poke at the server for it to do its lazy
+    # initialization sequence. The only purpose of this client context
+    # is to make a connection to the *local* server created using the same
+    # code. That is also the reason why we disable cert verification below
+    # and the flake8 check for the same.
+    client_ctx.verify_mode = ssl.CERT_NONE    # noqa: DUO122
+    return client_ctx
+
+
 class _FakeServer(asyncio.StreamReaderProtocol):
     """
     Returned by _factory_invoker() in lieu of an SMTP instance in case
@@ -425,7 +443,8 @@ class InetMixin(BaseController, metaclass=ABCMeta):
         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))
+                client_ctx = _server_to_client_ssl_ctx(self.ssl_context)
+                s = stk.enter_context(client_ctx.wrap_socket(s))
             s.recv(1024)
 
 
@@ -467,7 +486,8 @@ class UnixSocketMixin(BaseController, metaclass=ABCMeta):  # pragma: no-unixsock
             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))
+                client_ctx = _server_to_client_ssl_ctx(self.ssl_context)
+                s = stk.enter_context(client_ctx.wrap_socket(s))
             s.recv(1024)
 
 
diff --git a/setup.cfg b/setup.cfg
index 9f16d3a..63ae3a8 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -25,6 +25,7 @@ classifiers =
     Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
+    Programming Language :: Python :: 3.10
     Programming Language :: Python :: Implementation :: CPython
     Programming Language :: Python :: Implementation :: PyPy
     Topic :: Communications :: Email :: Mail Transport Agents
diff --git a/tox.ini b/tox.ini
index e5ac6a3..678cd98 100644
--- a/tox.ini
+++ b/tox.ini
@@ -39,6 +39,7 @@ deps =
     pytest-print
     pytest-profiling
     pytest-sugar
+    py # needed for pytest-sugar as it doesn't declare dependency on it.
     diff_cover
 setenv =
     cov: COVERAGE_FILE={toxinidir}/_dump/.coverage
@@ -108,12 +109,14 @@ commands =
     python -m flake8 aiosmtpd setup.py housekeep.py release.py
     check-manifest -v
     pytest -v --tb=short aiosmtpd/qa
+    pytype --keep-going --jobs auto .
 deps =
     colorama
     flake8
     {[flake8_plugins]deps}
     pytest
     check-manifest
+    pytype
 
 [testenv:docs]
 basepython = python3


Test patch:
diff --git a/.github/workflows/unit-testing-and-coverage.yml b/.github/workflows/unit-testing-and-coverage.yml
index eb8daa1..8d4d2ef 100644
--- a/.github/workflows/unit-testing-and-coverage.yml
+++ b/.github/workflows/unit-testing-and-coverage.yml
@@ -89,7 +89,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ "macos-10.15", "ubuntu-18.04", "ubuntu-20.04", "windows-latest" ]
-        python-version: [ "3.6", "3.7", "3.8", "3.9", "pypy3" ]
+        python-version: [ "3.6", "3.7", "3.8", "3.9", "3.10", "pypy3.6" ]
     runs-on: ${{ matrix.os }}
     timeout-minutes: 15  # Slowest so far is pypy3 on MacOS, taking almost 7m
     steps:
@@ -98,7 +98,7 @@ jobs:
       with:
         fetch-depth: 0  # Required by codecov/codecov-action@v1
     - name: "Set up Python ${{ matrix.python-version }}"
-      uses: actions/setup-python@v2
+      uses: actions/setup-python@v4
       with:
         python-version: ${{ matrix.python-version }}
     - name: "Install dependencies"
diff --git a/aiosmtpd/tests/conftest.py b/aiosmtpd/tests/conftest.py
index 859d5ef..6a8c3dd 100644
--- a/aiosmtpd/tests/conftest.py
+++ b/aiosmtpd/tests/conftest.py
@@ -71,7 +71,7 @@ class Global:
 
 # If less than 1.0, might cause intermittent error if test system
 # is too busy/overloaded.
-AUTOSTOP_DELAY = 1.0
+AUTOSTOP_DELAY = 1.5
 SERVER_CRT = resource_filename("aiosmtpd.tests.certs", "server.crt")
 SERVER_KEY = resource_filename("aiosmtpd.tests.certs", "server.key")
 
diff --git a/aiosmtpd/tests/test_handlers.py b/aiosmtpd/tests/test_handlers.py
index a20df9a..d48593c 100644
--- a/aiosmtpd/tests/test_handlers.py
+++ b/aiosmtpd/tests/test_handlers.py
@@ -821,7 +821,7 @@ class TestProxyMocked:
             if rt == (
                 logger_name,
                 logging.INFO,
-                "we got some refusals: {'bart@example.com': (-1, 'ignore')}",
+                "we got some refusals: {'bart@example.com': (-1, b'ignore')}",
             ):
                 break
         else:
diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py
index 0449310..bda6d3b 100644
--- a/aiosmtpd/tests/test_server.py
+++ b/aiosmtpd/tests/test_server.py
@@ -28,6 +28,7 @@ from aiosmtpd.controller import (
     UnixSocketUnthreadedController,
     _FakeServer,
     get_localhost,
+    _server_to_client_ssl_ctx,
 )
 from aiosmtpd.handlers import Sink
 from aiosmtpd.smtp import SMTP as Server
@@ -101,7 +102,10 @@ def safe_socket_dir() -> Generator[Path, None, None]:
 def assert_smtp_socket(controller: UnixSocketMixin) -> bool:
     assert Path(controller.unix_socket).exists()
     sockfile = controller.unix_socket
-    ssl_context = controller.ssl_context
+    if controller.ssl_context:
+        ssl_context = _server_to_client_ssl_ctx(controller.ssl_context)
+    else:
+        ssl_context = None
     with ExitStack() as stk:
         sock: socket.socket = stk.enter_context(
             socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
diff --git a/aiosmtpd/tests/test_smtp.py b/aiosmtpd/tests/test_smtp.py
index ce61af2..0907d37 100644
--- a/aiosmtpd/tests/test_smtp.py
+++ b/aiosmtpd/tests/test_smtp.py
@@ -7,6 +7,7 @@ import asyncio
 import itertools
 import logging
 import socket
+import sys
 import time
 import warnings
 from asyncio.transports import Transport
@@ -1060,7 +1061,12 @@ class TestAuthMechanisms(_CommonMethods):
         client.user = "goodlogin"
         client.password = PW
         auth_meth = getattr(client, "auth_" + mechanism)
-        if (mechanism, init_resp) == ("login", False):
+        if (mechanism, init_resp) == ("login", False) and (
+                sys.version_info < (3, 8, 9)
+                or (3, 9, 0) < sys.version_info < (3, 9, 4)):
+            # The bug with SMTP.auth_login was fixed in Python 3.10 and backported
+            # to 3.9.4 and and 3.8.9.
+            # See https://github.com/python/cpython/pull/24118 for the fixes.:
             with pytest.raises(SMTPAuthenticationError):
                 client.auth(mechanism, auth_meth, initial_response_ok=init_resp)
             client.docmd("*")
@@ -1940,12 +1946,16 @@ class TestAuthArgs:
     def test_warn_authreqnotls(self, caplog):
         with pytest.warns(UserWarning) as record:
             _ = Server(Sink(), auth_required=True, auth_require_tls=False)
-        assert len(record) == 1
-        assert (
-            record[0].message.args[0]
-            == "Requiring AUTH while not requiring TLS can lead to "
-            "security vulnerabilities!"
-        )
+        for warning in record:
+            if warning.message.args and (
+                warning.message.args[0]
+                == "Requiring AUTH while not requiring TLS can lead to "
+                "security vulnerabilities!"
+            ):
+                break
+            else:
+                pytest.xfail("Did not raise expected warning")
+
         assert caplog.record_tuples[0] == (
             "mail.log",
             logging.WARNING,