Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add compatibility for Python 3.13 #473

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
1 change: 0 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions aiosmtpd/docs/NEWS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

.. towncrier release notes start
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weird to see this and no towncrier config in the repo..


1.4.7 (aiosmtpd-next)
=====================

Fixed/Improved
--------------

* Added compatibility for Python 3.13 (Closes #403)

1.4.6 (2024-05-18)
==================

Expand Down
5 changes: 0 additions & 5 deletions aiosmtpd/docs/testing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand All @@ -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
Expand Down
39 changes: 30 additions & 9 deletions aiosmtpd/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
rominf marked this conversation as resolved.
Show resolved Hide resolved
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:/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

Expand All @@ -32,8 +41,6 @@
"handler_data",
"Global",
"AUTOSTOP_DELAY",
"SERVER_CRT",
"SERVER_KEY",
]


Expand Down Expand Up @@ -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

Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird this is shipped in wheels.. I'd go for a relative path myself.

OTOH, I recommend integrating https://trustme.rtfd.io like I did in aiohttp and many other places so that the TLS certificates are ephemeral and don't even exist in the repo.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it is possible, however for me it looks like it is out of scope for this PR.

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]:
"""
Expand Down Expand Up @@ -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

Expand Down
31 changes: 17 additions & 14 deletions aiosmtpd/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
15 changes: 11 additions & 4 deletions aiosmtpd/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:/python/cpython/issues/111246
# https:/python/cpython/pull/111483
assert not Path(cont.unix_socket).exists()

@pytest.mark.filterwarnings(
"ignore::pytest.PytestUnraisableExceptionWarning"
Expand Down
3 changes: 0 additions & 3 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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 <socket.socket:ResourceWarning
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
atpublic==5.0
attrs==24.2.0
coverage==7.6.1
importlib_resources;python_version<"3.9"
pytest==8.3.2
pytest-asyncio==0.24.0
pytest-cov==5.0.0
pytest-mock==3.14.0
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -21,11 +21,11 @@ usedevelop = True
deps =
bandit
colorama
importlib_resources;python_version<"3.9"
packaging
pytest >= 6.0 # Require >= 6.0 for pyproject.toml support (PEP 517)
pytest-mock
pytest-print
pytest-profiling
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
pytest-sugar
py # needed for pytest-sugar as it doesn't declare dependency on it.
!nocov: coverage>=7.0.1
Expand All @@ -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
Expand Down