New Upstream Release - python-aiosmtpd

Ready changes

Summary

Merged new upstream version: 1.4.4.post2 (was: 1.4.3).

Diff

diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..4ed9ab7
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,41 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ "master" ]
+  pull_request:
+    branches: [ "master" ]
+  schedule:
+    - cron: "42 17 * * 1"
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ python ]
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+
+      - name: Initialize CodeQL
+        uses: github/codeql-action/init@v2
+        with:
+          languages: ${{ matrix.language }}
+          queries: +security-and-quality
+
+      - name: Autobuild
+        uses: github/codeql-action/autobuild@v2
+
+      - name: Perform CodeQL Analysis
+        uses: github/codeql-action/analyze@v2
+        with:
+          category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/unit-testing-and-coverage.yml b/.github/workflows/unit-testing-and-coverage.yml
index e21813b..e867f07 100644
--- a/.github/workflows/unit-testing-and-coverage.yml
+++ b/.github/workflows/unit-testing-and-coverage.yml
@@ -8,17 +8,19 @@ on:
     paths:
       - "aiosmtpd/**"
       - "setup.cfg"  # To monitor changes in dependencies
+      - "pyproject.toml"  # To monitor changes in dependencies
   # This is for PRs
   pull_request:
     branches: [ "master" ]
     paths:
       - "aiosmtpd/**"
       - "setup.cfg"  # To monitor changes in dependencies
+      - "pyproject.toml"  # To monitor changes in dependencies
   # Manual/on-demand
   workflow_dispatch:
   # When doing "releases"
   release:
-    types: [ "created", "edited", "published", "prereleased", "released" ]
+    types: [ "created", "edited" ]
 
 jobs:
   qa_docs:
@@ -27,7 +29,7 @@ jobs:
       - name: "Checkout latest PR commit"
         uses: actions/checkout@v3
       - name: "Set up Python"
-        uses: actions/setup-python@v4.3.1
+        uses: actions/setup-python@v4
         with:
           # 3.8 is chosen because it seems to be the fastest for <3.9
           # (3.9 excluded because it seems to be still very unstable)
@@ -50,7 +52,7 @@ jobs:
             "config.read('tox.ini');"
             "print(config['flake8_plugins']['deps']);"
           )
-          pip install flake8 $(python -c "${grab_f8_plugins[*]}")
+          pip install "flake8>=5.0.4" $(python -c "${grab_f8_plugins[*]}")
           python -m flake8 aiosmtpd setup.py housekeep.py release.py
       - name: "Docs Checking"
         # language=bash
@@ -60,7 +62,7 @@ jobs:
           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
-      - name: "Static Code Checking"
+      - name: "Static Type Checking"
         # language=bash
         run: |
           # Required by examples
@@ -88,15 +90,8 @@ jobs:
       # If a matrix fail, do NOT stop other matrix, let them run to completion
       fail-fast: false
       matrix:
-        os: [ "macos-11", "macos-12", "ubuntu-18.04", "ubuntu-20.04", "ubuntu-22.04", "windows-latest" ]
-        python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11", "pypy3.6", "pypy3.7", "pypy3.8" ]
-        exclude:
-          - os: windows-latest
-            python-version: pypy3.6
-          - os: macos-11
-            python-version: pypy3.6
-          - os: macos-12
-            python-version: pypy3.6
+        python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11", "pypy3.7", "pypy3.8", "pypy3.9" ]
+        os: [ "macos-11", "macos-12", "ubuntu-18.04", "ubuntu-20.04", "ubuntu-22.04", "windows-2019", "windows-2022" ]
     runs-on: ${{ matrix.os }}
     timeout-minutes: 15  # Slowest so far is pypy3 on MacOS, taking almost 7m
     steps:
@@ -105,7 +100,7 @@ jobs:
       with:
         fetch-depth: 0  # Required by codecov/codecov-action@v1
     - name: "Set up Python ${{ matrix.python-version }}"
-      uses: actions/setup-python@v4.3.1
+      uses: actions/setup-python@v4
       with:
         python-version: ${{ matrix.python-version }}
     - name: "Install dependencies"
@@ -113,7 +108,7 @@ jobs:
       run: |
         python -m pip install --upgrade pip setuptools wheel
         # Test deps
-        pip install colorama coverage[toml] coverage-conditional-plugin packaging pytest pytest-cov pytest-mock
+        pip install colorama "coverage>=7.0.1" coverage[toml] "coverage-conditional-plugin>=0.5.0" packaging pytest pytest-cov pytest-mock
         # Package deps
         python setup.py develop
     - name: "Security checking"
@@ -121,6 +116,12 @@ jobs:
       run: |
         pip install bandit
         bandit -c bandit.yml -r aiosmtpd
+
+    # IMPORTANT: pypy3.8 is currently excluded from coverage testing because coverage seems to be unstable when
+    # running on PyPy 3.8. This is still under investigation (See issue #325)
+    # Edit: This is due to change in behavior in PyPy v7.3.10, and coverage.py needs to adapt.
+    # See: https://github.com/nedbat/coveragepy/issues/1515
+
     - name: "Execute testing with coverage"
       if: matrix.python-version != 'pypy3.8'
       shell: bash
@@ -143,10 +144,11 @@ jobs:
         fi
         pytest
         #
+
     - name: "Report to codecov"
       # Ubuntu 18.04 came out of the box with 3.6, and LOTS of system are still running
       # 18.04 happily, so we choose this as the 'canonical' code coverage testing.
       # One day we'll have to revisit this and bump the version ...
       # 2022-12-16: Bumped to Python 3.8 and Ubuntu 20.04. Yeah, we take the conservative LTS route.
       if: matrix.python-version == '3.8' && matrix.os == 'ubuntu-20.04'
