Skip to content

Commit 29165bd

Browse files
authored
Merge pull request #30 from pytest-dev/use-aiosmtpd/1/dev
Use aiosmtpd for the smtp.Server class
2 parents bebb98b + d204f30 commit 29165bd

File tree

3 files changed

+114
-52
lines changed

3 files changed

+114
-52
lines changed

pytest_localserver/smtp.py

Lines changed: 105 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,44 @@
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
1513

1614

17-
PY35_OR_NEWER = sys.version_info[:2] >= (3, 5)
15+
class MessageDetails:
16+
def __init__(self, peer, mailfrom, rcpttos, *, mail_options=None, rcpt_options=None):
17+
self.peer = peer
18+
self.mailfrom = mailfrom
19+
self.rcpttos = rcpttos
20+
if mail_options:
21+
self.mail_options = mail_options
22+
if rcpt_options:
23+
self.rcpt_options = rcpt_options
1824

19-
class Server (smtpd.SMTPServer, threading.Thread):
25+
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):
2038

2139
"""
22-
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::
2348
2449
server = Server(port=8080)
2550
server.start()
@@ -32,68 +57,97 @@ class Server (smtpd.SMTPServer, threading.Thread):
3257
3358
"""
3459

35-
WAIT_BETWEEN_CHECKS = 0.001
36-
3760
def __init__(self, host='localhost', port=0):
38-
# Workaround for deprecated signature in Python 3.6
39-
if PY35_OR_NEWER:
40-
smtpd.SMTPServer.__init__(self, (host, port), None, decode_data=True)
41-
else:
42-
smtpd.SMTPServer.__init__(self, (host, port), None)
43-
44-
if self._localaddr[1] == 0:
45-
self.addr = self.socket.getsockname()
46-
47-
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)
4866

49-
# initialise thread
50-
self._stopevent = threading.Event()
51-
self.threadName = self.__class__.__name__
52-
threading.Thread.__init__(self, name=self.threadName)
67+
@property
68+
def outbox(self):
69+
return self.handler.outbox
5370

54-
def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
71+
def _set_server_socket_attributes(self):
5572
"""
56-
Adds message to outbox.
73+
Set the addr and port attributes on this Server instance, if they're not
74+
already set.
5775
"""
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()[:2]
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):
58103
try:
59-
message = email.message_from_bytes(data)
104+
return self.server.is_serving()
60105
except AttributeError:
61-
message = email.message_from_string(data)
62-
# on the message, also set the envelope details
63-
64-
class Bunch:
65-
def __init__(self, **kwds):
66-
vars(self).update(kwds)
67-
68-
message.details = Bunch(
69-
peer=peer,
70-
mailfrom=mailfrom,
71-
rcpttos=rcpttos,
72-
**kwargs
73-
)
74-
self.outbox.append(message)
75-
76-
def run(self):
77-
"""
78-
Threads run method.
79-
"""
80-
while not self._stopevent.is_set():
81-
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()
82119

83120
def stop(self, timeout=None):
84121
"""
85122
Stops test server.
86-
87123
:param timeout: When the timeout argument is present and not None, it
88124
should be a floating point number specifying a timeout for the
89125
operation in seconds (or fractions thereof).
90126
"""
91-
self._stopevent.set()
92-
threading.Thread.join(self, timeout)
93-
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
94143

95144
def __del__(self):
96-
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()
97151

98152
def __repr__(self): # pragma: no cover
99153
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=[

tests/test_smtp.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ def test_smtpserver_funcarg(smtpserver):
3939
assert smtpserver.accepting and smtpserver.addr
4040

4141

42+
def test_smtpserver_addr(smtpserver):
43+
host, port = smtpserver.addr
44+
assert isinstance(host, str)
45+
assert isinstance(port, int)
46+
assert port > 0
47+
48+
4249
def test_server_is_killed(smtpserver):
4350
assert smtpserver.is_alive()
4451
smtpserver.stop()

0 commit comments

Comments
 (0)