diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 736b04e4..e5442aca 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -66,7 +66,7 @@ jobs: name: Test strategy: matrix: - pyver: ['3.8', '3.9', '3.10', '3.11', '3.12'] + pyver: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] os: [ubuntu, macos, windows] include: - pyver: pypy-3.8 @@ -82,6 +82,7 @@ jobs: python-version: ${{ matrix.pyver }} cache: 'pip' cache-dependency-path: '**/requirements*.txt' + allow-prereleases: true - name: Install dependencies uses: py-actions/py-dependency-install@v4 with: diff --git a/README.rst b/README.rst index 9a5f90cc..5958b5bc 100644 --- a/README.rst +++ b/README.rst @@ -179,7 +179,6 @@ have been configured and tested: (showing a single character per test run) - ``diffcov`` = with diff-coverage report (showing difference in coverage compared to previous commit). Tests will run in brief mode - - ``profile`` = no coverage testing, but code profiling instead. This must be **invoked manually** using the ``-e`` parameter **Note 1:** As of 2021-02-23, diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst index c509f656..29f00a9e 100644 --- a/aiosmtpd/docs/NEWS.rst +++ b/aiosmtpd/docs/NEWS.rst @@ -4,6 +4,14 @@ .. towncrier release notes start +1.4.7 (aiosmtpd-next) +===================== + +Fixed/Improved +-------------- + +* Added compatibility for Python 3.13 (Closes #403) + 1.4.6 (2024-05-18) ================== diff --git a/aiosmtpd/docs/testing.rst b/aiosmtpd/docs/testing.rst index 966072e2..12edb168 100644 --- a/aiosmtpd/docs/testing.rst +++ b/aiosmtpd/docs/testing.rst @@ -30,9 +30,6 @@ Other plugins that are used, to various degrees, in the ``aiosmtpd`` test suite * |pytest-cov|_ to integrate with |coverage-py|_ * |pytest-sugar|_ to provide better ux * |pytest-print|_ to give some progress indicator and to assist test troubleshooting -* |pytest-profiling|_ to implement ``*-profile`` testenv, - although to be honest this is not really useful as the profiling gets 'muddied' by - pytest runner. .. _`pytest-mock`: https://pypi.org/project/pytest-mock/ .. |pytest-mock| replace:: ``pytest-mock`` @@ -44,8 +41,6 @@ Other plugins that are used, to various degrees, in the ``aiosmtpd`` test suite .. |pytest-sugar| replace:: ``pytest-sugar`` .. _`pytest-print`: https://pypi.org/project/pytest-print/ .. |pytest-print| replace:: ``pytest-print`` -.. _`pytest-profiling`: https://pypi.org/project/pytest-profiling/ -.. |pytest-profiling| replace:: ``pytest-profiling`` Fixtures diff --git a/aiosmtpd/tests/conftest.py b/aiosmtpd/tests/conftest.py index 0c691031..78817679 100644 --- a/aiosmtpd/tests/conftest.py +++ b/aiosmtpd/tests/conftest.py @@ -5,19 +5,28 @@ import inspect import socket import ssl +import sys import warnings from contextlib import suppress from functools import wraps +from pathlib import Path from smtplib import SMTP as SMTPClient 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 +# `importlib.resources.files` was added in Python 3.9: +# https://docs.python.org/3/library/importlib.resources.html#importlib.resources.files +# Use backport https://github.com/python/importlib_resources on Python 3.8. +if sys.version_info[:2] == (3, 8): + import importlib_resources +else: + import importlib.resources as importlib_resources + try: from asyncio.proactor_events import _ProactorBasePipeTransport @@ -32,8 +41,6 @@ "handler_data", "Global", "AUTOSTOP_DELAY", - "SERVER_CRT", - "SERVER_KEY", ] @@ -73,8 +80,6 @@ def set_addr_from(cls, contr: Controller): # If less than 1.0, might cause intermittent error if test system # is too busy/overloaded. AUTOSTOP_DELAY = 1.5 -SERVER_CRT = resource_filename("aiosmtpd.tests.certs", "server.crt") -SERVER_KEY = resource_filename("aiosmtpd.tests.certs", "server.key") # endregion @@ -99,6 +104,22 @@ def cache_fqdn(session_mocker: MockFixture): # region #### Common Fixtures ######################################################### +def _server_resource(name: str, /) -> Generator[Path, None, None]: + ref = importlib_resources.files("aiosmtpd.tests.certs") / name + with importlib_resources.as_file(ref) as path: + yield path + + +@pytest.fixture(scope="session") +def server_crt() -> Generator[Path, None, None]: + yield from _server_resource("server.crt") + + +@pytest.fixture(scope="session") +def server_key() -> Generator[Path, None, None]: + yield from _server_resource("server.key") + + @pytest.fixture def get_controller(request: pytest.FixtureRequest) -> Callable[..., Controller]: """ @@ -315,25 +336,25 @@ def client(request: pytest.FixtureRequest) -> Generator[SMTPClient, None, None]: @pytest.fixture -def ssl_context_server() -> ssl.SSLContext: +def ssl_context_server(server_crt: Path, server_key: Path) -> ssl.SSLContext: """ Provides a server-side SSL Context """ context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) context.check_hostname = False - context.load_cert_chain(SERVER_CRT, SERVER_KEY) + context.load_cert_chain(server_crt, server_key) # return context @pytest.fixture -def ssl_context_client() -> ssl.SSLContext: +def ssl_context_client(server_crt: Path) -> ssl.SSLContext: """ Provides a client-side SSL Context """ context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) context.check_hostname = False - context.load_verify_locations(SERVER_CRT) + context.load_verify_locations(server_crt) # return context diff --git a/aiosmtpd/tests/test_main.py b/aiosmtpd/tests/test_main.py index b53cd856..1336607d 100644 --- a/aiosmtpd/tests/test_main.py +++ b/aiosmtpd/tests/test_main.py @@ -9,6 +9,7 @@ import time from contextlib import contextmanager from multiprocessing.synchronize import Event as MP_Event +from pathlib import Path from smtplib import SMTP as SMTPClient from smtplib import SMTP_SSL from typing import Generator @@ -21,7 +22,7 @@ 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 AUTOSTOP_DELAY, SERVER_CRT, SERVER_KEY +from aiosmtpd.tests.conftest import AUTOSTOP_DELAY try: import pwd @@ -199,24 +200,24 @@ def test_debug_3(self): @pytest.mark.skipif(sys.platform == "darwin", reason="No idea why these are failing") class TestMainByWatcher: - def test_tls(self, temp_event_loop): + def test_tls(self, temp_event_loop, server_crt: Path, server_key: Path): 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)) + 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() assert require_tls is True - def test_tls_noreq(self, temp_event_loop): + def test_tls_noreq(self, temp_event_loop, server_crt: Path, server_key: Path): 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), + str(server_crt), "--tlskey", - str(SERVER_KEY), + str(server_key), "--no-requiretls", ) catchup_delay() @@ -225,10 +226,10 @@ def test_tls_noreq(self, temp_event_loop): require_tls = retq.get() assert require_tls is False - def test_smtps(self, temp_event_loop): + def test_smtps(self, temp_event_loop, server_crt: Path, server_key: Path): 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)) + main_n("--smtpscert", str(server_crt), "--smtpskey", str(server_key)) catchup_delay() has_smtps = retq.get() assert has_smtps is True @@ -335,19 +336,21 @@ def test_norequiretls(self, capsys, mocker): assert args.requiretls is False @pytest.mark.parametrize( - ("certfile", "keyfile", "expect"), + ("certfile_present", "keyfile_present", "expect"), [ - ("x", "x", "Cert file x not found"), - (SERVER_CRT, "x", "Key file x not found"), - ("x", SERVER_KEY, "Cert file x not found"), + (False, False, "Cert file x not found"), + (True, False, "Key file x not found"), + (False, True, "Cert file x not found"), ], ids=["x-x", "cert-x", "x-key"], ) @pytest.mark.parametrize("meth", ["smtps", "tls"]) - def test_ssl_files_err(self, capsys, mocker, meth, certfile, keyfile, expect): + def test_ssl_files_err(self, capsys, mocker, meth, certfile_present, keyfile_present, expect, request: pytest.FixtureRequest): + certfile = request.getfixturevalue("server_crt") if certfile_present else "x" + keyfile = request.getfixturevalue("server_key") if keyfile_present else "x" mocker.patch("aiosmtpd.main.PROGRAM", "smtpd") with pytest.raises(SystemExit) as exc: - parseargs((f"--{meth}cert", certfile, f"--{meth}key", keyfile)) + parseargs((f"--{meth}cert", str(certfile), f"--{meth}key", str(keyfile))) assert exc.value.code == 2 assert expect in capsys.readouterr().err diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py index 443c0833..d2251d4a 100644 --- a/aiosmtpd/tests/test_server.py +++ b/aiosmtpd/tests/test_server.py @@ -448,10 +448,17 @@ def test_unixsocket(self, safe_socket_dir, autostop_loop, runner): # 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) + if sys.version_info < (3, 13): + # Now the listener has gone away + # noinspection PyTypeChecker + with pytest.raises((socket.timeout, ConnectionError)): + assert_smtp_socket(cont) + else: + # Starting from Python 3.13, listening asyncio Unix socket is + # removed on close, see: + # https://github.com/python/cpython/issues/111246 + # https://github.com/python/cpython/pull/111483 + assert not Path(cont.unix_socket).exists() @pytest.mark.filterwarnings( "ignore::pytest.PytestUnraisableExceptionWarning" diff --git a/pytest.ini b/pytest.ini index f2b9850f..976a765d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -10,11 +10,8 @@ addopts = --showlocals # coverage reports --cov=aiosmtpd/ --cov-report term -asyncio_mode = auto filterwarnings = error - # TODO: Replace pkg_resources - ignore:pkg_resources is deprecated as an API:DeprecationWarning # TODO: Fix resource warnings ignore:unclosed transport:ResourceWarning ignore:unclosed = 6.0 # Require >= 6.0 for pyproject.toml support (PEP 517) pytest-mock pytest-print - pytest-profiling pytest-sugar py # needed for pytest-sugar as it doesn't declare dependency on it. !nocov: coverage>=7.0.1 @@ -41,6 +41,7 @@ setenv = py310: INTERP=py310 py311: INTERP=py311 py312: INTERP=py312 + py313: INTERP=py313 pypy3: INTERP=pypy3 pypy38: INTERP=pypy38 pypy39: INTERP=pypy39