-      uses: codecov/codecov-action@v3.1.1
+      uses: codecov/codecov-action@v3
diff --git a/DESCRIPTION.rst b/DESCRIPTION.rst
index f341b0a..036caf4 100644
--- a/DESCRIPTION.rst
+++ b/DESCRIPTION.rst
@@ -2,11 +2,15 @@
  aiosmtpd - asyncio based SMTP server
 ######################################
 
-| |github license| |_| |PyPI Version| |_| |PyPI Python|
-| |GA badge| |_| |codecov| |_| |LGTM.com| |_| |readthedocs|
+| |github license| |_| |PyPI Version| |_| |PyPI Python| |_| |PyPI PythonImpl|
+| |GA badge| |_| |CodeQL badge| |_| |codecov| |_| |readthedocs|
 | |GH Release| |_| |GH PRs| |_| |GH LastCommit|
+| |PyPI DL| |_| |GH DL|
+|
+| |GH Discussions|
 |
 
+.. .. U+00A0 is non-breaking space
 .. |_| unicode:: 0xA0
    :trim:
 .. |github license| image:: https://img.shields.io/github/license/aio-libs/aiosmtpd?logo=Open+Source+Initiative&logoColor=0F0
@@ -18,21 +22,22 @@
 .. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd?logo=python&logoColor=yellow
    :target: https://pypi.org/project/aiosmtpd/
    :alt: Supported Python Versions
+.. |PyPI PythonImpl| image:: https://img.shields.io/pypi/implementation/aiosmtpd?logo=python
+   :target: https://pypi.org/project/aiosmtpd/
+   :alt: Supported Python Implementations
 .. .. 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
-   :alt: GitHub Actions status
+   :target: https://github.com/aio-libs/aiosmtpd/actions/workflows/unit-testing-and-coverage.yml
+   :alt: GitHub CI status
+.. |CodeQL badge| image:: https://github.com/aio-libs/aiosmtpd/workflows/CodeQL/badge.svg
+   :target: https://github.com/aio-libs/aiosmtpd/actions/workflows/codeql.yml
+   :alt: CodeQL status
 .. |codecov| image:: https://codecov.io/github/aio-libs/aiosmtpd/coverage.svg?branch=master
    :target: https://codecov.io/github/aio-libs/aiosmtpd?branch=master
    :alt: Code Coverage
-.. |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://img.shields.io/readthedocs/aiosmtpd?logo=Read+the+Docs
    :target: https://aiosmtpd.readthedocs.io/en/latest/?badge=latest
    :alt: Documentation Status
-.. .. 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
@@ -42,6 +47,15 @@
 .. |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
+.. |PyPI DL| image:: https://img.shields.io/pypi/dm/aiosmtpd?logo=pypi
+   :target: https://pypi.org/project/aiosmtpd/
+   :alt: PyPI monthly downloads
+.. |GH DL| image:: https://img.shields.io/github/downloads/aio-libs/aiosmtpd/total?logo=github
+   :target: https://github.com/aio-libs/aiosmtpd/releases
+   :alt: GitHub downloads
+.. |GH Discussions| image:: https://img.shields.io/github/discussions/aio-libs/aiosmtpd?logo=github&style=social
+   :target: https://github.com/aio-libs/aiosmtpd/discussions
+   :alt: GitHub Discussions
 
 This is a server for SMTP and related MTA protocols,
 similar in utility to the standard library's |smtpd.py|_ module,
diff --git a/README.rst b/README.rst
index 9cca496..2714318 100644
--- a/README.rst
+++ b/README.rst
@@ -2,10 +2,11 @@
  aiosmtpd - An asyncio based SMTP server
 =========================================
 
-| |github license| |_| |PyPI Version| |_| |PyPI Python|
-| |GA badge| |_| |codecov| |_| |LGTM.com| |_| |readthedocs|
+| |github license| |_| |PyPI Version| |_| |PyPI Python| |_| |PyPI PythonImpl|
+| |GA badge| |_| |CodeQL badge| |_| |codecov| |_| |readthedocs|
+|
+| |GH Discussions|
 |
-| |Discourse|
 
 .. |_| unicode:: 0xA0
    :trim:
@@ -18,29 +19,31 @@
 .. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd?logo=python&logoColor=yellow
    :target: https://pypi.org/project/aiosmtpd/
    :alt: Supported Python Versions
+.. |PyPI PythonImpl| image:: https://img.shields.io/pypi/implementation/aiosmtpd?logo=python
+   :target: https://pypi.org/project/aiosmtpd/
+   :alt: Supported Python Implementations
 .. .. 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
-   :alt: GitHub Actions status
+   :target: https://github.com/aio-libs/aiosmtpd/actions/workflows/unit-testing-and-coverage.yml
+   :alt: GitHub CI status
+.. |CodeQL badge| image:: https://github.com/aio-libs/aiosmtpd/workflows/CodeQL/badge.svg
+   :target: https://github.com/aio-libs/aiosmtpd/actions/workflows/codeql.yml
+   :alt: CodeQL status
 .. |codecov| image:: https://codecov.io/github/aio-libs/aiosmtpd/coverage.svg?branch=master
    :target: https://codecov.io/github/aio-libs/aiosmtpd?branch=master
    :alt: Code Coverage
-.. |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://img.shields.io/readthedocs/aiosmtpd?logo=Read+the+Docs&logoColor=white
    :target: https://aiosmtpd.readthedocs.io/en/latest/
    :alt: Documentation Status
-.. .. 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
-   :target: https://aio-libs.discourse.group/
-   :alt: Discourse
+.. |GH Discussions| image:: https://img.shields.io/github/discussions/aio-libs/aiosmtpd?logo=github&style=social
+   :target: https://github.com/aio-libs/aiosmtpd/discussions
+   :alt: GitHub Discussions
 
 The Python standard library includes a basic |SMTP|_ server in the |smtpd|_ module,
 based on the old asynchronous libraries |asyncore|_ and |asynchat|_.
 These modules are quite old and are definitely showing their age;
 ``asyncore`` and ``asynchat`` are difficult APIs to work with, understand, extend, and fix.
