From c0ddfd7e79e1d6949070eefe112f1d9534462c5a Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Fri, 28 Jun 2024 08:29:23 +0300 Subject: [PATCH 1/4] Fix tox for Python >= 3.12 Fix import error: ``` py312-cov: commands[2]> pytest --cov --cov-report=xml --cov-report=html --cov-report=term --tb=short ImportError while loading conftest '/home/rominf/dev/aiosmtpd/aiosmtpd/tests/conftest.py'. aiosmtpd/tests/conftest.py:15: in from pkg_resources import resource_filename E ModuleNotFoundError: No module named 'pkg_resources' ``` by migrating to `importlib.resources`. --- aiosmtpd/tests/conftest.py | 32 +++++++++++++++++++++++--------- aiosmtpd/tests/test_main.py | 31 +++++++++++++++++-------------- pytest.ini | 2 -- 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/aiosmtpd/tests/conftest.py b/aiosmtpd/tests/conftest.py index 0c691031..6c147086 100644 --- a/aiosmtpd/tests/conftest.py +++ b/aiosmtpd/tests/conftest.py @@ -2,17 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio +import importlib.resources 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 @@ -32,8 +34,6 @@ "handler_data", "Global", "AUTOSTOP_DELAY", - "SERVER_CRT", - "SERVER_KEY", ] @@ -73,8 +73,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 +97,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 +329,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/pytest.ini b/pytest.ini index f2b9850f..c2990edf 100644 --- a/pytest.ini +++ b/pytest.ini @@ -13,8 +13,6 @@ addopts = 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 Date: Fri, 28 Jun 2024 08:04:09 +0300 Subject: [PATCH 2/4] Remove `pytest-profiling` Remove [pytest-profiling](https://pypi.org/project/pytest-profiling/), as it does not support latest Python versions, was not updated for 5 years, and according to the comment in `aiosmtpd/docs/testing.rst` was not very useful anyway. --- README.rst | 1 - aiosmtpd/docs/testing.rst | 5 ----- tox.ini | 1 - 3 files changed, 7 deletions(-) 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/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/tox.ini b/tox.ini index 8282343d..d3d5bc1e 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,6 @@ deps = pytest >= 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 From 9565ef849f3c4e7c9477d5469c48f0eee21793b7 Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Fri, 28 Jun 2024 08:18:21 +0300 Subject: [PATCH 3/4] Remove `pytest-asyncio` traces `pytest-asyncio` is unnecessary, it was missing in `tox.ini`, and tests pass. --- pytest.ini | 1 - requirements.txt | 1 - 2 files changed, 2 deletions(-) diff --git a/pytest.ini b/pytest.ini index c2990edf..976a765d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -10,7 +10,6 @@ addopts = --showlocals # coverage reports --cov=aiosmtpd/ --cov-report term -asyncio_mode = auto filterwarnings = error # TODO: Fix resource warnings diff --git a/requirements.txt b/requirements.txt index 21f032cc..bbc5921f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,5 @@ atpublic==5.0 attrs==24.2.0 coverage==7.6.1 pytest==8.3.4 -pytest-asyncio==0.24.0 pytest-cov==5.0.0 pytest-mock==3.14.0 From 9005f4eddc08026d4fb5e7863e4f682fa3fba60e Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Fri, 28 Jun 2024 07:42:33 +0300 Subject: [PATCH 4/4] Add compatibility for Python 3.13 Closes https://github.com/aio-libs/aiosmtpd/issues/403. --- .github/workflows/ci-cd.yml | 3 ++- aiosmtpd/docs/NEWS.rst | 8 ++++++++ aiosmtpd/tests/test_server.py | 15 +++++++++++---- setup.cfg | 1 + tox.ini | 3 ++- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 4be57b91..39ffefe0 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/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/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/setup.cfg b/setup.cfg index 1e23cb17..b33900a0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,7 @@ classifiers = Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 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 d3d5bc1e..b1dbc9d4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 3.9.0 -envlist = qa, static, docs, py{38,39,310,311,312,py3}-{nocov,cov,diffcov} +envlist = qa, static, docs, py{38,39,310,311,312,313,py3}-{nocov,cov,diffcov} skip_missing_interpreters = True [testenv] @@ -40,6 +40,7 @@ setenv = py310: INTERP=py310 py311: INTERP=py311 py312: INTERP=py312 + py313: INTERP=py313 pypy3: INTERP=pypy3 pypy38: INTERP=pypy38 pypy39: INTERP=pypy39