Skip to content

Commit 90d6c23

Browse files
committed
Rewrite smtp.Server to be based on aiosmtpd
In this commit I've completely rewritten the smtp.Server class using aiosmtpd instead of Python's standard smtpd module, which is deprecated and scheduled for removal in Python 3.12. Migrating the main functionality to aiosmtpd is intrinsically not hard; it just required creating the Handler class, which is about ten lines and can be used as-is with aiosmtpd.controller.Controller. The rest of the changes here are to reproduce the interface of the existing Server class well enough to pass the tests. We've been testing some features of the Server class which don't exactly have equivalents in aiosmtpd, so I had to use some wrappers and hacks to offer the same interface: for example, I reimplemented the Server.stop() method to make it idempotent and accept a timeout argument, and I had to use some private attributes of Controller to make Server.is_alive() and Server.accepting behave the way they used to. I also added some additional hacks to support older versions of aiosmtpd that work with Python 3.5. In retrospect, that probably wasn't worth it, but I started trying to do it without realizing how much work it'd be, and once the work was done, I figured I might as well keep it. We should definitely drop support for Python 3.5 soon, though, and that will simplify the implementation.
1 parent c77c578 commit 90d6c23

File tree

2 files changed

+97
-50
lines changed

2 files changed

+97
-50
lines changed

pytest_localserver/smtp.py

Lines changed: 95 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,9 @@
77
# Written by Adam Feuer, Matt Branthwaite, and Troy Frever
88
# which is Licensed under the PSF License
99

10-
import asyncore
10+
import aiosmtpd.controller
1111
import email
12-
import smtpd
1312
import sys
14-
import threading
15-
16-
17-
PY35_OR_NEWER = sys.version_info[:2] >= (3, 5)
1813

1914