+(And have been deprecated since Python 3.6, and will be removed in Python 3.12.)
 
 With the introduction of the |asyncio|_ module in Python 3.4,
 a much better way of doing asynchronous I/O is now available.
@@ -64,16 +67,14 @@ Supported Platforms
 ``aiosmtpd`` has been tested on **CPython**>=3.7 and |PyPy|_>=3.7
 for the following platforms (in alphabetical order):
 
-* Cygwin (on Windows 10) [1]
-* FreeBSD 12 [2]
-* OpenSUSE Leap 15 [2]
+* Cygwin (as of 2022-12-22, only for CPython 3.7, 3.8, and 3.9)
+* MacOS 11 and 12
 * Ubuntu 18.04
 * Ubuntu 20.04
 * Ubuntu 22.04
 * Windows 10
-
-  | [1] Supported only with Cygwin-provided CPython versions
-  | [2] Supported only on the latest minor release
+* Windows Server 2019
+* Windows Server 2022
 
 ``aiosmtpd`` *probably* can run on platforms not listed above,
 but we cannot provide support for unlisted platforms.
@@ -154,20 +155,16 @@ option::
 
 You can also add the ``-s``/``--capture=no`` option to show output, e.g.::
 
-    $ tox -e py36-nocov -- -s
+    $ tox -e py37-nocov -- -s
 
 and these options can be combined::
 
-    $ tox -e py36-nocov -- -x -s <testname>
+    $ tox -e py37-nocov -- -x -s <testname>
 
 (The ``-e`` parameter is explained in the next section about 'testenvs'.
 In general, you'll want to choose the ``nocov`` testenvs if you want to show output,
 so you can see which test is generating which output.)
 
-The `-x` and `-s` options can be combined::
-
-    $ tox -e py36-nocov -- -x -s <testname>
-
 
 Supported 'testenvs'
 ------------------------
@@ -176,7 +173,7 @@ In general, the ``-e`` parameter to tox specifies one (or more) **testenv**
 to run (separate using comma if more than one testenv). The following testenvs
 have been configured and tested:
 
-* ``{py37,py38,py39,py310,py311,pypy3}-{nocov,cov,diffcov,profile}``
+* ``{py37,py38,py39,py310,py311,pypy3,pypy37,pypy38,pypy39}-{nocov,cov,diffcov,profile}``
 
   Specifies the interpreter to run and the kind of testing to perform.
 
@@ -189,7 +186,7 @@ have been configured and tested:
     This must be **invoked manually** using the ``-e`` parameter
 
   **Note 1:** As of 2021-02-23,
-  only the ``{py36,py38}-{nocov,cov}`` combinations work on **Cygwin**.
+  only the ``{py37,py38,py39}-{nocov,cov}`` combinations work on **Cygwin**.
 
   **Note 2:** It is also possible to use whatever Python version is used when
   invoking ``tox`` by using the ``py`` target, but you must explicitly include
diff --git a/aiosmtpd/__init__.py b/aiosmtpd/__init__.py
index e8a5131..4def5e0 100644
--- a/aiosmtpd/__init__.py
+++ b/aiosmtpd/__init__.py
@@ -1,4 +1,21 @@
 # Copyright 2014-2021 The aiosmtpd Developers
 # SPDX-License-Identifier: Apache-2.0
+import asyncio
+import warnings
 
-__version__ = "1.4.3"
+
+__version__ = "1.4.4.post2"
+
+
+def _get_or_new_eventloop() -> asyncio.AbstractEventLoop:
+    loop = None
+    with warnings.catch_warnings():
+        warnings.simplefilter("error")
+        try:
+            loop = asyncio.get_event_loop()
+        except (DeprecationWarning, RuntimeError):  # pragma: py-lt-310
+            if loop is None:  # pragma: py-lt-312
+                loop = asyncio.new_event_loop()
+                asyncio.set_event_loop(loop)
+    assert isinstance(loop, asyncio.AbstractEventLoop)
+    return loop
diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py
index 8a336d9..5e07eb4 100644
--- a/aiosmtpd/controller.py
+++ b/aiosmtpd/controller.py
@@ -107,11 +107,11 @@ class _FakeServer(asyncio.StreamReaderProtocol):
         # Imitate what SMTP does
         super().__init__(
             asyncio.StreamReader(loop=loop),
-            client_connected_cb=self._client_connected_cb,
+            client_connected_cb=self._cb_client_connected,
             loop=loop,
         )
 
