Add ASGI lifespan events (Fixes #322)

This commit is contained in:
Miguel Grinberg
2025-11-23 00:08:29 +00:00
parent ae9f237ce6
commit f128b3ded4
3 changed files with 145 additions and 1 deletions

View File

@@ -722,6 +722,18 @@ runs the web application using the Uvicorn web server::
When using the ASGI support, the ``scope`` dictionary provided by the web When using the ASGI support, the ``scope`` dictionary provided by the web
server is available to request handlers as ``request.asgi_scope``. server is available to request handlers as ``request.asgi_scope``.
The application instance can be initialized with ``lifespan_startup`` and
``lifespan_shutdown`` arguments, which are invoked when the web server sends
the ASGI lifespan signals with the ASGI scope as only argument::
async def startup(scope):
pass
async def shutdown(scope):
pass
app = Microdot(lifespan_startup=startup, lifespan_shutdown=shutdown)
Using a WSGI Web Server Using a WSGI Web Server
^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^

View File

@@ -48,15 +48,47 @@ class Microdot(BaseMicrodot):
"""A subclass of the core :class:`Microdot <microdot.Microdot>` class that """A subclass of the core :class:`Microdot <microdot.Microdot>` class that
implements the ASGI protocol. implements the ASGI protocol.
:param startup: An optional function to handle the `lifespan.startup` ASGI
signal.
:param shutdown: An optional function to handle the `lifespan.shutdown`
ASGI signal.
This class must be used as the application instance when running under an This class must be used as the application instance when running under an
ASGI web server. ASGI web server.
""" """
def __init__(self): def __init__(self, lifespan_startup=None, lifespan_shutdown=None):
super().__init__() super().__init__()
self.lifespan_startup = lifespan_startup
self.lifespan_shutdown = lifespan_shutdown
self.embedded_server = False self.embedded_server = False
async def handle_lifespan(self, scope, receive, send):
while True:
message = await receive()
if message['type'] == 'lifespan.startup':
try:
if self.lifespan_startup:
await self.lifespan_startup(scope)
except Exception as e:
await send({'type': 'lifespan.startup.failed',
'message': repr(e)})
else:
await send({'type': 'lifespan.startup.complete'})
elif message['type'] == 'lifespan.shutdown': # pragma: no branch
try:
if self.lifespan_shutdown:
await self.lifespan_shutdown(scope)
except Exception as e:
await send({'type': 'lifespan.shutdown.failed',
'message': repr(e)})
else:
await send({'type': 'lifespan.shutdown.complete'})
break
async def asgi_app(self, scope, receive, send): async def asgi_app(self, scope, receive, send):
"""An ASGI application.""" """An ASGI application."""
if scope['type'] == 'lifespan':
return await self.handle_lifespan(scope, receive, send)
if scope['type'] not in ['http', 'websocket']: # pragma: no cover if scope['type'] not in ['http', 'websocket']: # pragma: no cover
return return
path = scope['path'] path = scope['path']

View File

@@ -170,3 +170,103 @@ class TestASGI(unittest.TestCase):
self._run(app(scope, receive, send)) self._run(app(scope, receive, send))
kill.assert_called() kill.assert_called()
def test_no_lifespan(self):
app = Microdot()
scope = {
'type': 'lifespan',
'asgi': {'version': '3.0'},
'state': {},
}
messages = [
{'type': 'lifespan.startup'},
{'type': 'lifespan.shutdown'},
]
message_iter = iter(messages)
sends = []
async def receive():
return next(message_iter)
async def send(packet):
sends.append(packet)
self._run(app(scope, receive, send))
self.assertEqual(sends, [
{'type': 'lifespan.startup.complete'},
{'type': 'lifespan.shutdown.complete'},
])
def test_lifespan(self):
async def startup(scope):
scope['state']['foo'] = 'bar'
async def shutdown(scope):
self.assertEqual(scope['state']['foo'], 'bar')
scope['state']['foo'] = 'baz'
app = Microdot(lifespan_startup=startup, lifespan_shutdown=shutdown)
scope = {
'type': 'lifespan',
'asgi': {'version': '3.0'},
'state': {},
}
messages = [
{'type': 'lifespan.startup'},
{'type': 'lifespan.shutdown'},
]
message_iter = iter(messages)
sends = []
async def receive():
return next(message_iter)
async def send(packet):
sends.append(packet)
self._run(app(scope, receive, send))
self.assertEqual(scope['state']['foo'], 'baz')
self.assertEqual(sends, [
{'type': 'lifespan.startup.complete'},
{'type': 'lifespan.shutdown.complete'},
])
def test_lifespan_errors(self):
async def startup(scope):
return scope['scope']['foo'] # KeyError
async def shutdown(scope):
return 1 / 0
app = Microdot(lifespan_startup=startup, lifespan_shutdown=shutdown)
scope = {
'type': 'lifespan',
'asgi': {'version': '3.0'},
'state': {},
}
messages = [
{'type': 'lifespan.startup'},
{'type': 'lifespan.shutdown'},
]
message_iter = iter(messages)
sends = []
async def receive():
return next(message_iter)
async def send(packet):
sends.append(packet)
self._run(app(scope, receive, send))
self.assertEqual(sends, [
{'type': 'lifespan.startup.failed',
'message': "KeyError('scope')"},
{'type': 'lifespan.shutdown.failed',
'message': "ZeroDivisionError('division by zero')"},
])