2015
class MessageDetails:
@@ -28,10 +23,28 @@ def __init__(self, peer, mailfrom, rcpttos, *, mail_options=None, rcpt_options=N
2823
self.rcpt_options = rcpt_options
2924

3025

31-
class Server (smtpd.SMTPServer, threading.Thread):
26+
class Handler:
27+
def __init__(self):
28+
self.outbox = []
29+
30+
async def handle_DATA(self, server, session, envelope):
31+
message = email.message_from_bytes(envelope.content)
32+
message.details = MessageDetails(session.peer, envelope.mail_from, envelope.rcpt_tos)
33+
self.outbox.append(message)
34+
return '250 OK'
35+
36+
37+
class Server(aiosmtpd.controller.Controller):
3238

3339
"""
34-
Small SMTP test server. Try the following snippet for sending mail::
40+
Small SMTP test server.
41+
42+
This is little more than a wrapper around aiosmtpd.controller.Controller
43+
which offers a slightly different interface for backward compatibility with
44+
earlier versions of pytest-localserver. You can just as well use a standard
45+
Controller and pass it a Handler instance.
46+
47+
Here is how to use this class for sending an email, if you really need to::
3548
3649
server = Server(port=8080)
3750
server.start()
@@ -44,64 +57,97 @@ class Server (smtpd.SMTPServer, threading.Thread):
4457
4558
"""
4659

47-
WAIT_BETWEEN_CHECKS = 0.001
48-
4960
def __init__(self, host='localhost', port=0):
50-
# Workaround for deprecated signature in Python 3.6
51-
if PY35_OR_NEWER:
52-
smtpd.SMTPServer.__init__(self, (host, port), None, decode_data=True)
53-
else:
54-
smtpd.SMTPServer.__init__(self, (host, port), None)
55-
56-
if self._localaddr[1] == 0:
57-
self.addr = self.socket.getsockname()
58-
59-
self.outbox = []
61+
try:
62+
super().__init__(Handler(), hostname=host, port=port, server_hostname=host)
63+
except TypeError:
64+
# for aiosmtpd <1.3
65+
super().__init__(Handler(), hostname=host, port=port)
6066

61-
# initialise thread
62-
self._stopevent = threading.Event()
63-
self.threadName = self.__class__.__name__
64-
threading.Thread.__init__(self, name=self.threadName)
67+
@property
68+
def outbox(self):
69+
return self.handler.outbox
6570

66-
def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
71+
def _set_server_socket_attributes(self):
6772
"""
68-
Adds message to outbox.
73+
Set the addr and port attributes on this Server instance, if they're not
74+
already set.
6975
"""
76+
77+
# I split this out into its own method to allow running this code in
78+
# aiosmtpd <1.4, which doesn't have the _trigger_server() method on
79+
# the Controller class. If I put it directly in _trigger_server(), it
80+
# would fail when calling super()._trigger_server(). In the future, when
81+
# we can safely require aiosmtpd >=1.4, this method can be inlined
82+
# directly into _trigger_server().
83+
if hasattr(self, 'addr'):
84+
assert hasattr(self, 'port')
85+
return
86+
87+
self.addr = self.server.sockets[0].getsockname()
88+
89+
# Work around a bug/missing feature in aiosmtpd (https://github.com/aio-libs/aiosmtpd/issues/276)
90+
if self.port == 0:
91+
self.port = self.addr[1]
92+
assert self.port != 0
93+
94+
def _trigger_server(self):
95+
self._set_server_socket_attributes()
96+
super()._trigger_server()
97+
98+
def is_alive(self):
99+
return self._thread is not None and self._thread.is_alive()
100+
101+
@property
102+
def accepting(self):
70103
try:
71-
message = email.message_from_bytes(data)
104+
return self.server.is_serving()
72105
except AttributeError:
73-
message = email.message_from_string(data)
74-
# on the message, also set the envelope details
75-
76-
message.details = MessageDetails(
77-
peer=peer,
78-
mailfrom=mailfrom,
79-
rcpttos=rcpttos,
80-
**kwargs
81-
)
82-
self.outbox.append(message)
83-
84-
def run(self):
85-
"""
86-
Threads run method.
87-
"""
88-
while not self._stopevent.is_set():
89-
asyncore.loop(timeout=self.WAIT_BETWEEN_CHECKS, count=1)
106+
# asyncio.base_events.Server.is_serving() only exists in Python 3.6
107+
# and up. For Python 3.5, asyncio.base_events.BaseEventLoop.is_running()
108+
# is a close approximation; it should mostly return the same value
109+
# except for brief periods when the server is starting up or shutting
110+
# down. Once we drop support for Python 3.5, this branch becomes
111+
# unnecessary.
112+
return self.loop.is_running()
113+
114+
# for aiosmtpd <1.4
115+
if not hasattr(aiosmtpd.controller.Controller, '_trigger_server'):
116+
def start(self):
117+
super().start()
118+
self._set_server_socket_attributes()
90119

91120
def stop(self, timeout=None):
92121
"""
93122
Stops test server.
94-
95123
:param timeout: When the timeout argument is present and not None, it
96124
should be a floating point number specifying a timeout for the
97125
operation in seconds (or fractions thereof).
98126
"""
99-
self._stopevent.set()
100-
threading.Thread.join(self, timeout)
101-
self.close()
127+
128+
# This mostly copies the implementation from Controller.stop(), with two
129+
# differences:
130+
# - It removes the assertion that the thread exists, allowing stop() to
131+
# be called more than once safely
132+
# - It passes the timeout argument to Thread.join()
133+
if self.loop.is_running():
134+
self.loop.call_soon_threadsafe(self._stop)
135+
if self._thread is not None:
136+
self._thread.join(timeout)
137+
self._thread = None
138+
self._thread_exception = None
139+
self._factory_invoked = None
140+
self.server_coro = None
141+
self.server = None
142+
self.smtpd = None
102143

103144
def __del__(self):
104-
self.stop()
145+
# This is just for backward compatibility, to preserve the behavior that
146+
# the server is stopped when this object is finalized. But it seems
147+
# sketchy to rely on this to stop the server. Typically, the server
148+
# should be stopped "manually", before it gets deleted.
149+
if self.is_alive():
150+
self.stop()
105151

106152
def __repr__(self): # pragma: no cover
107153
return '<smtp.Server %s:%s>' % self.addr

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ def run(self):
3434
packages=['pytest_localserver'],
3535
python_requires='>=3.5',
3636
install_requires=[
37-
'werkzeug>=0.10'
37+
'werkzeug>=0.10',
38+
'aiosmtpd'
3839
],
3940
cmdclass={'test': PyTest},
4041
tests_require=[

0 commit comments

Comments
 (0)