2736 lines
95 KiB
Diff
2736 lines
95 KiB
Diff
|
|
From 1a1c1bb15d4659f1076c7e14a064721761d81aa6 Mon Sep 17 00:00:00 2001
|
||
|
|
From: Pandu E POLUAN <pepoluan@gmail.com>
|
||
|
|
Date: Tue, 23 Mar 2021 13:31:32 +0700
|
||
|
|
Subject: [PATCH 2/4] Code Hygiene (#259)
|
||
|
|
|
||
|
|
* Activate LOTS of flake8 plugins to enforce code hygiene
|
||
|
|
* Tune Annotation Thresholds
|
||
|
|
* Add Annotation
|
||
|
|
* Add pytest-mock to the deps of "docs"
|
||
|
|
* Fix post-rebase flake8 complaints
|
||
|
|
* Update NEWS.rst
|
||
|
|
* Move flake8 plugins into a pseudo-section in tox.ini
|
||
|
|
* Create concrete class for MessageHandler
|
||
|
|
* Bump Version to 1.5.0a2
|
||
|
|
* Experimentally enable tox-ing on 3.10
|
||
|
|
* Use typing.ByteString instead of custom AnyBytes
|
||
|
|
---
|
||
|
|
.../workflows/unit-testing-and-coverage.yml | 10 +-
|
||
|
|
aiosmtpd/__init__.py | 2 +-
|
||
|
|
aiosmtpd/controller.py | 10 +-
|
||
|
|
aiosmtpd/docs/NEWS.rst | 5 +-
|
||
|
|
aiosmtpd/docs/_exts/autoprogramm.py | 64 ++++---
|
||
|
|
aiosmtpd/docs/conf.py | 9 +-
|
||
|
|
aiosmtpd/docs/proxyprotocol.rst | 6 +-
|
||
|
|
aiosmtpd/docs/smtp.rst | 2 +-
|
||
|
|
aiosmtpd/handlers.py | 158 +++++++++++-------
|
||
|
|
aiosmtpd/lmtp.py | 6 +-
|
||
|
|
aiosmtpd/main.py | 11 +-
|
||
|
|
aiosmtpd/proxy_protocol.py | 32 ++--
|
||
|
|
aiosmtpd/qa/test_0packaging.py | 38 ++++-
|
||
|
|
aiosmtpd/qa/test_1testsuite.py | 6 +-
|
||
|
|
aiosmtpd/smtp.py | 124 ++++++++------
|
||
|
|
aiosmtpd/testing/helpers.py | 10 +-
|
||
|
|
aiosmtpd/tests/conftest.py | 48 +++---
|
||
|
|
aiosmtpd/tests/test_handlers.py | 90 +++++++---
|
||
|
|
aiosmtpd/tests/test_main.py | 16 +-
|
||
|
|
aiosmtpd/tests/test_proxyprotocol.py | 67 +++++---
|
||
|
|
aiosmtpd/tests/test_server.py | 26 +--
|
||
|
|
aiosmtpd/tests/test_smtp.py | 89 +++++-----
|
||
|
|
aiosmtpd/tests/test_starttls.py | 17 +-
|
||
|
|
housekeep.py | 3 +-
|
||
|
|
setup.cfg | 52 +++++-
|
||
|
|
tox.ini | 57 ++++++-
|
||
|
|
26 files changed, 627 insertions(+), 331 deletions(-)
|
||
|
|
|
||
|
|
diff --git a/.github/workflows/unit-testing-and-coverage.yml b/.github/workflows/unit-testing-and-coverage.yml
|
||
|
|
index f7b0e32..ebc2248 100644
|
||
|
|
--- a/.github/workflows/unit-testing-and-coverage.yml
|
||
|
|
+++ b/.github/workflows/unit-testing-and-coverage.yml
|
||
|
|
@@ -38,9 +38,17 @@ jobs:
|
||
|
|
python -m pip install --upgrade pip setuptools wheel
|
||
|
|
python setup.py develop
|
||
|
|
- name: "flake8 Style Checking"
|
||
|
|
+ shell: bash
|
||
|
|
# language=bash
|
||
|
|
run: |
|
||
|
|
- pip install colorama flake8 flake8-bugbear
|
||
|
|
+ # A bunch of flake8 plugins...
|
||
|
|
+ grab_f8_plugins=(
|
||
|
|
+ "from configparser import ConfigParser;"
|
||
|
|
+ "config = ConfigParser();"
|
||
|
|
+ "config.read('tox.ini');"
|
||
|
|
+ "print(config['flake8_plugins']['deps']);"
|
||
|
|
+ )
|
||
|
|
+ pip install colorama flake8 $(python -c "${grab_f8_plugins[*]}")
|
||
|
|
python -m flake8 aiosmtpd setup.py housekeep.py release.py
|
||
|
|
- name: "Docs Checking"
|
||
|
|
# language=bash
|
||
|
|
diff --git a/aiosmtpd/__init__.py b/aiosmtpd/__init__.py
|
||
|
|
index 9c7b938..e96d0ee 100644
|
||
|
|
--- a/aiosmtpd/__init__.py
|
||
|
|
+++ b/aiosmtpd/__init__.py
|
||
|
|
@@ -1,4 +1,4 @@
|
||
|
|
# Copyright 2014-2021 The aiosmtpd Developers
|
||
|
|
# SPDX-License-Identifier: Apache-2.0
|
||
|
|
|
||
|
|
-__version__ = "1.5.0a1"
|
||
|
|
+__version__ = "1.5.0a2"
|
||
|
|
diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py
|
||
|
|
index d3345b8..79bdbd0 100644
|
||
|
|
--- a/aiosmtpd/controller.py
|
||
|
|
+++ b/aiosmtpd/controller.py
|
||
|
|
@@ -85,7 +85,7 @@ class _FakeServer(asyncio.StreamReaderProtocol):
|
||
|
|
factory() failed to instantiate an SMTP instance.
|
||
|
|
"""
|
||
|
|
|
||
|
|
- def __init__(self, loop):
|
||
|
|
+ def __init__(self, loop: asyncio.AbstractEventLoop):
|
||
|
|
# Imitate what SMTP does
|
||
|
|
super().__init__(
|
||
|
|
asyncio.StreamReader(loop=loop),
|
||
|
|
@@ -93,7 +93,9 @@ class _FakeServer(asyncio.StreamReaderProtocol):
|
||
|
|
loop=loop,
|
||
|
|
)
|
||
|
|
|
||
|
|
- def _client_connected_cb(self, reader, writer):
|
||
|
|
+ def _client_connected_cb(
|
||
|
|
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||
|
|
+ ) -> None:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
@@ -143,7 +145,7 @@ class BaseController(metaclass=ABCMeta):
|
||
|
|
"""Subclasses can override this to customize the handler/server creation."""
|
||
|
|
return SMTP(self.handler, **self.SMTP_kwargs)
|
||
|
|
|
||
|
|
- def _factory_invoker(self):
|
||
|
|
+ def _factory_invoker(self) -> Union[SMTP, _FakeServer]:
|
||
|
|
"""Wraps factory() to catch exceptions during instantiation"""
|
||
|
|
try:
|
||
|
|
self.smtpd = self.factory()
|
||
|
|
@@ -223,7 +225,7 @@ class BaseThreadedController(BaseController, metaclass=ABCMeta):
|
||
|
|
"""
|
||
|
|
raise NotImplementedError
|
||
|
|
|
||
|
|
- def _run(self, ready_event: threading.Event):
|
||
|
|
+ def _run(self, ready_event: threading.Event) -> None:
|
||
|
|
asyncio.set_event_loop(self.loop)
|
||
|
|
try:
|
||
|
|
# Need to do two-step assignments here to ensure IDEs can properly
|
||
|
|
diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst
|
||
|
|
index ce627a7..eea911d 100644
|
||
|
|
--- a/aiosmtpd/docs/NEWS.rst
|
||
|
|
+++ b/aiosmtpd/docs/NEWS.rst
|
||
|
|
@@ -3,8 +3,8 @@
|
||
|
|
###################
|
||
|
|
|
||
|
|
|
||
|
|
-1.5.0 (aiosmtpd-next-next)
|
||
|
|
-==========================
|
||
|
|
+1.5.0 (aiosmtpd-next)
|
||
|
|
+=====================
|
||
|
|
|
||
|
|
Added
|
||
|
|
-----
|
||
|
|
@@ -13,6 +13,7 @@ Added
|
||
|
|
Fixed/Improved
|
||
|
|
--------------
|
||
|
|
* All Controllers now have more rationale design, as they are now composited from a Base + a Mixin
|
||
|
|
+* A whole bunch of annotations
|
||
|
|
|
||
|
|
|
||
|
|
1.4.2 (2021-03-08)
|
||
|
|
diff --git a/aiosmtpd/docs/_exts/autoprogramm.py b/aiosmtpd/docs/_exts/autoprogramm.py
|
||
|
|
index 69088be..c23bd2f 100644
|
||
|
|
--- a/aiosmtpd/docs/_exts/autoprogramm.py
|
||
|
|
+++ b/aiosmtpd/docs/_exts/autoprogramm.py
|
||
|
|
@@ -32,6 +32,7 @@ import argparse
|
||
|
|
import builtins
|
||
|
|
import collections
|
||
|
|
import os
|
||
|
|
+import sphinx
|
||
|
|
|
||
|
|
from docutils import nodes
|
||
|
|
from docutils.parsers.rst import Directive
|
||
|
|
@@ -39,13 +40,13 @@ from docutils.parsers.rst.directives import unchanged
|
||
|
|
from docutils.statemachine import StringList
|
||
|
|
from functools import reduce
|
||
|
|
from sphinx.util.nodes import nested_parse_with_titles
|
||
|
|
-from typing import List
|
||
|
|
+from typing import Any, Dict, List, Optional, Tuple
|
||
|
|
|
||
|
|
|
||
|
|
__all__ = ("AutoprogrammDirective", "import_object", "scan_programs", "setup")
|
||
|
|
|
||
|
|
|
||
|
|
-def get_subparser_action(parser):
|
||
|
|
+def get_subparser_action(parser: argparse.ArgumentParser) -> argparse._SubParsersAction:
|
||
|
|
neg1_action = parser._actions[-1]
|
||
|
|
|
||
|
|
if isinstance(neg1_action, argparse._SubParsersAction):
|
||
|
|
@@ -56,7 +57,13 @@ def get_subparser_action(parser):
|
||
|
|
return a
|
||
|
|
|
||
|
|
|
||
|
|
-def scan_programs(parser, command=None, maxdepth=0, depth=0, groups=False):
|
||
|
|
+def scan_programs(
|
||
|
|
+ parser: argparse.ArgumentParser,
|
||
|
|
+ command: List[str] = None,
|
||
|
|
+ maxdepth: int = 0,
|
||
|
|
+ depth: int = 0,
|
||
|
|
+ groups: bool = False,
|
||
|
|
+):
|
||
|
|
if command is None:
|
||
|
|
command = []
|
||
|
|
|
||
|
|
@@ -79,6 +86,7 @@ def scan_programs(parser, command=None, maxdepth=0, depth=0, groups=False):
|
||
|
|
subp_action = get_subparser_action(parser)
|
||
|
|
|
||
|
|
if subp_action:
|
||
|
|
+ # noinspection PyUnresolvedReferences
|
||
|
|
choices = subp_action.choices.items()
|
||
|
|
|
||
|
|
if not (
|
||
|
|
@@ -89,11 +97,10 @@ def scan_programs(parser, command=None, maxdepth=0, depth=0, groups=False):
|
||
|
|
|
||
|
|
for cmd, sub in choices:
|
||
|
|
if isinstance(sub, argparse.ArgumentParser):
|
||
|
|
- for program in scan_programs(sub, command + [cmd], maxdepth, depth + 1):
|
||
|
|
- yield program
|
||
|
|
+ yield from scan_programs(sub, command + [cmd], maxdepth, depth + 1)
|
||
|
|
|
||
|
|
|
||
|
|
-def scan_options(actions):
|
||
|
|
+def scan_options(actions: list):
|
||
|
|
for arg in actions:
|
||
|
|
if not (arg.option_strings or isinstance(arg, argparse._SubParsersAction)):
|
||
|
|
yield format_positional_argument(arg)
|
||
|
|
@@ -103,13 +110,13 @@ def scan_options(actions):
|
||
|
|
yield format_option(arg)
|
||
|
|
|
||
|
|
|
||
|
|
-def format_positional_argument(arg):
|
||
|
|
+def format_positional_argument(arg: argparse.Action) -> Tuple[List[str], str]:
|
||
|
|
desc = (arg.help or "") % {"default": arg.default}
|
||
|
|
name = arg.metavar or arg.dest
|
||
|
|
return [name], desc
|
||
|
|
|
||
|
|
|
||
|
|
-def format_option(arg):
|
||
|
|
+def format_option(arg: argparse.Action) -> Tuple[List[str], str]:
|
||
|
|
desc = (arg.help or "") % {"default": arg.default}
|
||
|
|
|
||
|
|
if not isinstance(arg, (argparse._StoreAction, argparse._AppendAction)):
|
||
|
|
@@ -131,7 +138,7 @@ def format_option(arg):
|
||
|
|
return names, desc
|
||
|
|
|
||
|
|
|
||
|
|
-def import_object(import_name):
|
||
|
|
+def import_object(import_name: str) -> Any:
|
||
|
|
module_name, expr = import_name.split(":", 1)
|
||
|
|
try:
|
||
|
|
mod = __import__(module_name)
|
||
|
|
@@ -151,7 +158,8 @@ def import_object(import_name):
|
||
|
|
with open(f[0]) as fobj:
|
||
|
|
codestring = fobj.read()
|
||
|
|
foo = imp.new_module("foo")
|
||
|
|
- exec(codestring, foo.__dict__) # nosec
|
||
|
|
+ # noinspection BuiltinExec
|
||
|
|
+ exec(codestring, foo.__dict__) # noqa: DUO105 # nosec
|
||
|
|
|
||
|
|
sys.modules["foo"] = foo
|
||
|
|
mod = __import__("foo")
|
||
|
|
@@ -163,7 +171,7 @@ def import_object(import_name):
|
||
|
|
globals_ = builtins
|
||
|
|
if not isinstance(globals_, dict):
|
||
|
|
globals_ = globals_.__dict__
|
||
|
|
- return eval(expr, globals_, mod.__dict__) # nosec
|
||
|
|
+ return eval(expr, globals_, mod.__dict__) # noqa: DUO104 # nosec
|
||
|
|
|
||
|
|
|
||
|
|
class AutoprogrammDirective(Directive):
|
||
|
|
@@ -204,13 +212,16 @@ class AutoprogrammDirective(Directive):
|
||
|
|
|
||
|
|
if start_command:
|
||
|
|
|
||
|
|
- def get_start_cmd_parser(p):
|
||
|
|
+ def get_start_cmd_parser(
|
||
|
|
+ p: argparse.ArgumentParser,
|
||
|
|
+ ) -> argparse.ArgumentParser:
|
||
|
|
looking_for = start_command.pop(0)
|
||
|
|
action = get_subparser_action(p)
|
||
|
|
|
||
|
|
if not action:
|
||
|
|
raise ValueError("No actions for command " + looking_for)
|
||
|
|
|
||
|
|
+ # noinspection PyUnresolvedReferences
|
||
|
|
subp = action.choices[looking_for]
|
||
|
|
|
||
|
|
if start_command:
|
||
|
|
@@ -263,7 +274,7 @@ class AutoprogrammDirective(Directive):
|
||
|
|
options_adornment=options_adornment,
|
||
|
|
)
|
||
|
|
|
||
|
|
- def run(self):
|
||
|
|
+ def run(self) -> list:
|
||
|
|
node = nodes.section()
|
||
|
|
node.document = self.state.document
|
||
|
|
result = StringList()
|
||
|
|
@@ -274,17 +285,17 @@ class AutoprogrammDirective(Directive):
|
||
|
|
|
||
|
|
|
||
|
|
def render_rst(
|
||
|
|
- title,
|
||
|
|
- options,
|
||
|
|
- is_program,
|
||
|
|
- is_subgroup,
|
||
|
|
- description,
|
||
|
|
- usage,
|
||
|
|
- usage_strip,
|
||
|
|
- usage_codeblock,
|
||
|
|
- epilog,
|
||
|
|
- options_title,
|
||
|
|
- options_adornment,
|
||
|
|
+ title: str,
|
||
|
|
+ options: List[Tuple[List[str], str]],
|
||
|
|
+ is_program: bool,
|
||
|
|
+ is_subgroup: bool,
|
||
|
|
+ description: str,
|
||
|
|
+ usage: Optional[str],
|
||
|
|
+ usage_strip: bool,
|
||
|
|
+ usage_codeblock: bool,
|
||
|
|
+ epilog: str,
|
||
|
|
+ options_title: str,
|
||
|
|
+ options_adornment: str,
|
||
|
|
):
|
||
|
|
if usage_strip:
|
||
|
|
to_strip = title.rsplit(" ", 1)[0]
|
||
|
|
@@ -310,8 +321,7 @@ def render_rst(
|
||
|
|
yield ("!" if is_subgroup else "?") * len(title)
|
||
|
|
yield ""
|
||
|
|
|
||
|
|
- for line in (description or "").splitlines():
|
||
|
|
- yield line
|
||
|
|
+ yield from (description or "").splitlines()
|
||
|
|
yield ""
|
||
|
|
|
||
|
|
if usage is None:
|
||
|
|
@@ -340,7 +350,7 @@ def render_rst(
|
||
|
|
yield line or ""
|
||
|
|
|
||
|
|
|
||
|
|
-def setup(app):
|
||
|
|
+def setup(app: sphinx.application.Sphinx) -> Dict[str, Any]:
|
||
|
|
app.add_directive("autoprogramm", AutoprogrammDirective)
|
||
|
|
return {
|
||
|
|
"version": "0.2a0",
|
||
|
|
diff --git a/aiosmtpd/docs/conf.py b/aiosmtpd/docs/conf.py
|
||
|
|
index 6ee2d05..d3273f1 100644
|
||
|
|
--- a/aiosmtpd/docs/conf.py
|
||
|
|
+++ b/aiosmtpd/docs/conf.py
|
||
|
|
@@ -1,6 +1,7 @@
|
||
|
|
-# -*- coding: utf-8 -*-
|
||
|
|
-#
|
||
|
|
-# aiosmtpd documentation build configuration file, created by
|
||
|
|
+# Copyright 2014-2021 The aiosmtpd Developers
|
||
|
|
+# SPDX-License-Identifier: Apache-2.0
|
||
|
|
+
|
||
|
|
+# aiosmtpd documentation build configuration file, originally created by
|
||
|
|
# sphinx-quickstart on Fri Oct 16 12:18:52 2015.
|
||
|
|
#
|
||
|
|
# This file is execfile()d with the current directory set to its
|
||
|
|
@@ -331,5 +332,5 @@ texinfo_documents = [
|
||
|
|
# endregion
|
||
|
|
|
||
|
|
|
||
|
|
-def setup(app):
|
||
|
|
+def setup(app): # noqa: ANN001
|
||
|
|
app.add_css_file("aiosmtpd.css")
|
||
|
|
diff --git a/aiosmtpd/docs/proxyprotocol.rst b/aiosmtpd/docs/proxyprotocol.rst
|
||
|
|
index 30e01b7..eac41b0 100644
|
||
|
|
--- a/aiosmtpd/docs/proxyprotocol.rst
|
||
|
|
+++ b/aiosmtpd/docs/proxyprotocol.rst
|
||
|
|
@@ -203,7 +203,7 @@ Enums
|
||
|
|
Valid only for address family of :attr:`AF.INET` or :attr:`AF.INET6`
|
||
|
|
|
||
|
|
.. py:attribute:: rest
|
||
|
|
- :type: Union[bytes, bytearray]
|
||
|
|
+ :type: ByteString
|
||
|
|
|
||
|
|
The contents depend on the version of the PROXY header *and* (for version 2)
|
||
|
|
the address family.
|
||
|
|
@@ -374,7 +374,7 @@ Enums
|
||
|
|
.. py:classmethod:: from_raw(raw) -> Optional[ProxyTLV]
|
||
|
|
|
||
|
|
:param raw: The raw bytes containing the TLV Vectors
|
||
|
|
- :type raw: Union[bytes, bytearray]
|
||
|
|
+ :type raw: ByteString
|
||
|
|
:return: A new instance of ProxyTLV, or ``None`` if parsing failed
|
||
|
|
|
||
|
|
This triggers the parsing of raw bytes/bytearray into a ProxyTLV instance.
|
||
|
|
@@ -387,7 +387,7 @@ Enums
|
||
|
|
.. py:classmethod:: parse(chunk, partial_ok=True) -> Dict[str, Any]
|
||
|
|
|
||
|
|
:param chunk: The bytes to parse into TLV Vectors
|
||
|
|
- :type chunk: Union[bytes, bytearray]
|
||
|
|
+ :type chunk: ByteString
|
||
|
|
:param partial_ok: If ``True``, return partially-parsed TLV Vectors as is.
|
||
|
|
If ``False``, (re)raise ``MalformedTLV``
|
||
|
|
:type partial_ok: bool
|
||
|
|
diff --git a/aiosmtpd/docs/smtp.rst b/aiosmtpd/docs/smtp.rst
|
||
|
|
index 3305079..b647e32 100644
|
||
|
|
--- a/aiosmtpd/docs/smtp.rst
|
||
|
|
+++ b/aiosmtpd/docs/smtp.rst
|
||
|
|
@@ -499,7 +499,7 @@ aiosmtpd.smtp
|
||
|
|
|
||
|
|
:param challenge: The SMTP AUTH challenge to send to the client.
|
||
|
|
May be in plaintext, may be in base64. Do NOT prefix with "334 "!
|
||
|
|
- :type challenge: Union[str, bytes, bytearray]
|
||
|
|
+ :type challenge: AnyStr
|
||
|
|
:param encode_to_b64: If true, will perform base64-encoding before sending
|
||
|
|
the challenge to the client.
|
||
|
|
:type encode_to_b64: bool
|
||
|
|
diff --git a/aiosmtpd/handlers.py b/aiosmtpd/handlers.py
|
||
|
|
index ada1e91..b22821e 100644
|
||
|
|
--- a/aiosmtpd/handlers.py
|
||
|
|
+++ b/aiosmtpd/handlers.py
|
||
|
|
@@ -10,91 +10,114 @@ your own handling of messages. Implement only the methods you care about.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
+import io
|
||
|
|
import logging
|
||
|
|
import mailbox
|
||
|
|
+import os
|
||
|
|
import re
|
||
|
|
import smtplib
|
||
|
|
import sys
|
||
|
|
from abc import ABCMeta, abstractmethod
|
||
|
|
-from email import message_from_bytes, message_from_string
|
||
|
|
+from argparse import ArgumentParser
|
||
|
|
+from email.message import Message as Em_Message
|
||
|
|
+from email.parser import BytesParser, Parser
|
||
|
|
+from typing import AnyStr, Dict, List, Tuple, Type, TypeVar
|
||
|
|
|
||
|
|
from public import public
|
||
|
|
|
||
|
|
-EMPTYBYTES = b''
|
||
|
|
-COMMASPACE = ', '
|
||
|
|
-CRLF = b'\r\n'
|
||
|
|
-NLCRE = re.compile(br'\r\n|\r|\n')
|
||
|
|
-log = logging.getLogger('mail.debug')
|
||
|
|
+from aiosmtpd.smtp import SMTP as SMTPServer
|
||
|
|
+from aiosmtpd.smtp import Envelope as SMTPEnvelope
|
||
|
|
+from aiosmtpd.smtp import Session as SMTPSession
|
||
|
|
|
||
|
|
+T = TypeVar("T")
|
||
|
|
|
||
|
|
-def _format_peer(peer):
|
||
|
|
+EMPTYBYTES = b""
|
||
|
|
+COMMASPACE = ", "
|
||
|
|
+CRLF = b"\r\n"
|
||
|
|
+NLCRE = re.compile(br"\r\n|\r|\n")
|
||
|
|
+log = logging.getLogger("mail.debug")
|
||
|
|
+
|
||
|
|
+
|
||
|
|
+def _format_peer(peer: str) -> str:
|
||
|
|
# This is a separate function mostly so the test suite can craft a
|
||
|
|
# reproducible output.
|
||
|
|
- return 'X-Peer: {!r}'.format(peer)
|
||
|
|
+ return "X-Peer: {!r}".format(peer)
|
||
|
|
+
|
||
|
|
+
|
||
|
|
+def message_from_bytes(s, *args, **kws):
|
||
|
|
+ return BytesParser(*args, **kws).parsebytes(s)
|
||
|
|
+
|
||
|
|
+
|
||
|
|
+def message_from_string(s, *args, **kws):
|
||
|
|
+ return Parser(*args, **kws).parsestr(s)
|
||
|
|
|
||
|
|
|
||
|
|
@public
|
||
|
|
class Debugging:
|
||
|
|
- def __init__(self, stream=None):
|
||
|
|
+ def __init__(self, stream: io.TextIOBase = None):
|
||
|
|
self.stream = sys.stdout if stream is None else stream
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
- def from_cli(cls, parser, *args):
|
||
|
|
+ def from_cli(cls: Type[T], parser: ArgumentParser, *args) -> T:
|
||
|
|
error = False
|
||
|
|
stream = None
|
||
|
|
if len(args) == 0:
|
||
|
|
pass
|
||
|
|
elif len(args) > 1:
|
||
|
|
error = True
|
||
|
|
- elif args[0] == 'stdout':
|
||
|
|
+ elif args[0] == "stdout":
|
||
|
|
stream = sys.stdout
|
||
|
|
- elif args[0] == 'stderr':
|
||
|
|
+ elif args[0] == "stderr":
|
||
|
|
stream = sys.stderr
|
||
|
|
else:
|
||
|
|
error = True
|
||
|
|
if error:
|
||
|
|
- parser.error('Debugging usage: [stdout|stderr]')
|
||
|
|
+ parser.error("Debugging usage: [stdout|stderr]")
|
||
|
|
return cls(stream)
|
||
|
|
|
||
|
|
- def _print_message_content(self, peer, data):
|
||
|
|
+ def _print_message_content(self, peer: str, data: AnyStr) -> None:
|
||
|
|
in_headers = True
|
||
|
|
for line in data.splitlines():
|
||
|
|
# Dump the RFC 2822 headers first.
|
||
|
|
if in_headers and not line:
|
||
|
|
print(_format_peer(peer), file=self.stream)
|
||
|
|
in_headers = False
|
||
|
|
- if isinstance(data, bytes):
|
||
|
|
+ if isinstance(line, bytes):
|
||
|
|
# Avoid spurious 'str on bytes instance' warning.
|
||
|
|
- line = line.decode('utf-8', 'replace')
|
||
|
|
+ line = line.decode("utf-8", "replace")
|
||
|
|
print(line, file=self.stream)
|
||
|
|
|
||
|
|
- async def handle_DATA(self, server, session, envelope):
|
||
|
|
- print('---------- MESSAGE FOLLOWS ----------', file=self.stream)
|
||
|
|
+ async def handle_DATA(
|
||
|
|
+ self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope
|
||
|
|
+ ) -> str:
|
||
|
|
+ print("---------- MESSAGE FOLLOWS ----------", file=self.stream)
|
||
|
|
# Yes, actually test for truthiness since it's possible for either the
|
||
|
|
# keywords to be missing, or for their values to be empty lists.
|
||
|
|
add_separator = False
|
||
|
|
if envelope.mail_options:
|
||
|
|
- print('mail options:', envelope.mail_options, file=self.stream)
|
||
|
|
+ print("mail options:", envelope.mail_options, file=self.stream)
|
||
|
|
add_separator = True
|
||
|
|
# rcpt_options are not currently support by the SMTP class.
|
||
|
|
rcpt_options = envelope.rcpt_options
|
||
|
|
- if any(rcpt_options): # pragma: nocover
|
||
|
|
- print('rcpt options:', rcpt_options, file=self.stream)
|
||
|
|
+ if any(rcpt_options): # pragma: nocover
|
||
|
|
+ print("rcpt options:", rcpt_options, file=self.stream)
|
||
|
|
add_separator = True
|
||
|
|
if add_separator:
|
||
|
|
print(file=self.stream)
|
||
|
|
self._print_message_content(session.peer, envelope.content)
|
||
|
|
- print('------------ END MESSAGE ------------', file=self.stream)
|
||
|
|
- return '250 OK'
|
||
|
|
+ print("------------ END MESSAGE ------------", file=self.stream)
|
||
|
|
+ return "250 OK"
|
||
|
|
|
||
|
|
|
||
|
|
@public
|
||
|
|
class Proxy:
|
||
|
|
- def __init__(self, remote_hostname, remote_port):
|
||
|
|
+ def __init__(self, remote_hostname: str, remote_port: int):
|
||
|
|
self._hostname = remote_hostname
|
||
|
|
self._port = remote_port
|
||
|
|
|
||
|
|
- async def handle_DATA(self, server, session, envelope):
|
||
|
|
+ async def handle_DATA(
|
||
|
|
+ self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope
|
||
|
|
+ ) -> str:
|
||
|
|
if isinstance(envelope.content, str):
|
||
|
|
content = envelope.original_content
|
||
|
|
else:
|
||
|
|
@@ -107,15 +130,17 @@ class Proxy:
|
||
|
|
if NLCRE.match(line):
|
||
|
|
ending = line
|
||
|
|
break
|
||
|
|
- peer = session.peer[0].encode('ascii')
|
||
|
|
- lines.insert(_i, b'X-Peer: %s%s' % (peer, ending))
|
||
|
|
+ peer = session.peer[0].encode("ascii")
|
||
|
|
+ lines.insert(_i, b"X-Peer: " + peer + ending)
|
||
|
|
data = EMPTYBYTES.join(lines)
|
||
|
|
refused = self._deliver(envelope.mail_from, envelope.rcpt_tos, data)
|
||
|
|
# TBD: what to do with refused addresses?
|
||
|
|
- log.info('we got some refusals: %s', refused)
|
||
|
|
- return '250 OK'
|
||
|
|
+ log.info("we got some refusals: %s", refused)
|
||
|
|
+ return "250 OK"
|
||
|
|
|
||
|
|
- def _deliver(self, mail_from, rcpt_tos, data):
|
||
|
|
+ def _deliver(
|
||
|
|
+ self, mail_from: AnyStr, rcpt_tos: List[AnyStr], data: AnyStr
|
||
|
|
+ ) -> Dict[str, Tuple[int, bytes]]:
|
||
|
|
refused = {}
|
||
|
|
try:
|
||
|
|
s = smtplib.SMTP()
|
||
|
|
@@ -125,15 +150,15 @@ class Proxy:
|
||
|
|
finally:
|
||
|
|
s.quit()
|
||
|
|
except smtplib.SMTPRecipientsRefused as e:
|
||
|
|
- log.info('got SMTPRecipientsRefused')
|
||
|
|
+ log.info("got SMTPRecipientsRefused")
|
||
|
|
refused = e.recipients
|
||
|
|
except (OSError, smtplib.SMTPException) as e:
|
||
|
|
- log.exception('got %s', e.__class__)
|
||
|
|
+ log.exception("got %s", e.__class__)
|
||
|
|
# All recipients were refused. If the exception had an associated
|
||
|
|
# error code, use it. Otherwise, fake it with a non-triggering
|
||
|
|
# exception code.
|
||
|
|
- errcode = getattr(e, 'smtp_code', -1)
|
||
|
|
- errmsg = getattr(e, 'smtp_error', 'ignore')
|
||
|
|
+ errcode = getattr(e, "smtp_code", -1)
|
||
|
|
+ errmsg = getattr(e, "smtp_error", "ignore")
|
||
|
|
for r in rcpt_tos:
|
||
|
|
refused[r] = (errcode, errmsg)
|
||
|
|
return refused
|
||
|
|
@@ -142,75 +167,88 @@ class Proxy:
|
||
|
|
@public
|
||
|
|
class Sink:
|
||
|
|
@classmethod
|
||
|
|
- def from_cli(cls, parser, *args):
|
||
|
|
+ def from_cli(cls: Type[T], parser: ArgumentParser, *args) -> T:
|
||
|
|
if len(args) > 0:
|
||
|
|
- parser.error('Sink handler does not accept arguments')
|
||
|
|
+ parser.error("Sink handler does not accept arguments")
|
||
|
|
return cls()
|
||
|
|
|
||
|
|
|
||
|
|
@public
|
||
|
|
class Message(metaclass=ABCMeta):
|
||
|
|
- def __init__(self, message_class=None):
|
||
|
|
+ def __init__(self, message_class: Type[Em_Message] = None):
|
||
|
|
self.message_class = message_class
|
||
|
|
|
||
|
|
- async def handle_DATA(self, server, session, envelope):
|
||
|
|
- envelope = self.prepare_message(session, envelope)
|
||
|
|
- self.handle_message(envelope)
|
||
|
|
- return '250 OK'
|
||
|
|
+ async def handle_DATA(
|
||
|
|
+ self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope
|
||
|
|
+ ) -> str:
|
||
|
|
+ message = self.prepare_message(session, envelope)
|
||
|
|
+ self.handle_message(message)
|
||
|
|
+ return "250 OK"
|
||
|
|
|
||
|
|
- def prepare_message(self, session, envelope):
|
||
|
|
+ def prepare_message(
|
||
|
|
+ self, session: SMTPSession, envelope: SMTPEnvelope
|
||
|
|
+ ) -> Em_Message:
|
||
|
|
# If the server was created with decode_data True, then data will be a
|
||
|
|
# str, otherwise it will be bytes.
|
||
|
|
data = envelope.content
|
||
|
|
- if isinstance(data, bytes):
|
||
|
|
+ message: Em_Message
|
||
|
|
+ if isinstance(data, (bytes, bytearray)):
|
||
|
|
message = message_from_bytes(data, self.message_class)
|
||
|
|
- else:
|
||
|
|
- assert isinstance(data, str), (
|
||
|
|
- 'Expected str or bytes, got {}'.format(type(data)))
|
||
|
|
+ elif isinstance(data, str):
|
||
|
|
message = message_from_string(data, self.message_class)
|
||
|
|
- message['X-Peer'] = str(session.peer)
|
||
|
|
- message['X-MailFrom'] = envelope.mail_from
|
||
|
|
- message['X-RcptTo'] = COMMASPACE.join(envelope.rcpt_tos)
|
||
|
|
+ else:
|
||
|
|
+ raise TypeError(f"Expected str or bytes, got {type(data)}")
|
||
|
|
+ assert isinstance(message, Em_Message)
|
||
|
|
+ message["X-Peer"] = str(session.peer)
|
||
|
|
+ message["X-MailFrom"] = envelope.mail_from
|
||
|
|
+ message["X-RcptTo"] = COMMASPACE.join(envelope.rcpt_tos)
|
||
|
|
return message
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
- def handle_message(self, message):
|
||
|
|
+ def handle_message(self, message: Em_Message) -> None:
|
||
|
|
raise NotImplementedError
|
||
|
|
|
||
|
|
|
||
|
|
@public
|
||
|
|
class AsyncMessage(Message, metaclass=ABCMeta):
|
||
|
|
- def __init__(self, message_class=None, *, loop=None):
|
||
|
|
+ def __init__(
|
||
|
|
+ self,
|
||
|
|
+ message_class: Type[Em_Message] = None,
|
||
|
|
+ *,
|
||
|
|
+ loop: asyncio.AbstractEventLoop = None,
|
||
|
|
+ ):
|
||
|
|
super().__init__(message_class)
|
||
|
|
self.loop = loop or asyncio.get_event_loop()
|
||
|
|
|
||
|
|
- async def handle_DATA(self, server, session, envelope):
|
||
|
|
+ async def handle_DATA(
|
||
|
|
+ self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope
|
||
|
|
+ ) -> str:
|
||
|
|
message = self.prepare_message(session, envelope)
|
||
|
|
await self.handle_message(message)
|
||
|
|
- return '250 OK'
|
||
|
|
+ return "250 OK"
|
||
|
|
|
||
|
|
@abstractmethod
|
||
|
|
- async def handle_message(self, message):
|
||
|
|
+ async def handle_message(self, message: Em_Message) -> None:
|
||
|
|
raise NotImplementedError
|
||
|
|
|
||
|
|
|
||
|
|
@public
|
||
|
|
class Mailbox(Message):
|
||
|
|
- def __init__(self, mail_dir, message_class=None):
|
||
|
|
+ def __init__(self, mail_dir: os.PathLike, message_class: Type[Em_Message] = None):
|
||
|
|
self.mailbox = mailbox.Maildir(mail_dir)
|
||
|
|
self.mail_dir = mail_dir
|
||
|
|
super().__init__(message_class)
|
||
|
|
|
||
|
|
- def handle_message(self, message):
|
||
|
|
+ def handle_message(self, message: Em_Message) -> None:
|
||
|
|
self.mailbox.add(message)
|
||
|
|
|
||
|
|
- def reset(self):
|
||
|
|
+ def reset(self) -> None:
|
||
|
|
self.mailbox.clear()
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
- def from_cli(cls, parser, *args):
|
||
|
|
+ def from_cli(cls: Type[T], parser: ArgumentParser, *args) -> T:
|
||
|
|
if len(args) < 1:
|
||
|
|
- parser.error('The directory for the maildir is required')
|
||
|
|
+ parser.error("The directory for the maildir is required")
|
||
|
|
elif len(args) > 1:
|
||
|
|
- parser.error('Too many arguments for Mailbox handler')
|
||
|
|
+ parser.error("Too many arguments for Mailbox handler")
|
||
|
|
return cls(args[0])
|
||
|
|
diff --git a/aiosmtpd/lmtp.py b/aiosmtpd/lmtp.py
|
||
|
|
index 3f13af7..de68808 100644
|
||
|
|
--- a/aiosmtpd/lmtp.py
|
||
|
|
+++ b/aiosmtpd/lmtp.py
|
||
|
|
@@ -11,14 +11,14 @@ class LMTP(SMTP):
|
||
|
|
show_smtp_greeting: bool = False
|
||
|
|
|
||
|
|
@syntax('LHLO hostname')
|
||
|
|
- async def smtp_LHLO(self, arg):
|
||
|
|
+ async def smtp_LHLO(self, arg: str) -> None:
|
||
|
|
"""The LMTP greeting, used instead of HELO/EHLO."""
|
||
|
|
await super().smtp_EHLO(arg)
|
||
|
|
|
||
|
|
- async def smtp_HELO(self, arg):
|
||
|
|
+ async def smtp_HELO(self, arg: str) -> None:
|
||
|
|
"""HELO is not a valid LMTP command."""
|
||
|
|
await self.push('500 Error: command "HELO" not recognized')
|
||
|
|
|
||
|
|
- async def smtp_EHLO(self, arg):
|
||
|
|
+ async def smtp_EHLO(self, arg: str) -> None:
|
||
|
|
"""EHLO is not a valid LMTP command."""
|
||
|
|
await self.push('500 Error: command "EHLO" not recognized')
|
||
|
|
diff --git a/aiosmtpd/main.py b/aiosmtpd/main.py
|
||
|
|
index e978c60..2366ae4 100644
|
||
|
|
--- a/aiosmtpd/main.py
|
||
|
|
+++ b/aiosmtpd/main.py
|
||
|
|
@@ -7,11 +7,12 @@ import os
|
||
|
|
import signal
|
||
|
|
import ssl
|
||
|
|
import sys
|
||
|
|
-from argparse import ArgumentParser
|
||
|
|
+from argparse import ArgumentParser, Namespace
|
||
|
|
from contextlib import suppress
|
||
|
|
from functools import partial
|
||
|
|
from importlib import import_module
|
||
|
|
from pathlib import Path
|
||
|
|
+from typing import Optional, Sequence, Tuple
|
||
|
|
|
||
|
|
from public import public
|
||
|
|
|
||
|
|
@@ -167,7 +168,7 @@ def _parser() -> ArgumentParser:
|
||
|
|
return parser
|
||
|
|
|
||
|
|
|
||
|
|
-def parseargs(args=None):
|
||
|
|
+def parseargs(args: Optional[Sequence[str]] = None) -> Tuple[ArgumentParser, Namespace]:
|
||
|
|
parser = _parser()
|
||
|
|
parsed = parser.parse_args(args)
|
||
|
|
# Find the handler class.
|
||
|
|
@@ -214,7 +215,7 @@ def parseargs(args=None):
|
||
|
|
|
||
|
|
|
||
|
|
@public
|
||
|
|
-def main(args=None):
|
||
|
|
+def main(args: Optional[Sequence[str]] = None) -> None:
|
||
|
|
parser, args = parseargs(args=args)
|
||
|
|
|
||
|
|
if args.setuid: # pragma: on-win32
|
||
|
|
@@ -285,10 +286,8 @@ def main(args=None):
|
||
|
|
loop.add_signal_handler(signal.SIGINT, loop.stop)
|
||
|
|
|
||
|
|
log.debug("Starting asyncio loop")
|
||
|
|
- try:
|
||
|
|
+ with suppress(KeyboardInterrupt):
|
||
|
|
loop.run_forever()
|
||
|
|
- except KeyboardInterrupt:
|
||
|
|
- pass
|
||
|
|
server_loop.close()
|
||
|
|
log.debug("Completed asyncio loop")
|
||
|
|
loop.run_until_complete(server_loop.wait_closed())
|
||
|
|
diff --git a/aiosmtpd/proxy_protocol.py b/aiosmtpd/proxy_protocol.py
|
||
|
|
index 621098c..27d202a 100644
|
||
|
|
--- a/aiosmtpd/proxy_protocol.py
|
||
|
|
+++ b/aiosmtpd/proxy_protocol.py
|
||
|
|
@@ -1,6 +1,7 @@
|
||
|
|
# Copyright 2014-2021 The aiosmtpd Developers
|
||
|
|
# SPDX-License-Identifier: Apache-2.0
|
||
|
|
|
||
|
|
+import contextlib
|
||
|
|
import logging
|
||
|
|
import re
|
||
|
|
import struct
|
||
|
|
@@ -8,7 +9,7 @@ from collections import deque
|
||
|
|
from enum import IntEnum
|
||
|
|
from functools import partial
|
||
|
|
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||
|
|
-from typing import Any, AnyStr, Dict, Optional, Tuple, Union
|
||
|
|
+from typing import Any, AnyStr, ByteString, Dict, Optional, Tuple, Union
|
||
|
|
|
||
|
|
import attr
|
||
|
|
from public import public
|
||
|
|
@@ -73,9 +74,10 @@ V2_PARSE_ADDR_FAMPRO = {
|
||
|
|
"""Family & Proto combinations that need address parsing"""
|
||
|
|
|
||
|
|
|
||
|
|
-__all__ = [
|
||
|
|
+__all__ = ["struct", "partial", "IPv4Address", "IPv6Address"]
|
||
|
|
+__all__.extend(
|
||
|
|
k for k in globals().keys() if k.startswith("V1_") or k.startswith("V2_")
|
||
|
|
-] + ["struct", "partial", "IPv4Address", "IPv6Address"]
|
||
|
|
+)
|
||
|
|
|
||
|
|
|
||
|
|
_NOT_FOUND = object()
|
||
|
|
@@ -144,10 +146,10 @@ class ProxyTLV(dict):
|
||
|
|
super().__init__(*args, **kwargs)
|
||
|
|
self.tlv_loc = _tlv_loc
|
||
|
|
|
||
|
|
- def __getattr__(self, item):
|
||
|
|
+ def __getattr__(self, item: str) -> Any:
|
||
|
|
return self.get(item)
|
||
|
|
|
||
|
|
- def __eq__(self, other):
|
||
|
|
+ def __eq__(self, other: Dict[str, Any]) -> bool:
|
||
|
|
return super().__eq__(other)
|
||
|
|
|
||
|
|
def same_attribs(self, _raises: bool = False, **kwargs) -> bool:
|
||
|
|
@@ -175,7 +177,7 @@ class ProxyTLV(dict):
|
||
|
|
@classmethod
|
||
|
|
def parse(
|
||
|
|
cls,
|
||
|
|
- data: Union[bytes, bytearray],
|
||
|
|
+ data: ByteString,
|
||
|
|
partial_ok: bool = True,
|
||
|
|
strict: bool = False,
|
||
|
|
) -> Tuple[Dict[str, Any], Dict[str, int]]:
|
||
|
|
@@ -189,7 +191,7 @@ class ProxyTLV(dict):
|
||
|
|
rslt: Dict[str, Any] = {}
|
||
|
|
tlv_loc: Dict[str, int] = {}
|
||
|
|
|
||
|
|
- def _pars(chunk: Union[bytes, bytearray], *, offset: int):
|
||
|
|
+ def _pars(chunk: ByteString, *, offset: int) -> None:
|
||
|
|
i = 0
|
||
|
|
while i < len(chunk):
|
||
|
|
typ = chunk[i]
|
||
|
|
@@ -228,7 +230,7 @@ class ProxyTLV(dict):
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_raw(
|
||
|
|
- cls, raw: Union[bytes, bytearray], strict: bool = False
|
||
|
|
+ cls, raw: ByteString, strict: bool = False
|
||
|
|
) -> Optional["ProxyTLV"]:
|
||
|
|
"""
|
||
|
|
Parses raw bytes for TLV Vectors, decode them and giving them human-readable
|
||
|
|
@@ -275,7 +277,7 @@ class ProxyData:
|
||
|
|
dst_addr: Optional[EndpointAddress] = _anoinit(default=None)
|
||
|
|
src_port: Optional[int] = _anoinit(default=None)
|
||
|
|
dst_port: Optional[int] = _anoinit(default=None)
|
||
|
|
- rest: Union[bytes, bytearray] = _anoinit(default=b"")
|
||
|
|
+ rest: ByteString = _anoinit(default=b"")
|
||
|
|
"""
|
||
|
|
Rest of PROXY Protocol data following UNKNOWN (v1) or UNSPEC (v2), or containing
|
||
|
|
undecoded TLV (v2). If the latter, you can use the ProxyTLV class to parse the
|
||
|
|
@@ -302,12 +304,10 @@ class ProxyData:
|
||
|
|
return not (self.error or self.version is None or self.protocol is None)
|
||
|
|
|
||
|
|
@property
|
||
|
|
- def tlv(self):
|
||
|
|
+ def tlv(self) -> Optional[ProxyTLV]:
|
||
|
|
if self._tlv is None:
|
||
|
|
- try:
|
||
|
|
+ with contextlib.suppress(MalformedTLV):
|
||
|
|
self._tlv = ProxyTLV.from_raw(self.rest)
|
||
|
|
- except MalformedTLV:
|
||
|
|
- pass
|
||
|
|
return self._tlv
|
||
|
|
|
||
|
|
def with_error(self, error_msg: str, log_prefix: bool = True) -> "ProxyData":
|
||
|
|
@@ -340,7 +340,7 @@ class ProxyData:
|
||
|
|
return False
|
||
|
|
return True
|
||
|
|
|
||
|
|
- def __bool__(self):
|
||
|
|
+ def __bool__(self) -> bool:
|
||
|
|
return self.valid
|
||
|
|
|
||
|
|
|
||
|
|
@@ -353,7 +353,7 @@ RE_PORT_NOLEADZERO = re.compile(r"^[1-9]\d{0,4}|0$")
|
||
|
|
# Reference: https://github.com/haproxy/haproxy/blob/v2.3.0/doc/proxy-protocol.txt
|
||
|
|
|
||
|
|
|
||
|
|
-async def _get_v1(reader: AsyncReader, initial=b"") -> ProxyData:
|
||
|
|
+async def _get_v1(reader: AsyncReader, initial: ByteString = b"") -> ProxyData:
|
||
|
|
proxy_data = ProxyData(version=1)
|
||
|
|
proxy_data.whole_raw = bytearray(initial)
|
||
|
|
|
||
|
|
@@ -437,7 +437,7 @@ async def _get_v1(reader: AsyncReader, initial=b"") -> ProxyData:
|
||
|
|
return proxy_data
|
||
|
|
|
||
|
|
|
||
|
|
-async def _get_v2(reader: AsyncReader, initial=b"") -> ProxyData:
|
||
|
|
+async def _get_v2(reader: AsyncReader, initial: ByteString = b"") -> ProxyData:
|
||
|
|
proxy_data = ProxyData(version=2)
|
||
|
|
whole_raw = bytearray()
|
||
|
|
|
||
|
|
diff --git a/aiosmtpd/qa/test_0packaging.py b/aiosmtpd/qa/test_0packaging.py
|
||
|
|
index 2240762..9dbb115 100644
|
||
|
|
--- a/aiosmtpd/qa/test_0packaging.py
|
||
|
|
+++ b/aiosmtpd/qa/test_0packaging.py
|
||
|
|
@@ -2,8 +2,10 @@
|
||
|
|
# SPDX-License-Identifier: Apache-2.0
|
||
|
|
|
||
|
|
"""Test meta / packaging"""
|
||
|
|
+
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
+from datetime import datetime
|
||
|
|
from itertools import tee
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
@@ -15,6 +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>[^)]+)\)")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
@@ -23,14 +26,16 @@ def aiosmtpd_version() -> version.Version:
|
||
|
|
|
||
|
|
|
||
|
|
class TestVersion:
|
||
|
|
- def test_pep440(self, aiosmtpd_version):
|
||
|
|
+ def test_pep440(self, aiosmtpd_version: version.Version):
|
||
|
|
"""Ensure version number compliance to PEP-440"""
|
||
|
|
assert isinstance(
|
||
|
|
aiosmtpd_version, version.Version
|
||
|
|
), "Version number must comply with PEP-440"
|
||
|
|
|
||
|
|
# noinspection PyUnboundLocalVariable
|
||
|
|
- def test_ge_master(self, aiosmtpd_version, capsys):
|
||
|
|
+ def test_ge_master(
|
||
|
|
+ self, aiosmtpd_version: version.Version, capsys: pytest.CaptureFixture
|
||
|
|
+ ):
|
||
|
|
"""Ensure version is monotonically increasing"""
|
||
|
|
reference = "master:aiosmtpd/__init__.py"
|
||
|
|
cmd = f"git show {reference}".split()
|
||
|
|
@@ -50,10 +55,11 @@ class TestVersion:
|
||
|
|
assert aiosmtpd_version >= master_ver, "Version number cannot be < master's"
|
||
|
|
|
||
|
|
|
||
|
|
-class TestDocs:
|
||
|
|
- def test_NEWS_version(self, aiosmtpd_version):
|
||
|
|
- news_rst = next(Path("..").rglob("*/NEWS.rst"))
|
||
|
|
- with open(news_rst, "rt") as fin:
|
||
|
|
+class TestNews:
|
||
|
|
+ news_rst = list(Path("..").rglob("*/NEWS.rst"))[0]
|
||
|
|
+
|
||
|
|
+ def test_NEWS_version(self, aiosmtpd_version: version.Version):
|
||
|
|
+ with self.news_rst.open("rt") as fin:
|
||
|
|
# pairwise() from https://docs.python.org/3/library/itertools.html
|
||
|
|
a, b = tee(fin)
|
||
|
|
next(b, None)
|
||
|
|
@@ -73,3 +79,23 @@ class TestDocs:
|
||
|
|
f"NEWS.rst is not updated: "
|
||
|
|
f"{newsver.base_version} < {aiosmtpd_version.base_version}"
|
||
|
|
)
|
||
|
|
+
|
||
|
|
+ def test_release_date(self, aiosmtpd_version: version.Version):
|
||
|
|
+ if aiosmtpd_version.pre is not None:
|
||
|
|
+ pytest.skip("Not a release version")
|
||
|
|
+ with self.news_rst.open("rt") as fin:
|
||
|
|
+ for ln in fin:
|
||
|
|
+ ln = ln.strip()
|
||
|
|
+ m = RE_VERHEADING.match(ln)
|
||
|
|
+ if not m:
|
||
|
|
+ continue
|
||
|
|
+ ver = version.Version(m.group("ver"))
|
||
|
|
+ if ver != aiosmtpd_version:
|
||
|
|
+ continue
|
||
|
|
+ try:
|
||
|
|
+ datetime.strptime(m.group("date"), "%Y-%m-%d")
|
||
|
|
+ except ValueError:
|
||
|
|
+ pytest.fail("Release version not dated correctly")
|
||
|
|
+ break
|
||
|
|
+ else:
|
||
|
|
+ pytest.fail("Release version has no NEWS fragment")
|
||
|
|
diff --git a/aiosmtpd/qa/test_1testsuite.py b/aiosmtpd/qa/test_1testsuite.py
|
||
|
|
index e61a71d..db20c61 100644
|
||
|
|
--- a/aiosmtpd/qa/test_1testsuite.py
|
||
|
|
+++ b/aiosmtpd/qa/test_1testsuite.py
|
||
|
|
@@ -19,7 +19,7 @@ RE_ESC = re.compile(rb"(?P<digit1>\d)\.\d+\.\d+\s")
|
||
|
|
|
||
|
|
# noinspection PyUnresolvedReferences
|
||
|
|
@pytest.fixture(scope="module", autouse=True)
|
||
|
|
-def exit_on_fail(request):
|
||
|
|
+def exit_on_fail(request: pytest.FixtureRequest):
|
||
|
|
# Behavior of this will be undefined if tests are running in parallel.
|
||
|
|
# But since parallel running is not practically possible (the ports will conflict),
|
||
|
|
# then I don't think that will be a problem.
|
||
|
|
@@ -65,7 +65,9 @@ class TestStatusCodes:
|
||
|
|
f"{key}: First digit of Enhanced Status Code different from "
|
||
|
|
f"first digit of Standard Status Code"
|
||
|
|
)
|
||
|
|
- total_correct += 1
|
||
|
|
+ # Can't use enumerate(); total_correct does not increase in lockstep with
|
||
|
|
+ # the loop (there are several "continue"s above)
|
||
|
|
+ total_correct += 1 # noqa: SIM113
|
||
|
|
assert total_correct > 0
|
||
|
|
|
||
|
|
def test_commands(self):
|
||
|
|
diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
|
||
|
|
index 04a3497..b985b64 100644
|
||
|
|
--- a/aiosmtpd/smtp.py
|
||
|
|
+++ b/aiosmtpd/smtp.py
|
||
|
|
@@ -24,7 +24,9 @@ from typing import (
|
||
|
|
List,
|
||
|
|
NamedTuple,
|
||
|
|
Optional,
|
||
|
|
+ Sequence,
|
||
|
|
Tuple,
|
||
|
|
+ TypeVar,
|
||
|
|
Union,
|
||
|
|
)
|
||
|
|
from warnings import warn
|
||
|
|
@@ -39,7 +41,7 @@ from aiosmtpd.proxy_protocol import ProxyData, get_proxy
|
||
|
|
# region #### Custom Data Types #######################################################
|
||
|
|
|
||
|
|
class _Missing:
|
||
|
|
- def __repr__(self):
|
||
|
|
+ def __repr__(self) -> str:
|
||
|
|
return "MISSING"
|
||
|
|
|
||
|
|
|
||
|
|
@@ -59,6 +61,9 @@ AuthenticatorType = Callable[["SMTP", "Session", "Envelope", str, Any], "AuthRes
|
||
|
|
AuthMechanismType = Callable[["SMTP", List[str]], Awaitable[Any]]
|
||
|
|
_TriStateType = Union[None, _Missing, bytes]
|
||
|
|
|
||
|
|
+RT = TypeVar("RT") # "ReturnType"
|
||
|
|
+DecoratorType = Callable[[Callable[..., RT]], Callable[..., RT]]
|
||
|
|
+
|
||
|
|
|
||
|
|
# endregion
|
||
|
|
|
||
|
|
@@ -149,7 +154,7 @@ class LoginPassword(NamedTuple):
|
||
|
|
|
||
|
|
@public
|
||
|
|
class Session:
|
||
|
|
- def __init__(self, loop):
|
||
|
|
+ def __init__(self, loop: asyncio.AbstractEventLoop):
|
||
|
|
self.peer = None
|
||
|
|
self.ssl = None
|
||
|
|
self.host_name = None
|
||
|
|
@@ -172,7 +177,7 @@ class Session:
|
||
|
|
self.authenticated = None
|
||
|
|
|
||
|
|
@property
|
||
|
|
- def login_data(self):
|
||
|
|
+ def login_data(self) -> Any:
|
||
|
|
"""Legacy login_data, usually containing the username"""
|
||
|
|
log.warning(
|
||
|
|
"Session.login_data is deprecated and will be removed in version 2.0"
|
||
|
|
@@ -180,7 +185,7 @@ class Session:
|
||
|
|
return self._login_data
|
||
|
|
|
||
|
|
@login_data.setter
|
||
|
|
- def login_data(self, value):
|
||
|
|
+ def login_data(self, value: Any) -> None:
|
||
|
|
log.warning(
|
||
|
|
"Session.login_data is deprecated and will be removed in version 2.0"
|
||
|
|
)
|
||
|
|
@@ -189,7 +194,7 @@ class Session:
|
||
|
|
|
||
|
|
@public
|
||
|
|
class Envelope:
|
||
|
|
- def __init__(self):
|
||
|
|
+ def __init__(self) -> None:
|
||
|
|
self.mail_from = None
|
||
|
|
self.mail_options = []
|
||
|
|
self.smtp_utf8 = False
|
||
|
|
@@ -202,12 +207,14 @@ class Envelope:
|
||
|
|
# This is here to enable debugging output when the -E option is given to the
|
||
|
|
# 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():
|
||
|
|
+def make_loop() -> asyncio.AbstractEventLoop:
|
||
|
|
return asyncio.get_event_loop()
|
||
|
|
|
||
|
|
|
||
|
|
@public
|
||
|
|
-def syntax(text, extended=None, when: Optional[str] = None):
|
||
|
|
+def syntax(
|
||
|
|
+ text: str, extended: str = None, when: Optional[str] = None
|
||
|
|
+) -> DecoratorType:
|
||
|
|
"""
|
||
|
|
A @decorator that provides helptext for (E)SMTP HELP.
|
||
|
|
Applies for smtp_* methods only!
|
||
|
|
@@ -217,7 +224,7 @@ def syntax(text, extended=None, when: Optional[str] = None):
|
||
|
|
:param when: The name of the attribute of SMTP class to check; if the value
|
||
|
|
of the attribute is false-y then HELP will not be available for the command
|
||
|
|
"""
|
||
|
|
- def decorator(f):
|
||
|
|
+ def decorator(f: Callable[..., RT]) -> Callable[..., RT]:
|
||
|
|
f.__smtp_syntax__ = text
|
||
|
|
f.__smtp_syntax_extended__ = extended
|
||
|
|
f.__smtp_syntax_when__ = when
|
||
|
|
@@ -226,7 +233,7 @@ def syntax(text, extended=None, when: Optional[str] = None):
|
||
|
|
|
||
|
|
|
||
|
|
@public
|
||
|
|
-def auth_mechanism(actual_name: str):
|
||
|
|
+def auth_mechanism(actual_name: str) -> DecoratorType:
|
||
|
|
"""
|
||
|
|
A @decorator to explicitly specifies the name of the AUTH mechanism implemented by
|
||
|
|
the function/method this decorates
|
||
|
|
@@ -234,9 +241,10 @@ def auth_mechanism(actual_name: str):
|
||
|
|
:param actual_name: Name of AUTH mechanism. Must consists of [A-Z0-9_-] only.
|
||
|
|
Will be converted to uppercase
|
||
|
|
"""
|
||
|
|
- def decorator(f):
|
||
|
|
+ def decorator(f: Callable[..., RT]) -> Callable[..., RT]:
|
||
|
|
f.__auth_mechanism_name__ = actual_name
|
||
|
|
return f
|
||
|
|
+
|
||
|
|
actual_name = actual_name.upper()
|
||
|
|
if not VALID_AUTHMECH.match(actual_name):
|
||
|
|
raise ValueError(f"Invalid AUTH mechanism name: {actual_name}")
|
||
|
|
@@ -249,7 +257,7 @@ def login_always_fail(
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
-def is_int(o):
|
||
|
|
+def is_int(o: Any) -> bool:
|
||
|
|
return isinstance(o, int)
|
||
|
|
|
||
|
|
|
||
|
|
@@ -267,7 +275,7 @@ def sanitize(text: bytes) -> bytes:
|
||
|
|
|
||
|
|
|
||
|
|
@public
|
||
|
|
-def sanitized_log(func: Callable, msg: AnyStr, *args, **kwargs):
|
||
|
|
+def sanitized_log(func: Callable[..., None], msg: AnyStr, *args, **kwargs) -> None:
|
||
|
|
"""
|
||
|
|
Sanitize args before passing to a logging function.
|
||
|
|
"""
|
||
|
|
@@ -305,24 +313,24 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
- handler,
|
||
|
|
+ handler: Any,
|
||
|
|
*,
|
||
|
|
- data_size_limit=DATA_SIZE_DEFAULT,
|
||
|
|
- enable_SMTPUTF8=False,
|
||
|
|
- decode_data=False,
|
||
|
|
- hostname=None,
|
||
|
|
- ident=None,
|
||
|
|
+ data_size_limit: int = DATA_SIZE_DEFAULT,
|
||
|
|
+ enable_SMTPUTF8: bool = False,
|
||
|
|
+ decode_data: bool = False,
|
||
|
|
+ hostname: str = None,
|
||
|
|
+ ident: str = None,
|
||
|
|
tls_context: Optional[ssl.SSLContext] = None,
|
||
|
|
- require_starttls=False,
|
||
|
|
- timeout=300,
|
||
|
|
- auth_required=False,
|
||
|
|
- auth_require_tls=True,
|
||
|
|
+ require_starttls: bool = False,
|
||
|
|
+ timeout: float = 300,
|
||
|
|
+ auth_required: bool = False,
|
||
|
|
+ auth_require_tls: bool = True,
|
||
|
|
auth_exclude_mechanism: Optional[Iterable[str]] = None,
|
||
|
|
auth_callback: AuthCallbackType = None,
|
||
|
|
command_call_limit: Union[int, Dict[str, int], None] = None,
|
||
|
|
authenticator: AuthenticatorType = None,
|
||
|
|
proxy_protocol_timeout: Optional[Union[int, float]] = None,
|
||
|
|
- loop=None
|
||
|
|
+ loop: asyncio.AbstractEventLoop = None
|
||
|
|
):
|
||
|
|
self.__ident__ = ident or __ident__
|
||
|
|
self.loop = loop if loop else make_loop()
|
||
|
|
@@ -343,7 +351,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
self.tls_context = tls_context
|
||
|
|
if tls_context:
|
||
|
|
if (tls_context.verify_mode
|
||
|
|
- not in {ssl.CERT_NONE, ssl.CERT_OPTIONAL}):
|
||
|
|
+ not in {ssl.CERT_NONE, ssl.CERT_OPTIONAL}): # noqa: DUO122
|
||
|
|
log.warning("tls_context.verify_mode not in {CERT_NONE, "
|
||
|
|
"CERT_OPTIONAL}; this might cause client "
|
||
|
|
"connection problems")
|
||
|
|
@@ -452,13 +460,13 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
else:
|
||
|
|
raise TypeError("command_call_limit must be int or Dict[str, int]")
|
||
|
|
|
||
|
|
- def _create_session(self):
|
||
|
|
+ def _create_session(self) -> Session:
|
||
|
|
return Session(self.loop)
|
||
|
|
|
||
|
|
- def _create_envelope(self):
|
||
|
|
+ def _create_envelope(self) -> Envelope:
|
||
|
|
return Envelope()
|
||
|
|
|
||
|
|
- async def _call_handler_hook(self, command, *args):
|
||
|
|
+ async def _call_handler_hook(self, command: str, *args) -> Any:
|
||
|
|
hook = self._handle_hooks.get(command)
|
||
|
|
if hook is None:
|
||
|
|
return MISSING
|
||
|
|
@@ -466,7 +474,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
return status
|
||
|
|
|
||
|
|
@property
|
||
|
|
- def max_command_size_limit(self):
|
||
|
|
+ def max_command_size_limit(self) -> int:
|
||
|
|
try:
|
||
|
|
return max(self.command_size_limits.values())
|
||
|
|
except ValueError:
|
||
|
|
@@ -484,7 +492,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
if closed.done() and not closed.cancelled():
|
||
|
|
closed.exception()
|
||
|
|
|
||
|
|
- def connection_made(self, transport):
|
||
|
|
+ def connection_made(self, transport: asyncio.transports.Transport) -> None:
|
||
|
|
# Reset state due to rfc3207 part 4.2.
|
||
|
|
self._set_rset_state()
|
||
|
|
self.session = self._create_session()
|
||
|
|
@@ -513,7 +521,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
self._handler_coroutine = self.loop.create_task(
|
||
|
|
self._handle_client())
|
||
|
|
|
||
|
|
- def connection_lost(self, error):
|
||
|
|
+ def connection_lost(self, error: Optional[Exception]) -> None:
|
||
|
|
log.info('%r connection lost', self.session.peer)
|
||
|
|
self._timeout_handle.cancel()
|
||
|
|
# If STARTTLS was issued, then our transport is the SSL protocol
|
||
|
|
@@ -527,7 +535,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
self._handler_coroutine.cancel()
|
||
|
|
self.transport = None
|
||
|
|
|
||
|
|
- def eof_received(self):
|
||
|
|
+ def eof_received(self) -> bool:
|
||
|
|
log.info('%r EOF received', self.session.peer)
|
||
|
|
self._handler_coroutine.cancel()
|
||
|
|
if self.session.ssl is not None:
|
||
|
|
@@ -537,7 +545,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
return False
|
||
|
|
return super().eof_received()
|
||
|
|
|
||
|
|
- def _reset_timeout(self, duration=None):
|
||
|
|
+ def _reset_timeout(self, duration: float = None) -> None:
|
||
|
|
if self._timeout_handle is not None:
|
||
|
|
self._timeout_handle.cancel()
|
||
|
|
self._timeout_handle = self.loop.call_later(
|
||
|
|
@@ -552,7 +560,9 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
# up state.
|
||
|
|
self.transport.close()
|
||
|
|
|
||
|
|
- def _client_connected_cb(self, reader, writer):
|
||
|
|
+ def _client_connected_cb(
|
||
|
|
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||
|
|
+ ):
|
||
|
|
# This is redundant since we subclass StreamReaderProtocol, but I like
|
||
|
|
# the shorter names.
|
||
|
|
self._reader = reader
|
||
|
|
@@ -577,7 +587,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
log.debug("%r << %r", self.session.peer, response)
|
||
|
|
await self._writer.drain()
|
||
|
|
|
||
|
|
- async def handle_exception(self, error):
|
||
|
|
+ async def handle_exception(self, error: Exception) -> str:
|
||
|
|
if hasattr(self.event_handler, 'handle_exception'):
|
||
|
|
status = await self.event_handler.handle_exception(error)
|
||
|
|
return status
|
||
|
|
@@ -678,9 +688,11 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
await self.push('500 Error: strict ASCII mode')
|
||
|
|
# Should we await self.handle_exception()?
|
||
|
|
continue
|
||
|
|
- max_sz = (self.command_size_limits[command]
|
||
|
|
- if self.session.extended_smtp
|
||
|
|
- else self.command_size_limit)
|
||
|
|
+ max_sz = (
|
||
|
|
+ self.command_size_limits[command]
|
||
|
|
+ if self.session.extended_smtp
|
||
|
|
+ else self.command_size_limit
|
||
|
|
+ )
|
||
|
|
if len(line) > max_sz:
|
||
|
|
await self.push('500 Command line too long')
|
||
|
|
continue
|
||
|
|
@@ -720,7 +732,8 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
self.transport.close()
|
||
|
|
continue
|
||
|
|
await self.push(
|
||
|
|
- '500 Error: command "%s" not recognized' % command)
|
||
|
|
+ f'500 Error: command "{command}" not recognized'
|
||
|
|
+ )
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Received a valid command, reset the timer.
|
||
|
|
@@ -785,7 +798,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
|
||
|
|
# SMTP and ESMTP commands
|
||
|
|
@syntax('HELO hostname')
|
||
|
|
- async def smtp_HELO(self, hostname):
|
||
|
|
+ async def smtp_HELO(self, hostname: str):
|
||
|
|
if not hostname:
|
||
|
|
await self.push('501 Syntax: HELO hostname')
|
||
|
|
return
|
||
|
|
@@ -798,7 +811,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
await self.push(status)
|
||
|
|
|
||
|
|
@syntax('EHLO hostname')
|
||
|
|
- async def smtp_EHLO(self, hostname):
|
||
|
|
+ async def smtp_EHLO(self, hostname: str):
|
||
|
|
if not hostname:
|
||
|
|
await self.push('501 Syntax: EHLO hostname')
|
||
|
|
return
|
||
|
|
@@ -806,9 +819,9 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
response = []
|
||
|
|
self._set_rset_state()
|
||
|
|
self.session.extended_smtp = True
|
||
|
|
- response.append('250-%s' % self.hostname)
|
||
|
|
+ response.append('250-' + self.hostname)
|
||
|
|
if self.data_size_limit:
|
||
|
|
- response.append('250-SIZE %s' % self.data_size_limit)
|
||
|
|
+ response.append(f'250-SIZE {self.data_size_limit}')
|
||
|
|
self.command_size_limits['MAIL'] += 26
|
||
|
|
if not self._decode_data:
|
||
|
|
response.append('250-8BITMIME')
|
||
|
|
@@ -848,12 +861,12 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
await self.push(r)
|
||
|
|
|
||
|
|
@syntax('NOOP [ignored]')
|
||
|
|
- async def smtp_NOOP(self, arg):
|
||
|
|
+ async def smtp_NOOP(self, arg: str):
|
||
|
|
status = await self._call_handler_hook('NOOP', arg)
|
||
|
|
await self.push('250 OK' if status is MISSING else status)
|
||
|
|
|
||
|
|
@syntax('QUIT')
|
||
|
|
- async def smtp_QUIT(self, arg):
|
||
|
|
+ async def smtp_QUIT(self, arg: str):
|
||
|
|
if arg:
|
||
|
|
await self.push('501 Syntax: QUIT')
|
||
|
|
else:
|
||
|
|
@@ -863,7 +876,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
self.transport.close()
|
||
|
|
|
||
|
|
@syntax('STARTTLS', when='tls_context')
|
||
|
|
- async def smtp_STARTTLS(self, arg):
|
||
|
|
+ async def smtp_STARTTLS(self, arg: str):
|
||
|
|
if arg:
|
||
|
|
await self.push('501 Syntax: STARTTLS')
|
||
|
|
return
|
||
|
|
@@ -1032,7 +1045,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
encode_to_b64=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
- def _authenticate(self, mechanism, auth_data) -> AuthResult:
|
||
|
|
+ def _authenticate(self, mechanism: str, auth_data: Any) -> AuthResult:
|
||
|
|
if self._authenticator is not None:
|
||
|
|
# self.envelope is likely still empty, but we'll pass it anyways to
|
||
|
|
# make the invocation similar to the one in _call_handler_hook
|
||
|
|
@@ -1093,7 +1106,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
assert password is not None
|
||
|
|
return self._authenticate("PLAIN", LoginPassword(login, password))
|
||
|
|
|
||
|
|
- async def auth_LOGIN(self, _, args: List[str]):
|
||
|
|
+ async def auth_LOGIN(self, _, args: List[str]) -> AuthResult:
|
||
|
|
login: _TriStateType
|
||
|
|
if len(args) == 1:
|
||
|
|
# Client sent only "AUTH LOGIN"
|
||
|
|
@@ -1117,13 +1130,13 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
|
||
|
|
return self._authenticate("LOGIN", LoginPassword(login, password))
|
||
|
|
|
||
|
|
- def _strip_command_keyword(self, keyword, arg):
|
||
|
|
+ def _strip_command_keyword(self, keyword: str, arg: str) -> Optional[str]:
|
||
|
|
keylen = len(keyword)
|
||
|
|
if arg[:keylen].upper() == keyword:
|
||
|
|
return arg[keylen:].strip()
|
||
|
|
return None
|
||
|
|
|
||
|
|
- def _getaddr(self, arg) -> Tuple[Optional[str], Optional[str]]:
|
||
|
|
+ def _getaddr(self, arg: str) -> Tuple[Optional[str], Optional[str]]:
|
||
|
|
"""
|
||
|
|
Try to parse address given in SMTP command.
|
||
|
|
|
||
|
|
@@ -1145,7 +1158,9 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
return None, None
|
||
|
|
return address, rest
|
||
|
|
|
||
|
|
- def _getparams(self, params):
|
||
|
|
+ def _getparams(
|
||
|
|
+ self, params: Sequence[str]
|
||
|
|
+ ) -> Optional[Dict[str, Union[str, bool]]]:
|
||
|
|
# Return params as dictionary. Return None if not all parameters
|
||
|
|
# appear to be syntactically valid according to RFC 1869.
|
||
|
|
result = {}
|
||
|
|
@@ -1156,7 +1171,8 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
result[param] = value if eq else True
|
||
|
|
return result
|
||
|
|
|
||
|
|
- def _syntax_available(self, method):
|
||
|
|
+ # noinspection PyUnresolvedReferences
|
||
|
|
+ def _syntax_available(self, method: Callable) -> bool:
|
||
|
|
if not hasattr(method, '__smtp_syntax__'):
|
||
|
|
return False
|
||
|
|
if method.__smtp_syntax_when__:
|
||
|
|
@@ -1193,7 +1209,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
if arg:
|
||
|
|
address, params = self._getaddr(arg)
|
||
|
|
if address is None:
|
||
|
|
- await self.push('502 Could not VRFY %s' % arg)
|
||
|
|
+ await self.push('502 Could not VRFY ' + arg)
|
||
|
|
else:
|
||
|
|
status = await self._call_handler_hook('VRFY', address)
|
||
|
|
await self.push(
|
||
|
|
@@ -1314,7 +1330,7 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
await self.push(status)
|
||
|
|
|
||
|
|
@syntax('RSET')
|
||
|
|
- async def smtp_RSET(self, arg):
|
||
|
|
+ async def smtp_RSET(self, arg: str):
|
||
|
|
if arg:
|
||
|
|
await self.push('501 Syntax: RSET')
|
||
|
|
return
|
||
|
|
@@ -1458,5 +1474,5 @@ class SMTP(asyncio.StreamReaderProtocol):
|
||
|
|
await self.push('250 OK' if status is MISSING else status)
|
||
|
|
|
||
|
|
# Commands that have not been implemented.
|
||
|
|
- async def smtp_EXPN(self, arg):
|
||
|
|
+ async def smtp_EXPN(self, arg: str):
|
||
|
|
await self.push('502 EXPN not implemented')
|
||
|
|
diff --git a/aiosmtpd/testing/helpers.py b/aiosmtpd/testing/helpers.py
|
||
|
|
index 7fa62a2..2328704 100644
|
||
|
|
--- a/aiosmtpd/testing/helpers.py
|
||
|
|
+++ b/aiosmtpd/testing/helpers.py
|
||
|
|
@@ -12,7 +12,7 @@ import time
|
||
|
|
from smtplib import SMTP as SMTP_Client
|
||
|
|
from typing import List
|
||
|
|
|
||
|
|
-from aiosmtpd.smtp import Envelope
|
||
|
|
+from aiosmtpd.smtp import Envelope, Session, SMTP
|
||
|
|
|
||
|
|
ASYNCIO_CATCHUP_DELAY = float(os.environ.get("ASYNCIO_CATCHUP_DELAY", 0.1))
|
||
|
|
"""
|
||
|
|
@@ -52,12 +52,14 @@ class ReceivingHandler:
|
||
|
|
def __init__(self):
|
||
|
|
self.box = []
|
||
|
|
|
||
|
|
- async def handle_DATA(self, server, session, envelope):
|
||
|
|
+ async def handle_DATA(
|
||
|
|
+ self, server: SMTP, session: Session, envelope: Envelope
|
||
|
|
+ ) -> str:
|
||
|
|
self.box.append(envelope)
|
||
|
|
return "250 OK"
|
||
|
|
|
||
|
|
|
||
|
|
-def catchup_delay(delay=ASYNCIO_CATCHUP_DELAY):
|
||
|
|
+def catchup_delay(delay: float = ASYNCIO_CATCHUP_DELAY):
|
||
|
|
"""
|
||
|
|
Sleep for awhile to give asyncio's event loop time to catch up.
|
||
|
|
"""
|
||
|
|
@@ -65,7 +67,7 @@ def catchup_delay(delay=ASYNCIO_CATCHUP_DELAY):
|
||
|
|
|
||
|
|
|
||
|
|
def send_recv(
|
||
|
|
- sock: socket.socket, data: bytes, end: bytes = b"\r\n", timeout=0.1
|
||
|
|
+ sock: socket.socket, data: bytes, end: bytes = b"\r\n", timeout: float = 0.1
|
||
|
|
) -> bytes:
|
||
|
|
sock.send(data + end)
|
||
|
|
slist = [sock]
|
||
|
|
diff --git a/aiosmtpd/tests/conftest.py b/aiosmtpd/tests/conftest.py
|
||
|
|
index d0a6cd3..859d5ef 100644
|
||
|
|
--- a/aiosmtpd/tests/conftest.py
|
||
|
|
+++ b/aiosmtpd/tests/conftest.py
|
||
|
|
@@ -8,10 +8,11 @@ import ssl
|
||
|
|
from contextlib import suppress
|
||
|
|
from functools import wraps
|
||
|
|
from smtplib import SMTP as SMTPClient
|
||
|
|
-from typing import Generator, NamedTuple, Optional, Type
|
||
|
|
+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
|
||
|
|
@@ -50,6 +51,9 @@ class HostPort(NamedTuple):
|
||
|
|
port: int = 8025
|
||
|
|
|
||
|
|
|
||
|
|
+RT = TypeVar("RT") # "ReturnType"
|
||
|
|
+
|
||
|
|
+
|
||
|
|
# endregion
|
||
|
|
|
||
|
|
|
||
|
|
@@ -79,15 +83,13 @@ SERVER_KEY = resource_filename("aiosmtpd.tests.certs", "server.key")
|
||
|
|
|
||
|
|
# autouse=True and scope="session" automatically apply this fixture to ALL test cases
|
||
|
|
@pytest.fixture(autouse=True, scope="session")
|
||
|
|
-def cache_fqdn(session_mocker):
|
||
|
|
+def cache_fqdn(session_mocker: MockFixture):
|
||
|
|
"""
|
||
|
|
This fixture "caches" the socket.getfqdn() call. VERY necessary to prevent
|
||
|
|
situations where quick repeated getfqdn() causes extreme slowdown. Probably due to
|
||
|
|
the DNS server thinking it was an attack or something.
|
||
|
|
"""
|
||
|
|
session_mocker.patch("socket.getfqdn", return_value=Global.FQDN)
|
||
|
|
- #
|
||
|
|
- yield
|
||
|
|
|
||
|
|
|
||
|
|
# endregion
|
||
|
|
@@ -97,7 +99,7 @@ def cache_fqdn(session_mocker):
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def get_controller(request):
|
||
|
|
+def get_controller(request: pytest.FixtureRequest) -> Callable[..., Controller]:
|
||
|
|
"""
|
||
|
|
Provides a function that will return an instance of a controller.
|
||
|
|
|
||
|
|
@@ -122,7 +124,7 @@ def get_controller(request):
|
||
|
|
markerdata = {}
|
||
|
|
|
||
|
|
def getter(
|
||
|
|
- handler,
|
||
|
|
+ handler: Any,
|
||
|
|
class_: Optional[Type[Controller]] = None,
|
||
|
|
**server_kwargs,
|
||
|
|
) -> Controller:
|
||
|
|
@@ -154,7 +156,7 @@ def get_controller(request):
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def get_handler(request):
|
||
|
|
+def get_handler(request: pytest.FixtureRequest) -> Callable:
|
||
|
|
"""
|
||
|
|
Provides a function that will return an instance of
|
||
|
|
a :ref:`handler class <handlers>`.
|
||
|
|
@@ -179,7 +181,7 @@ def get_handler(request):
|
||
|
|
else:
|
||
|
|
markerdata = {}
|
||
|
|
|
||
|
|
- def getter(*args, **kwargs):
|
||
|
|
+ def getter(*args, **kwargs) -> Any:
|
||
|
|
if marker:
|
||
|
|
class_ = markerdata.pop("class_", default_class)
|
||
|
|
# *args overrides args_ in handler_data()
|
||
|
|
@@ -209,18 +211,22 @@ def temp_event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def autostop_loop(temp_event_loop) -> Generator[asyncio.AbstractEventLoop, None, None]:
|
||
|
|
+def autostop_loop(
|
||
|
|
+ temp_event_loop: asyncio.AbstractEventLoop,
|
||
|
|
+) -> asyncio.AbstractEventLoop:
|
||
|
|
# Create a new event loop, and arrange for that loop to end almost
|
||
|
|
# immediately. This will allow the calls to main() in these tests to
|
||
|
|
# also exit almost immediately. Otherwise, the foreground test
|
||
|
|
# process will hang.
|
||
|
|
temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop)
|
||
|
|
#
|
||
|
|
- yield temp_event_loop
|
||
|
|
+ return temp_event_loop
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def plain_controller(get_handler, get_controller) -> Generator[Controller, None, None]:
|
||
|
|
+def plain_controller(
|
||
|
|
+ get_handler: Callable, get_controller: Callable
|
||
|
|
+) -> Generator[Controller, None, None]:
|
||
|
|
"""
|
||
|
|
Returns a Controller that, by default, gets invoked with no optional args.
|
||
|
|
Hence the moniker "plain".
|
||
|
|
@@ -246,7 +252,7 @@ def plain_controller(get_handler, get_controller) -> Generator[Controller, None,
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def nodecode_controller(
|
||
|
|
- get_handler, get_controller
|
||
|
|
+ get_handler: Callable, get_controller: Callable
|
||
|
|
) -> Generator[Controller, None, None]:
|
||
|
|
"""
|
||
|
|
Same as :fixture:`plain_controller`,
|
||
|
|
@@ -268,7 +274,7 @@ def nodecode_controller(
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def decoding_controller(
|
||
|
|
- get_handler, get_controller
|
||
|
|
+ get_handler: Callable, get_controller: Callable
|
||
|
|
) -> Generator[Controller, None, None]:
|
||
|
|
handler = get_handler()
|
||
|
|
controller = get_controller(handler, decode_data=True)
|
||
|
|
@@ -285,7 +291,7 @@ def decoding_controller(
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def client(request) -> Generator[SMTPClient, None, None]:
|
||
|
|
+def client(request: pytest.FixtureRequest) -> Generator[SMTPClient, None, None]:
|
||
|
|
"""
|
||
|
|
Generic SMTP Client,
|
||
|
|
will connect to the ``host:port`` defined in ``Global.SrvAddr``
|
||
|
|
@@ -302,7 +308,7 @@ def client(request) -> Generator[SMTPClient, None, None]:
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def ssl_context_server() -> Generator[ssl.SSLContext, None, None]:
|
||
|
|
+def ssl_context_server() -> ssl.SSLContext:
|
||
|
|
"""
|
||
|
|
Provides a server-side SSL Context
|
||
|
|
"""
|
||
|
|
@@ -310,11 +316,11 @@ def ssl_context_server() -> Generator[ssl.SSLContext, None, None]:
|
||
|
|
context.check_hostname = False
|
||
|
|
context.load_cert_chain(SERVER_CRT, SERVER_KEY)
|
||
|
|
#
|
||
|
|
- yield context
|
||
|
|
+ return context
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def ssl_context_client() -> Generator[ssl.SSLContext, None, None]:
|
||
|
|
+def ssl_context_client() -> ssl.SSLContext:
|
||
|
|
"""
|
||
|
|
Provides a client-side SSL Context
|
||
|
|
"""
|
||
|
|
@@ -322,14 +328,14 @@ def ssl_context_client() -> Generator[ssl.SSLContext, None, None]:
|
||
|
|
context.check_hostname = False
|
||
|
|
context.load_verify_locations(SERVER_CRT)
|
||
|
|
#
|
||
|
|
- yield context
|
||
|
|
+ return context
|
||
|
|
|
||
|
|
|
||
|
|
# Please keep the scope as "module"; setting it as "function" (the default) somehow
|
||
|
|
# causes the 'hidden' exception to be detected when the loop starts over in the next
|
||
|
|
# test case, defeating the silencing.
|
||
|
|
@pytest.fixture(scope="module")
|
||
|
|
-def silence_event_loop_closed():
|
||
|
|
+def silence_event_loop_closed() -> bool:
|
||
|
|
"""
|
||
|
|
Mostly used to suppress "unhandled exception" error due to
|
||
|
|
``_ProactorBasePipeTransport`` raising an exception when doing ``__del__``
|
||
|
|
@@ -341,9 +347,9 @@ def silence_event_loop_closed():
|
||
|
|
return True
|
||
|
|
|
||
|
|
# From: https://github.com/aio-libs/aiohttp/issues/4324#issuecomment-733884349
|
||
|
|
- def silencer(func):
|
||
|
|
+ def silencer(func: Callable[..., RT]) -> Callable[..., RT]:
|
||
|
|
@wraps(func)
|
||
|
|
- def wrapper(self, *args, **kwargs):
|
||
|
|
+ def wrapper(self: Any, *args, **kwargs) -> RT:
|
||
|
|
try:
|
||
|
|
return func(self, *args, **kwargs)
|
||
|
|
except RuntimeError as e:
|
||
|
|
diff --git a/aiosmtpd/tests/test_handlers.py b/aiosmtpd/tests/test_handlers.py
|
||
|
|
index 51e06ce..35bd661 100644
|
||
|
|
--- a/aiosmtpd/tests/test_handlers.py
|
||
|
|
+++ b/aiosmtpd/tests/test_handlers.py
|
||
|
|
@@ -3,6 +3,7 @@
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import sys
|
||
|
|
+from email.message import Message as Em_Message
|
||
|
|
from io import StringIO
|
||
|
|
from mailbox import Maildir
|
||
|
|
from operator import itemgetter
|
||
|
|
@@ -10,14 +11,16 @@ from pathlib import Path
|
||
|
|
from smtplib import SMTPDataError, SMTPRecipientsRefused
|
||
|
|
from textwrap import dedent
|
||
|
|
from types import SimpleNamespace
|
||
|
|
-from typing import AnyStr, Generator, Type, TypeVar, Union
|
||
|
|
+from typing import AnyStr, Callable, Generator, Type, TypeVar, Union
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from aiosmtpd.controller import Controller
|
||
|
|
from aiosmtpd.handlers import AsyncMessage, Debugging, Mailbox, Proxy, Sink
|
||
|
|
+from aiosmtpd.handlers import Message as AbstractMessageHandler
|
||
|
|
from aiosmtpd.smtp import SMTP as Server
|
||
|
|
from aiosmtpd.smtp import Session as ServerSession
|
||
|
|
+from aiosmtpd.smtp import Envelope
|
||
|
|
from aiosmtpd.testing.statuscodes import SMTP_STATUS_CODES as S
|
||
|
|
from aiosmtpd.testing.statuscodes import StatusCode
|
||
|
|
|
||
|
|
@@ -54,7 +57,7 @@ class FakeParser:
|
||
|
|
|
||
|
|
message: AnyStr = None
|
||
|
|
|
||
|
|
- def error(self, message):
|
||
|
|
+ def error(self, message: AnyStr):
|
||
|
|
self.message = message
|
||
|
|
raise SystemExit
|
||
|
|
|
||
|
|
@@ -63,16 +66,23 @@ class DataHandler:
|
||
|
|
content: AnyStr = None
|
||
|
|
original_content: bytes = None
|
||
|
|
|
||
|
|
- async def handle_DATA(self, server, session, envelope):
|
||
|
|
+ async def handle_DATA(
|
||
|
|
+ self, server: Server, session: ServerSession, envelope: Envelope
|
||
|
|
+ ) -> str:
|
||
|
|
self.content = envelope.content
|
||
|
|
self.original_content = envelope.original_content
|
||
|
|
return S.S250_OK.to_str()
|
||
|
|
|
||
|
|
|
||
|
|
+class MessageHandler(AbstractMessageHandler):
|
||
|
|
+ def handle_message(self, message: Em_Message) -> None:
|
||
|
|
+ pass
|
||
|
|
+
|
||
|
|
+
|
||
|
|
class AsyncMessageHandler(AsyncMessage):
|
||
|
|
- handled_message = None
|
||
|
|
+ handled_message: Em_Message = None
|
||
|
|
|
||
|
|
- async def handle_message(self, message):
|
||
|
|
+ async def handle_message(self, message: Em_Message) -> None:
|
||
|
|
self.handled_message = message
|
||
|
|
|
||
|
|
|
||
|
|
@@ -209,14 +219,13 @@ def debugging_controller(get_controller) -> Generator[Controller, None, None]:
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def temp_maildir(tmp_path: Path) -> Generator[Path, None, None]:
|
||
|
|
- maildir_path = tmp_path / "maildir"
|
||
|
|
- yield maildir_path
|
||
|
|
+def temp_maildir(tmp_path: Path) -> Path:
|
||
|
|
+ return tmp_path / "maildir"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mailbox_controller(
|
||
|
|
- temp_maildir, get_controller
|
||
|
|
+ temp_maildir, get_controller
|
||
|
|
) -> Generator[Controller, None, None]:
|
||
|
|
handler = Mailbox(temp_maildir)
|
||
|
|
controller = get_controller(handler)
|
||
|
|
@@ -229,7 +238,7 @@ def mailbox_controller(
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def with_fake_parser():
|
||
|
|
+def with_fake_parser() -> Callable:
|
||
|
|
"""
|
||
|
|
Gets a function that will instantiate a handler_class using the class's
|
||
|
|
from_cli() @classmethod, using FakeParser as the parser.
|
||
|
|
@@ -250,7 +259,7 @@ def with_fake_parser():
|
||
|
|
handler = SimpleNamespace(fparser=parser, exception=type(e))
|
||
|
|
return handler
|
||
|
|
|
||
|
|
- yield handler_initer
|
||
|
|
+ return handler_initer
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
@@ -435,6 +444,43 @@ class TestDebugging:
|
||
|
|
|
||
|
|
|
||
|
|
class TestMessage:
|
||
|
|
+ @pytest.mark.parametrize(
|
||
|
|
+ "content",
|
||
|
|
+ [
|
||
|
|
+ b"",
|
||
|
|
+ bytearray(),
|
||
|
|
+ "",
|
||
|
|
+ ],
|
||
|
|
+ ids=["bytes", "bytearray", "str"]
|
||
|
|
+ )
|
||
|
|
+ def test_prepare_message(self, temp_event_loop, content):
|
||
|
|
+ sess_ = ServerSession(temp_event_loop)
|
||
|
|
+ enve_ = Envelope()
|
||
|
|
+ handler = MessageHandler()
|
||
|
|
+ enve_.content = content
|
||
|
|
+ msg = handler.prepare_message(sess_, enve_)
|
||
|
|
+ assert isinstance(msg, Em_Message)
|
||
|
|
+ assert msg.keys() == ['X-Peer', 'X-MailFrom', 'X-RcptTo']
|
||
|
|
+ assert msg.get_payload() == ""
|
||
|
|
+
|
||
|
|
+ @pytest.mark.parametrize(
|
||
|
|
+ ("content", "expectre"),
|
||
|
|
+ [
|
||
|
|
+ (None, r"Expected str or bytes, got <class 'NoneType'>"),
|
||
|
|
+ ([], r"Expected str or bytes, got <class 'list'>"),
|
||
|
|
+ ({}, r"Expected str or bytes, got <class 'dict'>"),
|
||
|
|
+ ((), r"Expected str or bytes, got <class 'tuple'>"),
|
||
|
|
+ ],
|
||
|
|
+ ids=("None", "List", "Dict", "Tuple")
|
||
|
|
+ )
|
||
|
|
+ def test_prepare_message_err(self, temp_event_loop, content, expectre):
|
||
|
|
+ sess_ = ServerSession(temp_event_loop)
|
||
|
|
+ enve_ = Envelope()
|
||
|
|
+ handler = MessageHandler()
|
||
|
|
+ enve_.content = content
|
||
|
|
+ with pytest.raises(TypeError, match=expectre):
|
||
|
|
+ _ = handler.prepare_message(sess_, enve_)
|
||
|
|
+
|
||
|
|
@handler_data(class_=DataHandler)
|
||
|
|
def test_message(self, plain_controller, client):
|
||
|
|
handler = plain_controller.handler
|
||
|
|
@@ -585,11 +631,8 @@ class TestMailbox:
|
||
|
|
# Check the messages in the mailbox.
|
||
|
|
mailbox = Maildir(temp_maildir)
|
||
|
|
messages = sorted(mailbox, key=itemgetter("message-id"))
|
||
|
|
- assert list(message["message-id"] for message in messages) == [
|
||
|
|
- "<ant>",
|
||
|
|
- "<bee>",
|
||
|
|
- "<cat>",
|
||
|
|
- ]
|
||
|
|
+ expect = ["<ant>", "<bee>", "<cat>"]
|
||
|
|
+ assert [message["message-id"] for message in messages] == expect
|
||
|
|
|
||
|
|
def test_mailbox_reset(self, temp_maildir, mailbox_controller, client):
|
||
|
|
client.sendmail(
|
||
|
|
@@ -766,7 +809,6 @@ class TestProxyMocked:
|
||
|
|
def patch_smtp_oserror(self, mocker):
|
||
|
|
mock = mocker.patch("aiosmtpd.handlers.smtplib.SMTP")
|
||
|
|
mock().sendmail.side_effect = OSError
|
||
|
|
- yield
|
||
|
|
|
||
|
|
def test_oserror(
|
||
|
|
self, caplog, patch_smtp_oserror, proxy_decoding_controller, client
|
||
|
|
@@ -804,13 +846,13 @@ class TestHooks:
|
||
|
|
|
||
|
|
def test_hook_EHLO_deprecated_warning(self):
|
||
|
|
with pytest.warns(
|
||
|
|
- DeprecationWarning,
|
||
|
|
- match=(
|
||
|
|
- # Is a regex; escape regex special chars if necessary
|
||
|
|
- r"Use the 5-argument handle_EHLO\(\) hook instead of the "
|
||
|
|
- r"4-argument handle_EHLO\(\) hook; support for the 4-argument "
|
||
|
|
- r"handle_EHLO\(\) hook will be removed in version 2.0"
|
||
|
|
- )
|
||
|
|
+ DeprecationWarning,
|
||
|
|
+ match=(
|
||
|
|
+ # Is a regex; escape regex special chars if necessary
|
||
|
|
+ r"Use the 5-argument handle_EHLO\(\) hook instead of the "
|
||
|
|
+ r"4-argument handle_EHLO\(\) hook; support for the 4-argument "
|
||
|
|
+ r"handle_EHLO\(\) hook will be removed in version 2.0"
|
||
|
|
+ ),
|
||
|
|
):
|
||
|
|
_ = Server(EHLOHandlerDeprecated())
|
||
|
|
|
||
|
|
diff --git a/aiosmtpd/tests/test_main.py b/aiosmtpd/tests/test_main.py
|
||
|
|
index 36992f3..e6b3868 100644
|
||
|
|
--- a/aiosmtpd/tests/test_main.py
|
||
|
|
+++ b/aiosmtpd/tests/test_main.py
|
||
|
|
@@ -7,11 +7,13 @@ import multiprocessing as MP
|
||
|
|
import os
|
||
|
|
import time
|
||
|
|
from contextlib import contextmanager
|
||
|
|
+from multiprocessing.synchronize import Event as MP_Event
|
||
|
|
from smtplib import SMTP as SMTPClient
|
||
|
|
from smtplib import SMTP_SSL
|
||
|
|
from typing import Generator
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
+from pytest_mock import MockFixture
|
||
|
|
|
||
|
|
from aiosmtpd import __version__
|
||
|
|
from aiosmtpd.handlers import Debugging
|
||
|
|
@@ -33,7 +35,7 @@ MAIL_LOG = logging.getLogger("mail.log")
|
||
|
|
|
||
|
|
|
||
|
|
class FromCliHandler:
|
||
|
|
- def __init__(self, called):
|
||
|
|
+ def __init__(self, called: bool):
|
||
|
|
self.called = called
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
@@ -63,14 +65,12 @@ def nobody_uid() -> Generator[int, None, None]:
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def setuid(mocker):
|
||
|
|
+def setuid(mocker: MockFixture):
|
||
|
|
if not HAS_SETUID:
|
||
|
|
pytest.skip("setuid is unavailable")
|
||
|
|
mocker.patch("aiosmtpd.main.pwd", None)
|
||
|
|
mocker.patch("os.setuid", side_effect=PermissionError)
|
||
|
|
mocker.patch("aiosmtpd.main.partial", side_effect=RuntimeError)
|
||
|
|
- #
|
||
|
|
- yield
|
||
|
|
|
||
|
|
|
||
|
|
# endregion
|
||
|
|
@@ -78,7 +78,7 @@ def setuid(mocker):
|
||
|
|
# region ##### Helper Funcs ###########################################################
|
||
|
|
|
||
|
|
|
||
|
|
-def watch_for_tls(ready_flag, retq: MP.Queue):
|
||
|
|
+def watch_for_tls(ready_flag: MP_Event, retq: MP.Queue):
|
||
|
|
has_tls = False
|
||
|
|
req_tls = False
|
||
|
|
ready_flag.set()
|
||
|
|
@@ -100,7 +100,7 @@ def watch_for_tls(ready_flag, retq: MP.Queue):
|
||
|
|
retq.put(req_tls)
|
||
|
|
|
||
|
|
|
||
|
|
-def watch_for_smtps(ready_flag, retq: MP.Queue):
|
||
|
|
+def watch_for_smtps(ready_flag: MP_Event, retq: MP.Queue):
|
||
|
|
has_smtps = False
|
||
|
|
ready_flag.set()
|
||
|
|
start = time.monotonic()
|
||
|
|
@@ -276,7 +276,7 @@ class TestParseArgs:
|
||
|
|
)
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
- "args, exp_host, exp_port",
|
||
|
|
+ ("args", "exp_host", "exp_port"),
|
||
|
|
[
|
||
|
|
((), "localhost", 8025),
|
||
|
|
(("-l", "foo:25"), "foo", 25),
|
||
|
|
@@ -333,7 +333,7 @@ class TestParseArgs:
|
||
|
|
assert args.requiretls is False
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
- "certfile, keyfile, expect",
|
||
|
|
+ ("certfile", "keyfile", "expect"),
|
||
|
|
[
|
||
|
|
("x", "x", "Cert file x not found"),
|
||
|
|
(SERVER_CRT, "x", "Key file x not found"),
|
||
|
|
diff --git a/aiosmtpd/tests/test_proxyprotocol.py b/aiosmtpd/tests/test_proxyprotocol.py
|
||
|
|
index bf7f939..ad9dc9a 100644
|
||
|
|
--- a/aiosmtpd/tests/test_proxyprotocol.py
|
||
|
|
+++ b/aiosmtpd/tests/test_proxyprotocol.py
|
||
|
|
@@ -10,12 +10,12 @@ import socket
|
||
|
|
import struct
|
||
|
|
import time
|
||
|
|
from base64 import b64decode
|
||
|
|
-from contextlib import contextmanager
|
||
|
|
+from contextlib import contextmanager, suppress
|
||
|
|
from functools import partial
|
||
|
|
from ipaddress import IPv4Address, IPv6Address
|
||
|
|
from smtplib import SMTP as SMTPClient
|
||
|
|
from smtplib import SMTPServerDisconnected
|
||
|
|
-from typing import Any, Dict, List, Optional
|
||
|
|
+from typing import Any, Callable, Dict, List, Optional
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from pytest_mock import MockFixture
|
||
|
|
@@ -35,6 +35,7 @@ from aiosmtpd.proxy_protocol import (
|
||
|
|
)
|
||
|
|
from aiosmtpd.smtp import SMTP as SMTPServer
|
||
|
|
from aiosmtpd.smtp import Session as SMTPSession
|
||
|
|
+from aiosmtpd.smtp import Envelope as SMTPEnvelope
|
||
|
|
from aiosmtpd.tests.conftest import Global, controller_data, handler_data
|
||
|
|
|
||
|
|
DEFAULT_AUTOCANCEL = 0.1
|
||
|
|
@@ -94,13 +95,19 @@ HANDSHAKES = {
|
||
|
|
|
||
|
|
|
||
|
|
class ProxyPeekerHandler(Sink):
|
||
|
|
- def __init__(self, retval=True):
|
||
|
|
+ def __init__(self, retval: bool = True):
|
||
|
|
self.called = False
|
||
|
|
self.sessions: List[SMTPSession] = []
|
||
|
|
self.proxy_datas: List[ProxyData] = []
|
||
|
|
self.retval = retval
|
||
|
|
|
||
|
|
- async def handle_PROXY(self, server, session, envelope, proxy_data):
|
||
|
|
+ async def handle_PROXY(
|
||
|
|
+ self,
|
||
|
|
+ server: SMTPServer,
|
||
|
|
+ session: SMTPSession,
|
||
|
|
+ envelope: SMTPEnvelope,
|
||
|
|
+ proxy_data: ProxyData,
|
||
|
|
+ ) -> bool:
|
||
|
|
self.called = True
|
||
|
|
self.sessions.append(session)
|
||
|
|
self.proxy_datas.append(proxy_data)
|
||
|
|
@@ -113,7 +120,9 @@ def does_not_raise():
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def setup_proxy_protocol(mocker: MockFixture, temp_event_loop):
|
||
|
|
+def setup_proxy_protocol(
|
||
|
|
+ mocker: MockFixture, temp_event_loop: asyncio.AbstractEventLoop
|
||
|
|
+) -> Callable:
|
||
|
|
proxy_timeout = 1.0
|
||
|
|
responses = []
|
||
|
|
transport = mocker.Mock()
|
||
|
|
@@ -129,16 +138,14 @@ def setup_proxy_protocol(mocker: MockFixture, temp_event_loop):
|
||
|
|
|
||
|
|
def runner(stop_after: float = DEFAULT_AUTOCANCEL):
|
||
|
|
loop.call_later(stop_after, protocol._handler_coroutine.cancel)
|
||
|
|
- try:
|
||
|
|
+ with suppress(asyncio.CancelledError):
|
||
|
|
loop.run_until_complete(protocol._handler_coroutine)
|
||
|
|
- except asyncio.CancelledError:
|
||
|
|
- pass
|
||
|
|
|
||
|
|
test_obj.protocol = protocol
|
||
|
|
test_obj.runner = runner
|
||
|
|
test_obj.transport = transport
|
||
|
|
|
||
|
|
- yield getter
|
||
|
|
+ return getter
|
||
|
|
|
||
|
|
|
||
|
|
class _TestProxyProtocolCommon:
|
||
|
|
@@ -303,7 +310,7 @@ class TestProxyTLV:
|
||
|
|
(None, "wrongname"),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
- def test_backmap(self, typename, typeint):
|
||
|
|
+ def test_backmap(self, typename: str, typeint: int):
|
||
|
|
assert ProxyTLV.name_to_num(typename) == typeint
|
||
|
|
|
||
|
|
def test_parse_partial(self):
|
||
|
|
@@ -384,14 +391,23 @@ class TestModule:
|
||
|
|
return emit
|
||
|
|
|
||
|
|
@parametrize("handshake", HANDSHAKES.values(), ids=HANDSHAKES.keys())
|
||
|
|
- def test_get(self, caplog, temp_event_loop, handshake):
|
||
|
|
+ def test_get(
|
||
|
|
+ self,
|
||
|
|
+ caplog: pytest.LogCaptureFixture,
|
||
|
|
+ temp_event_loop: asyncio.AbstractEventLoop,
|
||
|
|
+ handshake: bytes,
|
||
|
|
+ ):
|
||
|
|
caplog.set_level(logging.DEBUG)
|
||
|
|
mock_reader = self.MockAsyncReader(handshake)
|
||
|
|
reslt = temp_event_loop.run_until_complete(get_proxy(mock_reader))
|
||
|
|
assert isinstance(reslt, ProxyData)
|
||
|
|
assert reslt.valid
|
||
|
|
|
||
|
|
- def test_get_cut_v1(self, caplog, temp_event_loop):
|
||
|
|
+ def test_get_cut_v1(
|
||
|
|
+ self,
|
||
|
|
+ caplog: pytest.LogCaptureFixture,
|
||
|
|
+ temp_event_loop: asyncio.AbstractEventLoop,
|
||
|
|
+ ):
|
||
|
|
caplog.set_level(logging.DEBUG)
|
||
|
|
mock_reader = self.MockAsyncReader(GOOD_V1_HANDSHAKE[0:20])
|
||
|
|
reslt = temp_event_loop.run_until_complete(get_proxy(mock_reader))
|
||
|
|
@@ -401,7 +417,11 @@ class TestModule:
|
||
|
|
expect = ("mail.debug", 30, "PROXY error: PROXYv1 malformed")
|
||
|
|
assert expect in caplog.record_tuples
|
||
|
|
|
||
|
|
- def test_get_cut_v2(self, caplog, temp_event_loop):
|
||
|
|
+ def test_get_cut_v2(
|
||
|
|
+ self,
|
||
|
|
+ caplog: pytest.LogCaptureFixture,
|
||
|
|
+ temp_event_loop: asyncio.AbstractEventLoop,
|
||
|
|
+ ):
|
||
|
|
caplog.set_level(logging.DEBUG)
|
||
|
|
mock_reader = self.MockAsyncReader(TEST_V2_DATA1_EXACT[0:20])
|
||
|
|
reslt = temp_event_loop.run_until_complete(get_proxy(mock_reader))
|
||
|
|
@@ -412,7 +432,11 @@ class TestModule:
|
||
|
|
expect = ("mail.debug", 30, expect_msg)
|
||
|
|
assert expect in caplog.record_tuples
|
||
|
|
|
||
|
|
- def test_get_invalid_sig(self, caplog, temp_event_loop):
|
||
|
|
+ def test_get_invalid_sig(
|
||
|
|
+ self,
|
||
|
|
+ caplog: pytest.LogCaptureFixture,
|
||
|
|
+ temp_event_loop: asyncio.AbstractEventLoop,
|
||
|
|
+ ):
|
||
|
|
caplog.set_level(logging.DEBUG)
|
||
|
|
mock_reader = self.MockAsyncReader(b"PROXI TCP4 1.2.3.4 5.6.7.8 9 10\r\n")
|
||
|
|
reslt = temp_event_loop.run_until_complete(get_proxy(mock_reader))
|
||
|
|
@@ -451,7 +475,7 @@ class TestGetV1(_TestProxyProtocolCommon):
|
||
|
|
assert self.transport.close.called
|
||
|
|
|
||
|
|
@parametrize("patt", PUBLIC_V1_PATTERNS.values(), ids=PUBLIC_V1_PATTERNS.keys())
|
||
|
|
- def test_valid_patterns(self, setup_proxy_protocol, patt: bytes):
|
||
|
|
+ def test_valid_patterns(self, setup_proxy_protocol: Callable, patt: bytes):
|
||
|
|
if not patt.endswith(b"\r\n"):
|
||
|
|
patt += b"\r\n"
|
||
|
|
setup_proxy_protocol(self)
|
||
|
|
@@ -1004,7 +1028,7 @@ class TestWithController:
|
||
|
|
# Try resending the handshake. Should also fail (because connection has
|
||
|
|
# been closed by the server.
|
||
|
|
# noinspection PyTypeChecker
|
||
|
|
- with pytest.raises(OSError) as exc_info:
|
||
|
|
+ with pytest.raises(OSError) as exc_info: # noqa: PT011
|
||
|
|
sock.send(handshake)
|
||
|
|
resp = sock.recv(4096)
|
||
|
|
if resp == b"":
|
||
|
|
@@ -1041,7 +1065,7 @@ class TestWithController:
|
||
|
|
# Try resending the handshake. Should also fail (because connection has
|
||
|
|
# been closed by the server.
|
||
|
|
# noinspection PyTypeChecker
|
||
|
|
- with pytest.raises(OSError) as exc_info:
|
||
|
|
+ with pytest.raises(OSError) as exc_info: # noqa: PT011
|
||
|
|
sock.send(handshake)
|
||
|
|
resp = sock.recv(4096)
|
||
|
|
if resp == b"":
|
||
|
|
@@ -1094,8 +1118,7 @@ class TestHandlerAcceptReject:
|
||
|
|
sock.sendall(handshake)
|
||
|
|
resp = sock.recv(4096)
|
||
|
|
assert oper(resp, b"")
|
||
|
|
- with expect:
|
||
|
|
- with SMTPClient() as client:
|
||
|
|
- client.sock = sock
|
||
|
|
- code, mesg = client.ehlo("example.org")
|
||
|
|
- assert code == 250
|
||
|
|
+ with expect, SMTPClient() as client:
|
||
|
|
+ client.sock = sock
|
||
|
|
+ code, mesg = client.ehlo("example.org")
|
||
|
|
+ assert code == 250
|
||
|
|
diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py
|
||
|
|
index 41225dc..5e27070 100644
|
||
|
|
--- a/aiosmtpd/tests/test_server.py
|
||
|
|
+++ b/aiosmtpd/tests/test_server.py
|
||
|
|
@@ -10,6 +10,7 @@ import socket
|
||
|
|
import time
|
||
|
|
from contextlib import ExitStack
|
||
|
|
from functools import partial
|
||
|
|
+from threading import Event
|
||
|
|
from pathlib import Path
|
||
|
|
from smtplib import SMTP as SMTPClient, SMTPServerDisconnected
|
||
|
|
from tempfile import mkdtemp
|
||
|
|
@@ -40,7 +41,7 @@ class SlowStartController(Controller):
|
||
|
|
kwargs.setdefault("ready_timeout", 0.5)
|
||
|
|
super().__init__(*args, **kwargs)
|
||
|
|
|
||
|
|
- def _run(self, ready_event):
|
||
|
|
+ def _run(self, ready_event: Event):
|
||
|
|
time.sleep(self.ready_timeout * 1.5)
|
||
|
|
super()._run(ready_event)
|
||
|
|
|
||
|
|
@@ -88,7 +89,7 @@ def safe_socket_dir() -> Generator[Path, None, None]:
|
||
|
|
#
|
||
|
|
yield tmpdir
|
||
|
|
#
|
||
|
|
- plist = [p for p in tmpdir.rglob("*")]
|
||
|
|
+ plist = list(tmpdir.rglob("*"))
|
||
|
|
for p in reversed(plist):
|
||
|
|
if p.is_dir():
|
||
|
|
p.rmdir()
|
||
|
|
@@ -97,7 +98,7 @@ def safe_socket_dir() -> Generator[Path, None, None]:
|
||
|
|
tmpdir.rmdir()
|
||
|
|
|
||
|
|
|
||
|
|
-def assert_smtp_socket(controller: UnixSocketMixin):
|
||
|
|
+def assert_smtp_socket(controller: UnixSocketMixin) -> bool:
|
||
|
|
assert Path(controller.unix_socket).exists()
|
||
|
|
sockfile = controller.unix_socket
|
||
|
|
ssl_context = controller.ssl_context
|
||
|
|
@@ -134,6 +135,7 @@ def assert_smtp_socket(controller: UnixSocketMixin):
|
||
|
|
catchup_delay()
|
||
|
|
resp = sock.recv(1024)
|
||
|
|
assert resp.startswith(b"221")
|
||
|
|
+ return True
|
||
|
|
|
||
|
|
|
||
|
|
class TestServer:
|
||
|
|
@@ -207,8 +209,9 @@ class TestController:
|
||
|
|
contr2 = Controller(
|
||
|
|
Sink(), hostname=Global.SrvAddr.host, port=Global.SrvAddr.port
|
||
|
|
)
|
||
|
|
+ expectedre = r"error while attempting to bind on address"
|
||
|
|
try:
|
||
|
|
- with pytest.raises(socket.error):
|
||
|
|
+ with pytest.raises(socket.error, match=expectedre):
|
||
|
|
contr2.start()
|
||
|
|
finally:
|
||
|
|
contr2.stop()
|
||
|
|
@@ -526,6 +529,7 @@ class TestUnthreaded:
|
||
|
|
assert temp_event_loop.is_closed() is False
|
||
|
|
|
||
|
|
|
||
|
|
+@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
|
||
|
|
class TestFactory:
|
||
|
|
def test_normal_situation(self):
|
||
|
|
cont = Controller(Sink())
|
||
|
|
@@ -537,8 +541,7 @@ class TestFactory:
|
||
|
|
finally:
|
||
|
|
cont.stop()
|
||
|
|
|
||
|
|
- @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
|
||
|
|
- def test_unknown_args_direct(self, silence_event_loop_closed):
|
||
|
|
+ def test_unknown_args_direct(self, silence_event_loop_closed: bool):
|
||
|
|
unknown = "this_is_an_unknown_kwarg"
|
||
|
|
cont = Controller(Sink(), ready_timeout=0.3, **{unknown: True})
|
||
|
|
expectedre = r"__init__.. got an unexpected keyword argument '" + unknown + r"'"
|
||
|
|
@@ -553,8 +556,7 @@ class TestFactory:
|
||
|
|
@pytest.mark.filterwarnings(
|
||
|
|
"ignore:server_kwargs will be removed:DeprecationWarning"
|
||
|
|
)
|
||
|
|
- @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
|
||
|
|
- def test_unknown_args_inkwargs(self, silence_event_loop_closed):
|
||
|
|
+ def test_unknown_args_inkwargs(self, silence_event_loop_closed: bool):
|
||
|
|
unknown = "this_is_an_unknown_kwarg"
|
||
|
|
cont = Controller(Sink(), ready_timeout=0.3, server_kwargs={unknown: True})
|
||
|
|
expectedre = r"__init__.. got an unexpected keyword argument '" + unknown + r"'"
|
||
|
|
@@ -565,8 +567,7 @@ class TestFactory:
|
||
|
|
finally:
|
||
|
|
cont.stop()
|
||
|
|
|
||
|
|
- @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
|
||
|
|
- def test_factory_none(self, mocker: MockFixture, silence_event_loop_closed):
|
||
|
|
+ def test_factory_none(self, mocker: MockFixture, silence_event_loop_closed: bool):
|
||
|
|
# Hypothetical situation where factory() did not raise an Exception
|
||
|
|
# but returned None instead
|
||
|
|
mocker.patch("aiosmtpd.controller.SMTP", return_value=None)
|
||
|
|
@@ -579,8 +580,9 @@ class TestFactory:
|
||
|
|
finally:
|
||
|
|
cont.stop()
|
||
|
|
|
||
|
|
- @pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
|
||
|
|
- def test_noexc_smtpd_missing(self, mocker, silence_event_loop_closed):
|
||
|
|
+ def test_noexc_smtpd_missing(
|
||
|
|
+ self, mocker: MockFixture, silence_event_loop_closed: bool
|
||
|
|
+ ):
|
||
|
|
# Hypothetical situation where factory() failed but no
|
||
|
|
# Exception was generated.
|
||
|
|
cont = Controller(Sink())
|
||
|
|
diff --git a/aiosmtpd/tests/test_smtp.py b/aiosmtpd/tests/test_smtp.py
|
||
|
|
index 6fd8bfb..0fb3a15 100644
|
||
|
|
--- a/aiosmtpd/tests/test_smtp.py
|
||
|
|
+++ b/aiosmtpd/tests/test_smtp.py
|
||
|
|
@@ -9,6 +9,7 @@ import logging
|
||
|
|
import socket
|
||
|
|
import time
|
||
|
|
import warnings
|
||
|
|
+from asyncio.transports import Transport
|
||
|
|
from base64 import b64encode
|
||
|
|
from contextlib import suppress
|
||
|
|
from smtplib import (
|
||
|
|
@@ -19,7 +20,7 @@ from smtplib import (
|
||
|
|
SMTPServerDisconnected,
|
||
|
|
)
|
||
|
|
from textwrap import dedent
|
||
|
|
-from typing import Any, AnyStr, Callable, Generator, List, Tuple
|
||
|
|
+from typing import cast, Any, AnyStr, Callable, Generator, List, Tuple
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from pytest_mock import MockFixture
|
||
|
|
@@ -62,10 +63,7 @@ MAIL_LOG.setLevel(logging.DEBUG)
|
||
|
|
|
||
|
|
|
||
|
|
def auth_callback(mechanism, login, password) -> bool:
|
||
|
|
- if login and login.decode() == "goodlogin":
|
||
|
|
- return True
|
||
|
|
- else:
|
||
|
|
- return False
|
||
|
|
+ return login and login.decode() == "goodlogin"
|
||
|
|
|
||
|
|
|
||
|
|
def assert_nopassleak(passwd: str, record_tuples: List[Tuple[str, int, str]]):
|
||
|
|
@@ -87,7 +85,7 @@ class UndescribableError(Exception):
|
||
|
|
class ErrorSMTP(Server):
|
||
|
|
exception_type = ValueError
|
||
|
|
|
||
|
|
- async def smtp_HELO(self, hostname):
|
||
|
|
+ async def smtp_HELO(self, hostname: str):
|
||
|
|
raise self.exception_type("test")
|
||
|
|
|
||
|
|
|
||
|
|
@@ -136,8 +134,13 @@ class PeekerHandler:
|
||
|
|
return AuthResult(success=True, auth_data=login_data)
|
||
|
|
|
||
|
|
async def handle_MAIL(
|
||
|
|
- self, server, session: SMTPSession, envelope, address, mail_options
|
||
|
|
- ):
|
||
|
|
+ self,
|
||
|
|
+ server: Server,
|
||
|
|
+ session: SMTPSession,
|
||
|
|
+ envelope: SMTPEnvelope,
|
||
|
|
+ address: str,
|
||
|
|
+ mail_options: dict,
|
||
|
|
+ ) -> str:
|
||
|
|
self.sess = session
|
||
|
|
return S.S250_OK.to_str()
|
||
|
|
|
||
|
|
@@ -157,7 +160,7 @@ class PeekerHandler:
|
||
|
|
async def auth_DONT(self, server, args):
|
||
|
|
return MISSING
|
||
|
|
|
||
|
|
- async def auth_WITH_UNDERSCORE(self, server: Server, args):
|
||
|
|
+ async def auth_WITH_UNDERSCORE(self, server: Server, args) -> str:
|
||
|
|
"""
|
||
|
|
Be careful when using this AUTH mechanism; log_client_response is set to
|
||
|
|
True, and this will raise some severe warnings.
|
||
|
|
@@ -180,7 +183,9 @@ class StoreEnvelopeOnVRFYHandler:
|
||
|
|
|
||
|
|
envelope = None
|
||
|
|
|
||
|
|
- async def handle_VRFY(self, server, session, envelope, addr):
|
||
|
|
+ async def handle_VRFY(
|
||
|
|
+ self, server: Server, session: SMTPSession, envelope: SMTPEnvelope, addr: str
|
||
|
|
+ ) -> str:
|
||
|
|
self.envelope = envelope
|
||
|
|
return S.S250_OK.to_str()
|
||
|
|
|
||
|
|
@@ -189,10 +194,10 @@ class ErroringHandler:
|
||
|
|
error = None
|
||
|
|
custom_response = False
|
||
|
|
|
||
|
|
- async def handle_DATA(self, server, session, envelope):
|
||
|
|
+ async def handle_DATA(self, server, session, envelope) -> str:
|
||
|
|
return "499 Could not accept the message"
|
||
|
|
|
||
|
|
- async def handle_exception(self, error):
|
||
|
|
+ async def handle_exception(self, error) -> str:
|
||
|
|
self.error = error
|
||
|
|
if not self.custom_response:
|
||
|
|
return "500 ErroringHandler handling error"
|
||
|
|
@@ -215,7 +220,7 @@ class ErroringHandlerConnectionLost:
|
||
|
|
class ErroringErrorHandler:
|
||
|
|
error = None
|
||
|
|
|
||
|
|
- async def handle_exception(self, error):
|
||
|
|
+ async def handle_exception(self, error: Exception):
|
||
|
|
self.error = error
|
||
|
|
raise ValueError("ErroringErrorHandler test")
|
||
|
|
|
||
|
|
@@ -223,13 +228,19 @@ class ErroringErrorHandler:
|
||
|
|
class UndescribableErrorHandler:
|
||
|
|
error = None
|
||
|
|
|
||
|
|
- async def handle_exception(self, error):
|
||
|
|
+ async def handle_exception(self, error: Exception):
|
||
|
|
self.error = error
|
||
|
|
raise UndescribableError()
|
||
|
|
|
||
|
|
|
||
|
|
class SleepingHeloHandler:
|
||
|
|
- async def handle_HELO(self, server, session, envelope, hostname):
|
||
|
|
+ async def handle_HELO(
|
||
|
|
+ self,
|
||
|
|
+ server: Server,
|
||
|
|
+ session: SMTPSession,
|
||
|
|
+ envelope: SMTPEnvelope,
|
||
|
|
+ hostname: str,
|
||
|
|
+ ) -> str:
|
||
|
|
await asyncio.sleep(0.01)
|
||
|
|
session.host_name = hostname
|
||
|
|
return "250 {}".format(server.hostname)
|
||
|
|
@@ -267,8 +278,7 @@ class CustomIdentController(Controller):
|
||
|
|
ident: bytes = b"Identifying SMTP v2112"
|
||
|
|
|
||
|
|
def factory(self):
|
||
|
|
- server = Server(self.handler, ident=self.ident.decode())
|
||
|
|
- return server
|
||
|
|
+ return Server(self.handler, ident=self.ident.decode())
|
||
|
|
|
||
|
|
|
||
|
|
# endregion
|
||
|
|
@@ -278,18 +288,19 @@ class CustomIdentController(Controller):
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def transport_resp(mocker: MockFixture):
|
||
|
|
+def transport_resp(mocker: MockFixture) -> Tuple[Transport, list]:
|
||
|
|
responses = []
|
||
|
|
mocked = mocker.Mock()
|
||
|
|
mocked.write = responses.append
|
||
|
|
#
|
||
|
|
- yield mocked, responses
|
||
|
|
+ return cast(Transport, mocked), responses
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def get_protocol(
|
||
|
|
- temp_event_loop, transport_resp
|
||
|
|
-) -> Generator[Callable[..., Server], None, None]:
|
||
|
|
+ temp_event_loop: asyncio.AbstractEventLoop,
|
||
|
|
+ transport_resp: Any,
|
||
|
|
+) -> Callable[..., Server]:
|
||
|
|
transport, _ = transport_resp
|
||
|
|
|
||
|
|
def getter(*args, **kwargs) -> Server:
|
||
|
|
@@ -297,14 +308,16 @@ def get_protocol(
|
||
|
|
proto.connection_made(transport)
|
||
|
|
return proto
|
||
|
|
|
||
|
|
- yield getter
|
||
|
|
+ return getter
|
||
|
|
|
||
|
|
|
||
|
|
# region #### Fixtures: Controllers ##################################################
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def auth_peeker_controller(get_controller) -> Generator[Controller, None, None]:
|
||
|
|
+def auth_peeker_controller(
|
||
|
|
+ get_controller: Callable[..., Controller]
|
||
|
|
+) -> Generator[Controller, None, None]:
|
||
|
|
handler = PeekerHandler()
|
||
|
|
controller = get_controller(
|
||
|
|
handler,
|
||
|
|
@@ -324,7 +337,7 @@ def auth_peeker_controller(get_controller) -> Generator[Controller, None, None]:
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def authenticator_peeker_controller(
|
||
|
|
- get_controller,
|
||
|
|
+ get_controller: Callable[..., Controller]
|
||
|
|
) -> Generator[Controller, None, None]:
|
||
|
|
handler = PeekerHandler()
|
||
|
|
controller = get_controller(
|
||
|
|
@@ -345,7 +358,8 @@ def authenticator_peeker_controller(
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def decoding_authnotls_controller(
|
||
|
|
- get_handler, get_controller
|
||
|
|
+ get_handler: Callable,
|
||
|
|
+ get_controller: Callable[..., Controller]
|
||
|
|
) -> Generator[Controller, None, None]:
|
||
|
|
handler = get_handler()
|
||
|
|
controller = get_controller(
|
||
|
|
@@ -368,7 +382,7 @@ def decoding_authnotls_controller(
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
-def error_controller(get_handler) -> Generator[ErrorController, None, None]:
|
||
|
|
+def error_controller(get_handler: Callable) -> Generator[ErrorController, None, None]:
|
||
|
|
handler = get_handler()
|
||
|
|
controller = ErrorController(handler)
|
||
|
|
controller.start()
|
||
|
|
@@ -417,10 +431,8 @@ class TestProtocol:
|
||
|
|
]
|
||
|
|
)
|
||
|
|
)
|
||
|
|
- try:
|
||
|
|
+ with suppress(asyncio.CancelledError):
|
||
|
|
temp_event_loop.run_until_complete(protocol._handler_coroutine)
|
||
|
|
- except asyncio.CancelledError:
|
||
|
|
- pass
|
||
|
|
_, responses = transport_resp
|
||
|
|
assert responses[5] == S.S250_OK.to_bytes() + b"\r\n"
|
||
|
|
assert len(handler.box) == 1
|
||
|
|
@@ -441,10 +453,8 @@ class TestProtocol:
|
||
|
|
]
|
||
|
|
)
|
||
|
|
)
|
||
|
|
- try:
|
||
|
|
+ with suppress(asyncio.CancelledError):
|
||
|
|
temp_event_loop.run_until_complete(protocol._handler_coroutine)
|
||
|
|
- except asyncio.CancelledError:
|
||
|
|
- pass
|
||
|
|
_, responses = transport_resp
|
||
|
|
assert responses[5] == S.S250_OK.to_bytes() + b"\r\n"
|
||
|
|
assert len(handler.box) == 1
|
||
|
|
@@ -986,19 +996,19 @@ class TestAuthMechanisms(_CommonMethods):
|
||
|
|
@pytest.fixture
|
||
|
|
def do_auth_plain1(
|
||
|
|
self, client
|
||
|
|
- ) -> Generator[Callable[[str], Tuple[int, bytes]], None, None]:
|
||
|
|
+ ) -> Callable[[str], Tuple[int, bytes]]:
|
||
|
|
self._ehlo(client)
|
||
|
|
|
||
|
|
def do(param: str) -> Tuple[int, bytes]:
|
||
|
|
return client.docmd("AUTH PLAIN " + param)
|
||
|
|
|
||
|
|
do.client = client
|
||
|
|
- yield do
|
||
|
|
+ return do
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def do_auth_login3(
|
||
|
|
self, client
|
||
|
|
- ) -> Generator[Callable[[str], Tuple[int, bytes]], None, None]:
|
||
|
|
+ ) -> Callable[[str], Tuple[int, bytes]]:
|
||
|
|
self._ehlo(client)
|
||
|
|
resp = client.docmd("AUTH LOGIN")
|
||
|
|
assert resp == S.S334_AUTH_USERNAME
|
||
|
|
@@ -1007,7 +1017,7 @@ class TestAuthMechanisms(_CommonMethods):
|
||
|
|
return client.docmd(param)
|
||
|
|
|
||
|
|
do.client = client
|
||
|
|
- yield do
|
||
|
|
+ return do
|
||
|
|
|
||
|
|
def test_ehlo(self, client):
|
||
|
|
code, mesg = client.ehlo("example.com")
|
||
|
|
@@ -1119,11 +1129,11 @@ class TestAuthMechanisms(_CommonMethods):
|
||
|
|
assert_nopassleak(PW, caplog.record_tuples)
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
- def client_auth_plain2(self, client) -> Generator[SMTPClient, None, None]:
|
||
|
|
+ def client_auth_plain2(self, client) -> SMTPClient:
|
||
|
|
self._ehlo(client)
|
||
|
|
resp = client.docmd("AUTH PLAIN")
|
||
|
|
assert resp == S.S334_AUTH_EMPTYPROMPT
|
||
|
|
- yield client
|
||
|
|
+ return client
|
||
|
|
|
||
|
|
def test_plain2_good_credentials(
|
||
|
|
self, caplog, auth_peeker_controller, client_auth_plain2
|
||
|
|
@@ -1965,7 +1975,8 @@ class TestAuthArgs:
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_authmechname_decorator_badname(self, name):
|
||
|
|
- with pytest.raises(ValueError):
|
||
|
|
+ expectre = r"Invalid AUTH mechanism name"
|
||
|
|
+ with pytest.raises(ValueError, match=expectre):
|
||
|
|
auth_mechanism(name)
|
||
|
|
|
||
|
|
|
||
|
|
diff --git a/aiosmtpd/tests/test_starttls.py b/aiosmtpd/tests/test_starttls.py
|
||
|
|
index 6bb2cbd..5e0a180 100644
|
||
|
|
--- a/aiosmtpd/tests/test_starttls.py
|
||
|
|
+++ b/aiosmtpd/tests/test_starttls.py
|
||
|
|
@@ -12,6 +12,7 @@ import pytest
|
||
|
|
from aiosmtpd.controller import Controller
|
||
|
|
from aiosmtpd.handlers import Sink
|
||
|
|
from aiosmtpd.smtp import SMTP as Server
|
||
|
|
+from aiosmtpd.smtp import Envelope
|
||
|
|
from aiosmtpd.smtp import Session as Sess_
|
||
|
|
from aiosmtpd.smtp import TLSSetupException
|
||
|
|
from aiosmtpd.testing.helpers import ReceivingHandler, catchup_delay
|
||
|
|
@@ -31,14 +32,18 @@ class EOFingHandler:
|
||
|
|
ssl_existed = None
|
||
|
|
result = None
|
||
|
|
|
||
|
|
- async def handle_NOOP(self, server: Server, session: Sess_, envelope, arg):
|
||
|
|
+ async def handle_NOOP(
|
||
|
|
+ self, server: Server, session: Sess_, envelope: Envelope, arg: str
|
||
|
|
+ ) -> str:
|
||
|
|
self.ssl_existed = session.ssl is not None
|
||
|
|
self.result = server.eof_received()
|
||
|
|
return "250 OK"
|
||
|
|
|
||
|
|
|
||
|
|
class HandshakeFailingHandler:
|
||
|
|
- def handle_STARTTLS(self, server, session, envelope):
|
||
|
|
+ def handle_STARTTLS(
|
||
|
|
+ self, server: Server, session: Sess_, envelope: Envelope
|
||
|
|
+ ) -> bool:
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
@@ -198,7 +203,7 @@ class TestStartTLS:
|
||
|
|
class ExceptionCaptureHandler:
|
||
|
|
error = None
|
||
|
|
|
||
|
|
- async def handle_exception(self, error):
|
||
|
|
+ async def handle_exception(self, error: Exception) -> str:
|
||
|
|
self.error = error
|
||
|
|
return "500 ExceptionCaptureHandler handling error"
|
||
|
|
|
||
|
|
@@ -354,7 +359,7 @@ class TestRequireTLSAUTH:
|
||
|
|
class TestTLSContext:
|
||
|
|
def test_verify_mode_nochange(self, ssl_context_server):
|
||
|
|
context = ssl_context_server
|
||
|
|
- for mode in (ssl.CERT_NONE, ssl.CERT_OPTIONAL):
|
||
|
|
+ for mode in (ssl.CERT_NONE, ssl.CERT_OPTIONAL): # noqa: DUO122
|
||
|
|
context.verify_mode = mode
|
||
|
|
_ = Server(Sink(), tls_context=context)
|
||
|
|
assert context.verify_mode == mode
|
||
|
|
@@ -370,10 +375,10 @@ class TestTLSContext:
|
||
|
|
|
||
|
|
def test_nocertreq_chkhost_warn(self, caplog, ssl_context_server):
|
||
|
|
context = ssl_context_server
|
||
|
|
- context.verify_mode = ssl.CERT_OPTIONAL
|
||
|
|
+ context.verify_mode = ssl.CERT_OPTIONAL # noqa: DUO122
|
||
|
|
context.check_hostname = True
|
||
|
|
_ = Server(Sink(), tls_context=context)
|
||
|
|
- assert context.verify_mode == ssl.CERT_OPTIONAL
|
||
|
|
+ assert context.verify_mode == ssl.CERT_OPTIONAL # noqa: DUO122
|
||
|
|
logmsg = caplog.record_tuples[0][-1]
|
||
|
|
assert "tls_context.check_hostname == True" in logmsg
|
||
|
|
assert "might cause client connection problems" in logmsg
|
||
|
|
diff --git a/housekeep.py b/housekeep.py
|
||
|
|
index 88dddd5..92b8cb6 100644
|
||
|
|
--- a/housekeep.py
|
||
|
|
+++ b/housekeep.py
|
||
|
|
@@ -69,7 +69,8 @@ TERM_WIDTH, TERM_HEIGHT = shutil.get_terminal_size()
|
||
|
|
def deldir(targ: Path, verbose: bool = True):
|
||
|
|
if not targ.exists():
|
||
|
|
return
|
||
|
|
- for i, pp in enumerate(reversed(sorted(targ.rglob("*"))), start=1):
|
||
|
|
+ rev_items = sorted(targ.rglob("*"), reverse=True)
|
||
|
|
+ for i, pp in enumerate(rev_items, start=1):
|
||
|
|
if pp.is_symlink():
|
||
|
|
pp.unlink()
|
||
|
|
elif pp.is_file():
|
||
|
|
diff --git a/setup.cfg b/setup.cfg
|
||
|
|
index 7cfbf7f..6638b75 100644
|
||
|
|
--- a/setup.cfg
|
||
|
|
+++ b/setup.cfg
|
||
|
|
@@ -66,4 +66,54 @@ source-dir = aiosmtpd/docs
|
||
|
|
[flake8]
|
||
|
|
jobs = 1
|
||
|
|
max-line-length = 88
|
||
|
|
-ignore = E123, E133, W503, W504, W293, E203
|
||
|
|
+# "E,F,W,C90" are flake8 defaults
|
||
|
|
+# For others, take a gander at tox.ini to see which prefix provided by who
|
||
|
|
+select = E,F,W,C90,C4,MOD,JS,PIE,PT,SIM,ECE,C801,DUO,TAE,ANN,YTT,N400
|
||
|
|
+ignore =
|
||
|
|
+ # black conflicts with E123 & E133
|
||
|
|
+ E123
|
||
|
|
+ E133
|
||
|
|
+ # W503 conflicts with PEP8...
|
||
|
|
+ W503
|
||
|
|
+ # W293 is a bit too noisy. Many files have been edited using editors that do not remove spaces from blank lines.
|
||
|
|
+ W293
|
||
|
|
+ # Sometimes spaces around colons improve readability
|
||
|
|
+ E203
|
||
|
|
+ # Sometimes we prefer the func()-based creation, not literal, for readability
|
||
|
|
+ C408
|
||
|
|
+ # Sometimes we need to catch Exception broadly
|
||
|
|
+ PIE786
|
||
|
|
+ # We don't really care about pytest.fixture vs pytest.fixture()
|
||
|
|
+ PT001
|
||
|
|
+ # Good idea, but too many changes. Remove this in the future, and create separate PR
|
||
|
|
+ PT004
|
||
|
|
+ # Sometimes exception needs to be explicitly raised in special circumstances, needing additional lines of code
|
||
|
|
+ PT012
|
||
|
|
+ # I still can't grok the need to annotate "self" or "cls" ...
|
||
|
|
+ ANN101
|
||
|
|
+ ANN102
|
||
|
|
+ # I don't think forcing annotation for *args and **kwargs is a wise idea...
|
||
|
|
+ ANN002
|
||
|
|
+ ANN003
|
||
|
|
+ # We have too many "if..elif..else: raise" structures that does not convert well to "error-first" design
|
||
|
|
+ SIM106
|
||
|
|
+per-file-ignores =
|
||
|
|
+ aiosmtpd/tests/test_*:ANN001
|
||
|
|
+ aiosmtpd/tests/test_proxyprotocol.py:DUO102
|
||
|
|
+ aiosmtpd/docs/_exts/autoprogramm.py:C801
|
||
|
|
+# flake8-coding
|
||
|
|
+no-accept-encodings = True
|
||
|
|
+# flake8-copyright
|
||
|
|
+copyright-check = True
|
||
|
|
+# The number below was determined empirically by bisecting from 100 until no copyright-unnecessary files appear
|
||
|
|
+copyright-min-file-size = 44
|
||
|
|
+copyright-author = The aiosmtpd Developers
|
||
|
|
+# flake8-annotations-complexity
|
||
|
|
+max-annotations-complexity = 4
|
||
|
|
+# flake8-annotations-coverage
|
||
|
|
+min-coverage-percents = 12
|
||
|
|
+# flake8-annotations
|
||
|
|
+mypy-init-return = True
|
||
|
|
+suppress-none-returning = True
|
||
|
|
+suppress-dummy-args = True
|
||
|
|
+allow-untyped-defs = True
|
||
|
|
diff --git a/tox.ini b/tox.ini
|
||
|
|
index 17d246e..eb2f4f6 100644
|
||
|
|
--- a/tox.ini
|
||
|
|
+++ b/tox.ini
|
||
|
|
@@ -10,11 +10,14 @@ envdir =
|
||
|
|
py37: {toxworkdir}/3.7
|
||
|
|
py38: {toxworkdir}/3.8
|
||
|
|
py39: {toxworkdir}/3.9
|
||
|
|
+ py310: {toxworkdir}/3.10
|
||
|
|
pypy3: {toxworkdir}/pypy3
|
||
|
|
py: {toxworkdir}/py
|
||
|
|
commands =
|
||
|
|
python housekeep.py prep
|
||
|
|
- !diffcov: bandit -c bandit.yml -r aiosmtpd
|
||
|
|
+ # Bandit is not needed on diffcov, and seems to be incompatible with 310
|
||
|
|
+ # So, run only if "not (310 or diffcov)" ==> "(not 310) and (not diffcov)"
|
||
|
|
+ !py310-!diffcov: bandit -c bandit.yml -r aiosmtpd
|
||
|
|
nocov: pytest --verbose -p no:cov --tb=short {posargs}
|
||
|
|
cov: pytest --cov --cov-report=xml --cov-report=html --cov-report=term --tb=short {posargs}
|
||
|
|
diffcov: diff-cover _dump/coverage-{env:INTERP}.xml --html-report htmlcov/diffcov-{env:INTERP}.html
|
||
|
|
@@ -24,6 +27,7 @@ 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]
|
||
|
|
@@ -43,6 +47,7 @@ setenv =
|
||
|
|
py37: INTERP=py37
|
||
|
|
py38: INTERP=py38
|
||
|
|
py39: INTERP=py39
|
||
|
|
+ py310: INTERP=py310
|
||
|
|
pypy3: INTERP=pypy3
|
||
|
|
py: INTERP=py
|
||
|
|
passenv =
|
||
|
|
@@ -51,18 +56,62 @@ passenv =
|
||
|
|
CI
|
||
|
|
GITHUB*
|
||
|
|
|
||
|
|
+[flake8_plugins]
|
||
|
|
+# This is a pseudo-section that feeds into [testenv:qa] and GA
|
||
|
|
+# Snippets of letters above these plugins are tests that need to be "select"-ed in flake8 config (in
|
||
|
|
+# setup.cfg) to activate the respective plugins. If no snippet is given, that means the plugin is
|
||
|
|
+# always active.
|
||
|
|
+deps =
|
||
|
|
+ flake8-bugbear
|
||
|
|
+ flake8-builtins
|
||
|
|
+ flake8-coding
|
||
|
|
+ # C4
|
||
|
|
+ flake8-comprehensions
|
||
|
|
+ # JS
|
||
|
|
+ flake8-multiline-containers
|
||
|
|
+ # PIE
|
||
|
|
+ flake8-pie
|
||
|
|
+ # MOD
|
||
|
|
+ flake8-printf-formatting
|
||
|
|
+ # PT
|
||
|
|
+ flake8-pytest-style
|
||
|
|
+ # SIM
|
||
|
|
+ flake8-simplify
|
||
|
|
+ # Cognitive Complexity looks like a good idea, but to fix the complaints... it will be an epic effort.
|
||
|
|
+ # So we disable it for now and reenable when we're ready, probably just before 2.0
|
||
|
|
+ # # CCR
|
||
|
|
+ # flake8-cognitive-complexity
|
||
|
|
+ # ECE
|
||
|
|
+ flake8-expression-complexity
|
||
|
|
+ # C801
|
||
|
|
+ flake8-copyright
|
||
|
|
+ # DUO
|
||
|
|
+ dlint
|
||
|
|
+ # TAE
|
||
|
|
+ flake8-annotations-complexity
|
||
|
|
+ # TAE
|
||
|
|
+ flake8-annotations-coverage
|
||
|
|
+ # ANN
|
||
|
|
+ flake8-annotations
|
||
|
|
+ # YTT
|
||
|
|
+ flake8-2020
|
||
|
|
+ # N400
|
||
|
|
+ flake8-broken-line
|
||
|
|
+
|
||
|
|
[testenv:qa]
|
||
|
|
basepython = python3
|
||
|
|
envdir = {toxworkdir}/qa
|
||
|
|
commands =
|
||
|
|
python housekeep.py prep
|
||
|
|
+ # The next line lists enabled plugins
|
||
|
|
+ python -m flake8 --version
|
||
|
|
python -m flake8 aiosmtpd setup.py housekeep.py release.py
|
||
|
|
check-manifest -v
|
||
|
|
pytest -v --tb=short aiosmtpd/qa
|
||
|
|
deps =
|
||
|
|
colorama
|
||
|
|
flake8
|
||
|
|
- flake8-bugbear
|
||
|
|
+ {[flake8_plugins]deps}
|
||
|
|
pytest
|
||
|
|
check-manifest
|
||
|
|
|
||
|
|
@@ -79,11 +128,13 @@ deps:
|
||
|
|
# - .github/workflows/unit-testing-and-coverage.yml
|
||
|
|
# - aiosmtpd/docs/RTD-requirements.txt
|
||
|
|
colorama
|
||
|
|
- pytest
|
||
|
|
sphinx
|
||
|
|
sphinx-autofixture
|
||
|
|
sphinx_rtd_theme
|
||
|
|
pickle5 ; python_version < '3.8'
|
||
|
|
+ # The below used as deps, need to be installed so autofixture work properly
|
||
|
|
+ pytest
|
||
|
|
+ pytest-mock
|
||
|
|
|
||
|
|
[testenv:static]
|
||
|
|
basepython = python3
|
||
|
|
--
|
||
|
|
2.32.0
|
||
|
|
|