-    def _client_connected_cb(
+    def _cb_client_connected(
             self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
     ) -> None:
         pass
diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst
index 8012822..cf7251e 100644
--- a/aiosmtpd/docs/NEWS.rst
+++ b/aiosmtpd/docs/NEWS.rst
@@ -16,6 +16,23 @@ Fixed/Improved
 * A whole bunch of annotations
 
 
+1.4.4.post2 (2023-01-19)
+========================
+
+Fixed/Improved
+--------------
+* Prevent unclean repo from being built (Closes #365)
+* Reduce chance of not-ready-for-release packages from being uploaded
+
+
+1.4.4 (2023-01-17)
+==================
+
+Fixed/Improved
+--------------
+* No longer expect an implicit creation of the event loop through ``get_event_loop()`` (Closes #353)
+
+
 1.4.3 (2022-12-21)
 =====================
 
diff --git a/aiosmtpd/docs/_exts/autoprogramm.py b/aiosmtpd/docs/_exts/autoprogramm.py
index c23bd2f..de6afc3 100644
--- a/aiosmtpd/docs/_exts/autoprogramm.py
+++ b/aiosmtpd/docs/_exts/autoprogramm.py
@@ -34,9 +34,9 @@ import collections
 import os
 import sphinx
 
-from docutils import nodes
-from docutils.parsers.rst import Directive
-from docutils.parsers.rst.directives import unchanged
+from docutils import nodes  # pytype: disable=pyi-error
+from docutils.parsers.rst import Directive  # pytype: disable=pyi-error
+from docutils.parsers.rst.directives import unchanged  # pytype: disable=pyi-error
 from docutils.statemachine import StringList
 from functools import reduce
 from sphinx.util.nodes import nested_parse_with_titles
@@ -46,7 +46,16 @@ from typing import Any, Dict, List, Optional, Tuple
 __all__ = ("AutoprogrammDirective", "import_object", "scan_programs", "setup")
 
 
-def get_subparser_action(parser: argparse.ArgumentParser) -> argparse._SubParsersAction:
+# Need to temporarily disable this particular check, because although this function
+# is guaranteed to return a proper value (due to how ArgumentParser works), pytype
+# doesn't really know that, and therefore raised an error  in the (to its view)
+# possible fallthrough of "implicit return None" if the "for a" loop exits without
+# finding the right item.
+#
+# pytype: disable=bad-return-type
+def get_subparser_action(
+    parser: argparse.ArgumentParser
+) -> argparse._SubParsersAction:
     neg1_action = parser._actions[-1]
 
     if isinstance(neg1_action, argparse._SubParsersAction):
@@ -55,11 +64,12 @@ def get_subparser_action(parser: argparse.ArgumentParser) -> argparse._SubParser
     for a in parser._actions:
         if isinstance(a, argparse._SubParsersAction):
             return a
+# pytype: enable=bad-return-type
 
 
 def scan_programs(
     parser: argparse.ArgumentParser,
-    command: List[str] = None,
+    command: Optional[List[str]] = None,
     maxdepth: int = 0,
     depth: int = 0,
     groups: bool = False,
@@ -111,8 +121,12 @@ def scan_options(actions: list):
 
 
 def format_positional_argument(arg: argparse.Action) -> Tuple[List[str], str]:
-    desc = (arg.help or "") % {"default": arg.default}
-    name = arg.metavar or arg.dest
+    desc: str = (arg.help or "") % {"default": arg.default}
+    name: str
+    if isinstance(arg.metavar, tuple):
+        name = arg.metavar[0]
+    else:
+        name = arg.metavar or arg.dest or ""
     return [name], desc
 
 
@@ -149,7 +163,6 @@ def import_object(import_name: str) -> Any:
         # an ImportError as it did before.
         import glob
         import sys
-        import os
         import imp
 
         for p in sys.path:
@@ -298,6 +311,7 @@ def render_rst(
     options_adornment: str,
 ):
     if usage_strip:
+        assert usage is not None
         to_strip = title.rsplit(" ", 1)[0]
 
         len_to_strip = len(to_strip) - 4
diff --git a/aiosmtpd/docs/controller.rst b/aiosmtpd/docs/controller.rst
index c1edc13..220d45a 100644
--- a/aiosmtpd/docs/controller.rst
+++ b/aiosmtpd/docs/controller.rst
@@ -236,7 +236,8 @@ you'll have to do something similar to this:
 .. doctest:: unthreaded
 
     >>> import asyncio
-    >>> loop = asyncio.get_event_loop()
+    >>> loop = asyncio.new_event_loop()
+    >>> asyncio.set_event_loop(loop)
     >>> from aiosmtpd.controller import UnthreadedController
     >>> from aiosmtpd.handlers import Sink
     >>> controller = UnthreadedController(Sink(), loop=loop)
@@ -261,7 +262,7 @@ we'll also schedule an autostop so it won't hang:
     ...     loop.run_forever()
     >>> import threading
     >>> thread = threading.Thread(target=runner)
-    >>> thread.setDaemon(True)
+    >>> thread.daemon = True
     >>> thread.start()
     >>> import time
     >>> time.sleep(0.1)  # Allow the loop to begin
diff --git a/aiosmtpd/handlers.py b/aiosmtpd/handlers.py
index 5e44f7c..2d1b28f 100644
--- a/aiosmtpd/handlers.py
+++ b/aiosmtpd/handlers.py
@@ -25,6 +25,7 @@ from typing import Any, AnyStr, List, Type, TypeVar, Optional
 
 from public import public
 
+from aiosmtpd import _get_or_new_eventloop
 from aiosmtpd.smtp import SMTP as SMTPServer
 from aiosmtpd.smtp import Envelope as SMTPEnvelope
 from aiosmtpd.smtp import Session as SMTPSession
@@ -218,7 +219,7 @@ class AsyncMessage(Message, metaclass=ABCMeta):
         loop: Optional[asyncio.AbstractEventLoop] = None,
     ):
         super().__init__(message_class)
-        self.loop = loop or asyncio.get_event_loop()
+        self.loop = loop or _get_or_new_eventloop()
 
     async def handle_DATA(
         self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope
diff --git a/aiosmtpd/main.py b/aiosmtpd/main.py
index 2366ae4..166484c 100644
--- a/aiosmtpd/main.py
+++ b/aiosmtpd/main.py
@@ -1,7 +1,6 @@
 # Copyright 2014-2021 The aiosmtpd Developers
 # SPDX-License-Identifier: Apache-2.0
 
-import asyncio
 import logging
 import os
 import signal
@@ -16,7 +15,7 @@ from typing import Optional, Sequence, Tuple
 
 from public import public
 
-from aiosmtpd import __version__
+from aiosmtpd import __version__, _get_or_new_eventloop
 from aiosmtpd.smtp import DATA_SIZE_DEFAULT, SMTP
 
 try:
@@ -252,7 +251,7 @@ def main(args: Optional[Sequence[str]] = None) -> None:
 
     logging.basicConfig(level=logging.ERROR)
     log = logging.getLogger("mail.log")
-    loop = asyncio.get_event_loop()
+    loop = _get_or_new_eventloop()
 
     if args.debug > 0:
         log.setLevel(logging.INFO)
diff --git a/aiosmtpd/proxy_protocol.py b/aiosmtpd/proxy_protocol.py
index 537e522..5a41f0b 100644
--- a/aiosmtpd/proxy_protocol.py
+++ b/aiosmtpd/proxy_protocol.py
@@ -104,15 +104,12 @@ class UnknownTypeTLV(KeyError):
 class AsyncReader(Protocol):
     async def read(self, num_bytes: Optional[int] = None) -> bytes:
         ...
-        return b""
 
     async def readexactly(self, num_bytes: int) -> bytes:
         ...
-        return b""
 
     async def readuntil(self, until_chars: Optional[bytes] = None) -> bytes:
         ...
-        return b""
 
 
 _anoinit = partial(attr.ib, init=False)
diff --git a/aiosmtpd/qa/test_0packaging.py b/aiosmtpd/qa/test_0packaging.py
index 1f3fa6a..2c1e3d8 100644
--- a/aiosmtpd/qa/test_0packaging.py
+++ b/aiosmtpd/qa/test_0packaging.py
@@ -17,7 +17,7 @@ from packaging import version
 from aiosmtpd import __version__
 
 RE_DUNDERVER = re.compile(r"__version__\s*?=\s*?(['\"])(?P<ver>[^'\"]+)\1\s*$")
-RE_VERHEADING = re.compile(r"(?P<ver>[0-9.]+)\s*\((?P<date>[^)]+)\)")
+RE_VERHEADING = re.compile(r"(?P<ver>([0-9.]+)\S*)\s*\((?P<date>[^)]+)\)")
 
 
 @pytest.fixture
diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
index cfc37e4..a977f75 100644
--- a/aiosmtpd/smtp.py
+++ b/aiosmtpd/smtp.py
@@ -34,7 +34,7 @@ from warnings import warn
 import attr
 from public import public
 
-from aiosmtpd import __version__
+from aiosmtpd import __version__, _get_or_new_eventloop
 from aiosmtpd.proxy_protocol import ProxyData, get_proxy
 
 
@@ -208,7 +208,7 @@ class Envelope:
 # 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() -> asyncio.AbstractEventLoop:
-    return asyncio.get_event_loop()
+    return _get_or_new_eventloop()
 
 
 @public
@@ -336,7 +336,7 @@ class SMTP(asyncio.StreamReaderProtocol):
         self.loop = loop if loop else make_loop()
         super().__init__(
             asyncio.StreamReader(loop=self.loop, limit=self.line_length_limit),
-            client_connected_cb=self._client_connected_cb,
+            client_connected_cb=self._cb_client_connected,
             loop=self.loop)
         self.event_handler = handler
         assert data_size_limit is None or isinstance(data_size_limit, int)
@@ -560,7 +560,7 @@ class SMTP(asyncio.StreamReaderProtocol):
         # up state.
         self.transport.close()
 
-    def _client_connected_cb(
+    def _cb_client_connected(
             self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
     ):
         # This is redundant since we subclass StreamReaderProtocol, but I like
diff --git a/aiosmtpd/tests/conftest.py b/aiosmtpd/tests/conftest.py
index 6a8c3dd..0c69103 100644
--- a/aiosmtpd/tests/conftest.py
+++ b/aiosmtpd/tests/conftest.py
@@ -5,6 +5,7 @@ import asyncio
 import inspect
 import socket
 import ssl
+import warnings
 from contextlib import suppress
 from functools import wraps
 from smtplib import SMTP as SMTPClient
@@ -200,14 +201,20 @@ def get_handler(request: pytest.FixtureRequest) -> Callable:
 
 @pytest.fixture
 def temp_event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
-    default_loop = asyncio.get_event_loop()
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        try:
+            default_loop = asyncio.get_event_loop()
+        except (DeprecationWarning, RuntimeError):
+            default_loop = None
     new_loop = asyncio.new_event_loop()
     asyncio.set_event_loop(new_loop)
     #
     yield new_loop
     #
     new_loop.close()
-    asyncio.set_event_loop(default_loop)
+    if default_loop is not None:
+        asyncio.set_event_loop(default_loop)
 
 
 @pytest.fixture
diff --git a/aiosmtpd/tests/test_handlers.py b/aiosmtpd/tests/test_handlers.py
index d48593c..392689d 100644
--- a/aiosmtpd/tests/test_handlers.py
+++ b/aiosmtpd/tests/test_handlers.py
@@ -816,8 +816,7 @@ class TestProxyMocked:
         logger_name = "mail.debug"
         caplog.set_level(logging.INFO, logger=logger_name)
         client.sendmail("anne@example.com", ["bart@example.com"], self.SOURCE)
-        _l1 = -1
-        for _l1, rt in enumerate(reversed(caplog.record_tuples)):
+        for rt in reversed(caplog.record_tuples):
             if rt == (
                 logger_name,
                 logging.INFO,
diff --git a/aiosmtpd/tests/test_misc.py b/aiosmtpd/tests/test_misc.py
new file mode 100644
index 0000000..94f1489
--- /dev/null
+++ b/aiosmtpd/tests/test_misc.py
@@ -0,0 +1,52 @@
+# Copyright 2014-2021 The aiosmtpd Developers
+# SPDX-License-Identifier: Apache-2.0
+
+"""Test other aspects of the server implementation."""
+
+import asyncio
+import warnings
+from typing import Generator, Optional
+
+import pytest
+
+from aiosmtpd import _get_or_new_eventloop
+
+
+@pytest.fixture(scope="module")
+def close_existing_loop() -> Generator[Optional[asyncio.AbstractEventLoop], None, None]:
+    loop: Optional[asyncio.AbstractEventLoop]
+    with warnings.catch_warnings():
+        warnings.filterwarnings("error")
+        try:
+            loop = asyncio.get_event_loop()
+        except (DeprecationWarning, RuntimeError):
+            loop = None
+    if loop:
+        loop.stop()
+        loop.close()
+        asyncio.set_event_loop(None)
+        yield loop
+    else:
+        yield None
+
+
+class TestInit:
+
+    def test_create_new_if_none(self, close_existing_loop):
+        old_loop = close_existing_loop
+        loop: Optional[asyncio.AbstractEventLoop]
+        loop = _get_or_new_eventloop()
+        assert loop is not None
+        assert loop is not old_loop
+        assert isinstance(loop, asyncio.AbstractEventLoop)
+
+    def test_not_create_new_if_exist(self, close_existing_loop):
+        old_loop = close_existing_loop
+        loop: Optional[asyncio.AbstractEventLoop]
+        loop = asyncio.new_event_loop()
+        assert loop is not old_loop
+        asyncio.set_event_loop(loop)
+        ret_loop = _get_or_new_eventloop()
+        assert ret_loop is not old_loop
+        assert ret_loop == loop
+        assert ret_loop is loop
diff --git a/aiosmtpd/tests/test_proxyprotocol.py b/aiosmtpd/tests/test_proxyprotocol.py
index 949e032..02ec5f3 100644
--- a/aiosmtpd/tests/test_proxyprotocol.py
+++ b/aiosmtpd/tests/test_proxyprotocol.py
@@ -1112,7 +1112,6 @@ class TestHandlerAcceptReject:
         else:
             oper = operator.eq
             expect = pytest.raises(SMTPServerDisconnected)
-        oper = operator.ne if handler_retval else operator.eq
         with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
             sock.connect(Global.SrvAddr)
             sock.sendall(handshake)
diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py
index bda6d3b..656e963 100644
--- a/aiosmtpd/tests/test_server.py
+++ b/aiosmtpd/tests/test_server.py
@@ -403,7 +403,7 @@ class TestUnthreaded:
         def starter(loop: asyncio.AbstractEventLoop):
             nonlocal thread
             thread = Thread(target=_runner, args=(loop,))
-            thread.setDaemon(True)
+            thread.daemon = True
             thread.start()
             catchup_delay()
 
diff --git a/debian/changelog b/debian/changelog
index 72e5698..94e6c00 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,11 @@
+python-aiosmtpd (1.4.4.post2-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+  * Drop patch 0003-Remove-imported-images-from-the-web-for-privacy.patch,
+    present upstream.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Sat, 03 Jun 2023 11:58:42 -0000
+
 python-aiosmtpd (1.4.3-1.1) unstable; urgency=medium
 
   * Non-maintainer upload.
diff --git a/debian/patches/0001-Skip-a-git-version-test.patch b/debian/patches/0001-Skip-a-git-version-test.patch
index 14cda15..2821e80 100644
--- a/debian/patches/0001-Skip-a-git-version-test.patch
+++ b/debian/patches/0001-Skip-a-git-version-test.patch
@@ -7,10 +7,10 @@ Forwarded: not-needed
  aiosmtpd/qa/test_0packaging.py | 1 +
  1 file changed, 1 insertion(+)
 
-diff --git a/aiosmtpd/qa/test_0packaging.py b/aiosmtpd/qa/test_0packaging.py
-index 1f3fa6a..cb2f5c1 100644
---- a/aiosmtpd/qa/test_0packaging.py
-+++ b/aiosmtpd/qa/test_0packaging.py
+Index: python-aiosmtpd.git/aiosmtpd/qa/test_0packaging.py
+===================================================================
+--- python-aiosmtpd.git.orig/aiosmtpd/qa/test_0packaging.py
++++ python-aiosmtpd.git/aiosmtpd/qa/test_0packaging.py
 @@ -33,6 +33,7 @@ class TestVersion:
          ), "Version number must comply with PEP-440"
  
diff --git a/debian/patches/0002-Drop-sphinx-autofixture-extension-requirement.patch b/debian/patches/0002-Drop-sphinx-autofixture-extension-requirement.patch
index ae6a096..9fd0da0 100644
--- a/debian/patches/0002-Drop-sphinx-autofixture-extension-requirement.patch
+++ b/debian/patches/0002-Drop-sphinx-autofixture-extension-requirement.patch
@@ -7,10 +7,10 @@ Forwarded: not-needed
  aiosmtpd/docs/conf.py | 2 +-
  1 file changed, 1 insertion(+), 1 deletion(-)
 
-diff --git a/aiosmtpd/docs/conf.py b/aiosmtpd/docs/conf.py
-index 689e4a7..ab4495b 100644
---- a/aiosmtpd/docs/conf.py
-+++ b/aiosmtpd/docs/conf.py
+Index: python-aiosmtpd.git/aiosmtpd/docs/conf.py
+===================================================================
+--- python-aiosmtpd.git.orig/aiosmtpd/docs/conf.py
++++ python-aiosmtpd.git/aiosmtpd/docs/conf.py
 @@ -60,7 +60,7 @@ extensions = [
      "sphinx.ext.intersphinx",
      "sphinx.ext.autodoc",
diff --git a/debian/patches/0003-Remove-imported-images-from-the-web-for-privacy.patch b/debian/patches/0003-Remove-imported-images-from-the-web-for-privacy.patch
deleted file mode 100644
index 1124faa..0000000
--- a/debian/patches/0003-Remove-imported-images-from-the-web-for-privacy.patch
+++ /dev/null
@@ -1,97 +0,0 @@
-From: =?utf-8?q?Pierre-Elliott_B=C3=A9cue?= <peb@debian.org>
-Date: Mon, 18 Oct 2021 23:22:10 +0200
-Subject: Remove imported images from the web for privacy
-
-Forwarded: not-needed
----
- DESCRIPTION.rst | 36 ------------------------------------
- README.rst      | 29 -----------------------------
- 2 files changed, 65 deletions(-)
-
-diff --git a/DESCRIPTION.rst b/DESCRIPTION.rst
-index f341b0a..a82b431 100644
---- a/DESCRIPTION.rst
-+++ b/DESCRIPTION.rst
-@@ -7,42 +7,6 @@
- | |GH Release| |_| |GH PRs| |_| |GH LastCommit|
- |
- 
--.. |_| 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
--   :alt: GitHub Actions status
--.. |codecov| image:: https://codecov.io/github/aio-libs/aiosmtpd/coverage.svg?branch=master
--   :target: https://codecov.io/github/aio-libs/aiosmtpd?branch=master
--   :alt: Code Coverage
--.. |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://img.shields.io/readthedocs/aiosmtpd?logo=Read+the+Docs
--   :target: https://aiosmtpd.readthedocs.io/en/latest/?badge=latest
--   :alt: Documentation Status
--.. .. 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
--.. |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
--.. |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
--
- This is a server for SMTP and related MTA protocols,
- similar in utility to the standard library's |smtpd.py|_ module,
- but rewritten to be based on ``asyncio`` for Python 3.7+.
-diff --git a/README.rst b/README.rst
-index 9cca496..d60d251 100644
---- a/README.rst
-+++ b/README.rst
-@@ -7,35 +7,6 @@
- |
- | |Discourse|
- 
--.. |_| 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
--   :alt: GitHub Actions status
--.. |codecov| image:: https://codecov.io/github/aio-libs/aiosmtpd/coverage.svg?branch=master
--   :target: https://codecov.io/github/aio-libs/aiosmtpd?branch=master
--   :alt: Code Coverage
--.. |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://img.shields.io/readthedocs/aiosmtpd?logo=Read+the+Docs&logoColor=white
--   :target: https://aiosmtpd.readthedocs.io/en/latest/
--   :alt: Documentation Status
--.. .. 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
--   :target: https://aio-libs.discourse.group/
--   :alt: Discourse
- 
- The Python standard library includes a basic |SMTP|_ server in the |smtpd|_ module,
- based on the old asynchronous libraries |asyncore|_ and |asynchat|_.
diff --git a/debian/patches/0004-Replace-a-dynamic-date-in-copyright-by-a-static-one.patch b/debian/patches/0004-Replace-a-dynamic-date-in-copyright-by-a-static-one.patch
index 3a38937..496898f 100644
--- a/debian/patches/0004-Replace-a-dynamic-date-in-copyright-by-a-static-one.patch
+++ b/debian/patches/0004-Replace-a-dynamic-date-in-copyright-by-a-static-one.patch
@@ -7,10 +7,10 @@ Necessary for reproducibility
  aiosmtpd/docs/conf.py | 2 +-
  1 file changed, 1 insertion(+), 1 deletion(-)
 
-diff --git a/aiosmtpd/docs/conf.py b/aiosmtpd/docs/conf.py
-index ab4495b..b4fb709 100644
---- a/aiosmtpd/docs/conf.py
-+++ b/aiosmtpd/docs/conf.py
+Index: python-aiosmtpd.git/aiosmtpd/docs/conf.py
+===================================================================
+--- python-aiosmtpd.git.orig/aiosmtpd/docs/conf.py
++++ python-aiosmtpd.git/aiosmtpd/docs/conf.py
 @@ -83,7 +83,7 @@ master_doc = "index"
  author = "The aiosmtpd Developers"
  project = "aiosmtpd"
diff --git a/debian/patches/series b/debian/patches/series
index debdae2..83f95b4 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -1,4 +1,3 @@
 0001-Skip-a-git-version-test.patch
 0002-Drop-sphinx-autofixture-extension-requirement.patch
-0003-Remove-imported-images-from-the-web-for-privacy.patch
 0004-Replace-a-dynamic-date-in-copyright-by-a-static-one.patch
diff --git a/examples/authenticated_relayer/server.py b/examples/authenticated_relayer/server.py
index fd4a7b4..f00b602 100644
--- a/examples/authenticated_relayer/server.py
+++ b/examples/authenticated_relayer/server.py
@@ -94,9 +94,10 @@ if __name__ == '__main__':
         print(f"Please create {DB_AUTH} first using make_user_db.py")
         sys.exit(1)
     logging.basicConfig(level=logging.DEBUG)
-    loop = asyncio.get_event_loop()
+    loop = asyncio.new_event_loop()
+    asyncio.set_event_loop(loop)
     loop.create_task(amain())
     try:
         loop.run_forever()
     except KeyboardInterrupt:
-        pass
+        print("User abort indicated")
diff --git a/examples/basic/server.py b/examples/basic/server.py
index ab91a13..fcb572a 100644
--- a/examples/basic/server.py
+++ b/examples/basic/server.py
@@ -15,9 +15,10 @@ async def amain(loop):
 
 if __name__ == '__main__':
     logging.basicConfig(level=logging.DEBUG)
-    loop = asyncio.get_event_loop()
+    loop = asyncio.new_event_loop()
+    asyncio.set_event_loop(loop)
     loop.create_task(amain(loop=loop))
     try:
         loop.run_forever()
     except KeyboardInterrupt:
-        pass
+        print("User abort indicated")
diff --git a/pyproject.toml b/pyproject.toml
index e067d36..8731e60 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,8 @@ source = [
 
 [tool.coverage.coverage_conditional_plugin.rules]
 # Here we specify our pragma rules:
+py-lt-312 = "sys_version_info < (3, 12)"
+py-lt-310 = "sys_version_info < (3, 10)"
 py-ge-38 = "sys_version_info >= (3, 8)"
 py-lt-38 = "sys_version_info < (3, 8)"
 py-gt-36 = "sys_version_info > (3, 6)"
diff --git a/release.py b/release.py
index d0c3a4a..00c2e13 100755
--- a/release.py
+++ b/release.py
@@ -12,18 +12,26 @@ import time
 from functools import partial
 from pathlib import Path
 
-from aiosmtpd import __version__ as version
+from packaging import version
+
+from aiosmtpd import __version__ as ver_str
 
 printfl = partial(print, flush=True)
 run_hidden = partial(subprocess.run, stdout=subprocess.PIPE)
 
+result = run_hidden(shlex.split("git status --porcelain"))
+if result.stdout:
+    print("git is not clean!")
+    print("Please commit/shelf first before releasing!")
+    sys.exit(1)
+
 TWINE_CONFIG = Path(os.environ.get("TWINE_CONFIG", "~/.pypirc")).expanduser()
 TWINE_REPO = os.environ.get("TWINE_REPOSITORY", "aiosmtpd")
 UPSTREAM_REMOTE = os.environ.get("UPSTREAM_REMOTE", "upstream")
 GPG_SIGNING_ID = os.environ.get("GPG_SIGNING_ID")
 DISTFILES = [
-    f"dist/aiosmtpd-{version}.tar.gz",
-    f"dist/aiosmtpd-{version}-py3-none-any.whl",
+    f"dist/aiosmtpd-{ver_str}.tar.gz",
+    f"dist/aiosmtpd-{ver_str}-py3-none-any.whl",
 ]
 
 printfl("Updating release toolkit first...", end="")
@@ -48,20 +56,20 @@ UPSTREAM_REMOTE = {UPSTREAM_REMOTE}
 GPG_SIGNING_ID = {GPG_SIGNING_ID or 'None'}
 """
 )
-choice = input(f"Release aiosmtpd {version} - correct? [y/N]: ")
+choice = input(f"Release aiosmtpd {ver_str} - correct? [y/N]: ")
 if choice.lower() not in ("y", "yes"):
     sys.exit("Release aborted")
 
 newsfile = Path(".") / "aiosmtpd" / "docs" / "NEWS.rst"
 with newsfile.open("rt") as fin:
-    want = re.compile("^" + re.escape(version) + r"\s*\(\d{4}-\d\d-\d\d\)")
+    want = re.compile("^" + re.escape(ver_str) + r"\s*\(\d{4}-\d\d-\d\d\)")
     for ln in fin:
         m = want.match(ln)
         if not m:
             continue
         break
     else:
-        print(f"ERROR: I found no datestamped entry for {version} in NEWS.rst!")
+        print(f"ERROR: I found no datestamped entry for {ver_str} in NEWS.rst!")
         sys.exit(1)
 
 if not GPG_SIGNING_ID:
@@ -99,7 +107,16 @@ try:
     # Assuming twine is installed.
     print("### twine check")
     subprocess.run(["twine", "check"] + DISTFILES, check=True)
+except subprocess.CalledProcessError as e:
+    print("ERROR: Last step returned exitcode != 0")
+    sys.exit(e.returncode)
 
+choice = input("Ready to upload to PyPI? [y/N]: ")
+if choice.casefold() not in ("y", "yes"):
+    print("Okay.")
+    sys.exit(0)
+
+try:
     # You should have an aiosmtpd bit setup in your ~/.pypirc - for twine
     twine_up = f"twine upload --config-file {TWINE_CONFIG} -r {TWINE_REPO}".split()
     if GPG_SIGNING_ID:
@@ -134,10 +151,20 @@ if has_verify:
 # Only tag when we've actually built and uploaded. If something goes wrong
 # we may need the tag somewhere else!
 choice = input("tag and push? [y/N]: ")
-if choice.lower() not in ("y", "yes"):
-    pass
-else:
+if choice.lower() in ("y", "yes"):
     # The annotation information should come from the changelog
-    subprocess.run(["git", "tag", "-a", version])
+    subprocess.run(["git", "tag", "-a", ver_str])
     # And now push the tag, of course.
-    subprocess.run(["git", "push", "--atomic", UPSTREAM_REMOTE, "master", version])
+    subprocess.run(["git", "push", "--atomic", UPSTREAM_REMOTE, "master", ver_str])
+    vv = version.parse(ver_str)
+    new_ver = version.Version(f"{vv.major}.{vv.minor}.{vv.micro + 1}a0")
+    print("\u2591\u2592\u2593\u2588 IMPORTANT \u2588\u2593\u2592\u2591")
+    print(
+        f"Now that version {ver_str} has been tagged and pushed, "
+        f"you should bump the code to a new version."
+    )
+    print(
+        f"Suggested version is '{new_ver}'. Please do a grep for "
+        f"the old version and perform changes as necessary."
+    )
+    print("(Also remember to add a news stub in NEWS.rst if you bump the version.)")
diff --git a/tox.ini b/tox.ini
index f4fcf92..211e78d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,15 +4,7 @@ envlist = qa, static, docs, py{37,38,39,310,311,py3}-{nocov,cov,diffcov}
 skip_missing_interpreters = True
 
 [testenv]
-# One virtualenv per Python version
-envdir =
-    py37: {toxworkdir}/3.7
-    py38: {toxworkdir}/3.8
-    py39: {toxworkdir}/3.9
-    py310: {toxworkdir}/3.10
-    py311: {toxworkdir}/3.11
-    pypy3: {toxworkdir}/pypy3
-    py: {toxworkdir}/py
+envdir = {toxworkdir}/{envname}
 commands =
     python housekeep.py prep
     # Bandit is not needed on diffcov, and seems to be incompatible with 310
@@ -27,20 +19,20 @@ 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]
-    coverage-conditional-plugin
     packaging
     pytest >= 6.0  # Require >= 6.0 for pyproject.toml support (PEP 517)
-    pytest-cov
     pytest-mock
     pytest-print
     pytest-profiling
     pytest-sugar
     py # needed for pytest-sugar as it doesn't declare dependency on it.
-    diff_cover
+    !nocov: coverage>=7.0.1
+    !nocov: coverage[toml]
+    !nocov: coverage-conditional-plugin
+    !nocov: pytest-cov
+    diffcov: diff_cover
 setenv =
     cov: COVERAGE_FILE={toxinidir}/_dump/.coverage
     nocov: PYTHONASYNCIODEBUG=1
@@ -50,6 +42,8 @@ setenv =
     py310: INTERP=py310
     py311: INTERP=py311
     pypy3: INTERP=pypy3
+    pypy37: INTERP=pypy37
+    pypy38: INTERP=pypy38
     py: INTERP=py
 passenv =
     PYTHON*

More details

Full run details

Historical runs