ASGI support

This commit is contained in:
Miguel Grinberg
2022-05-25 23:47:37 +01:00
parent 1ae51ccdf7
commit 7e8ecb1997
8 changed files with 368 additions and 64 deletions

37
examples/hello_asgi.py Normal file
View File

@@ -0,0 +1,37 @@
try:
import uasyncio as asyncio
except ImportError:
import asyncio
from microdot_asgi import Microdot, Response
app = Microdot()
htmldoc = '''<!DOCTYPE html>
<html>
<head>
<title>Microdot Example Page</title>
</head>
<body>
<div>
<h1>Microdot Example Page</h1>
<p>Hello from Microdot!</p>
<p><a href="/shutdown">Click to shutdown the server</a></p>
</div>
</body>
</html>
'''
@app.route('/')
async def hello(request):
return htmldoc, 200, {'Content-Type': 'text/html'}
@app.route('/shutdown')
async def shutdown(request):
request.app.shutdown()
return 'The server is shutting down...'
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -24,7 +24,7 @@ htmldoc = '''<!DOCTYPE html>
@app.route('/') @app.route('/')
async def hello(request): async def hello(request):
return Response(body=htmldoc, headers={'Content-Type': 'text/html'}) return htmldoc, 200, {'Content-Type': 'text/html'}
@app.route('/shutdown') @app.route('/shutdown')

View File

@@ -440,6 +440,8 @@ class Response():
stream.write('{header}: {value}\r\n'.format( stream.write('{header}: {value}\r\n'.format(
header=header, value=value).encode()) header=header, value=value).encode())
stream.write(b'\r\n') stream.write(b'\r\n')
# body
for body in self.body_iter(): for body in self.body_iter():
stream.write(body) stream.write(body)

126
src/microdot_asgi.py Normal file
View File

@@ -0,0 +1,126 @@
import logging
import os
import signal
from microdot_asyncio import * # noqa: F401, F403
from microdot_asyncio import Microdot as BaseMicrodot
from microdot_asyncio import Request
class _BodyStream: # pragma: no cover
def __init__(self, receive):
self.receive = receive
self.data = b''
self.more = True
async def read_more(self):
if self.more:
packet = await self.receive()
self.data += packet.get('body', b'')
self.more = packet.get('more_body', False)
async def read(self, n=-1):
while self.more and len(self.data) < n:
self.read_more()
if len(self.data) < n:
data = self.data
self.data = b''
return data
data = self.data[:n]
self.data = self.data[n:]
return data
async def readline(self):
return self.readuntil()
async def readexactly(self, n):
return self.read(n)
async def readuntil(self, separator=b'\n'):
if self.more and separator not in self.data:
self.read_more()
data, self.data = self.data.split(separator, 1)
return data
class Microdot(BaseMicrodot):
async def asgi_app(self, scope, receive, send):
if scope['type'] != 'http': # pragma: no cover
return
path = scope['path']
if 'query_string' in scope and scope['query_string']:
path += '?' + scope['query_string'].decode()
headers = {}
content_length = 0
for key, value in scope.get('headers', []):
headers[key] = value
if key.lower() == 'content-length':
content_length = int(value)
body = b''
if content_length and content_length <= Request.max_body_length:
body = b''
more = True
while more:
packet = await receive()
body += packet.get('body', b'')
more = packet.get('more_body', False)
stream = None
else:
body = b''
stream = _BodyStream(receive)
req = Request(
self,
(scope['client'][0], scope['client'][1]),
scope['method'],
path,
'HTTP/' + scope['http_version'],
headers,
body=body,
stream=stream)
req.asgi_scope = scope
res = await self.dispatch_request(req)
res.complete()
await send({'type': 'http.response.start',
'status': res.status_code,
'headers': [(name, value)
for name, value in res.headers.items()]})
body_iter = res.body_iter()
body = b''
try:
body += await body_iter.__anext__()
while True:
next_body = await body_iter.__anext__()
await send({'type': 'http.response.body',
'body': body,
'more_body': True})
body = next_body
except StopAsyncIteration:
await send({'type': 'http.response.body',
'body': body,
'more_body': False})
async def __call__(self, scope, receive, send):
return await self.asgi_app(scope, receive, send)
def shutdown(self):
pid = os.getpgrp() if hasattr(os, 'getpgrp') else os.getpid()
os.kill(pid, signal.SIGTERM)
def run(self, host='0.0.0.0', port=5000, debug=False,
**options): # pragma: no cover
try:
from waitress import serve
except ImportError: # pragma: no cover
raise RuntimeError('The run() method requires Waitress to be '
'installed (i.e. run "pip install waitress").')
self.debug = debug
if debug:
logger = logging.getLogger('waitress')
logger.setLevel(logging.INFO)
serve(self, host=host, port=port, **options)

View File

@@ -133,18 +133,27 @@ class Response(BaseResponse):
await stream.awrite(b'\r\n') await stream.awrite(b'\r\n')
# body # body
async for body in self.body_iter():
await stream.awrite(body)
async def body_iter(self):
if self.body: if self.body:
if hasattr(self.body, 'read'): if hasattr(self.body, 'read'):
while True: while True:
buf = self.body.read(self.send_file_buffer_size) buf = self.body.read(self.send_file_buffer_size)
if _iscoroutine(buf): # pragma: no cover
buf = await buf
if len(buf): if len(buf):
await stream.awrite(buf) print('*', buf, self.send_file_buffer_size)
yield buf
if len(buf) < self.send_file_buffer_size: if len(buf) < self.send_file_buffer_size:
break break
if hasattr(self.body, 'close'): # pragma: no cover if hasattr(self.body, 'close'): # pragma: no cover
self.body.close() result = self.body.close()
if _iscoroutine(result):
await result
else: else:
await stream.awrite(self.body) yield self.body
class Microdot(BaseMicrodot): class Microdot(BaseMicrodot):
@@ -201,7 +210,7 @@ class Microdot(BaseMicrodot):
writer.awrite = MethodType(awrite, writer) writer.awrite = MethodType(awrite, writer)
writer.aclose = MethodType(aclose, writer) writer.aclose = MethodType(aclose, writer)
await self.dispatch_request(reader, writer) await self.handle_request(reader, writer)
if self.debug: # pragma: no cover if self.debug: # pragma: no cover
print('Starting async server on {host}:{port}...'.format( print('Starting async server on {host}:{port}...'.format(
@@ -251,13 +260,23 @@ class Microdot(BaseMicrodot):
def shutdown(self): def shutdown(self):
self.server.close() self.server.close()
async def dispatch_request(self, reader, writer): async def handle_request(self, reader, writer):
req = None req = None
try: try:
req = await Request.create(self, reader, req = await Request.create(self, reader,
writer.get_extra_info('peername')) writer.get_extra_info('peername'))
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
print_exception(exc) print_exception(exc)
res = await self.dispatch_request(req)
await res.write(writer)
await writer.aclose()
if self.debug and req: # pragma: no cover
print('{method} {path} {status_code}'.format(
method=req.method, path=req.path,
status_code=res.status_code))
async def dispatch_request(self, req):
if req: if req:
if req.content_length > req.max_content_length: if req.content_length > req.max_content_length:
if 413 in self.error_handlers: if 413 in self.error_handlers:
@@ -310,12 +329,7 @@ class Microdot(BaseMicrodot):
res = Response(*res) res = Response(*res)
elif not isinstance(res, Response): elif not isinstance(res, Response):
res = Response(res) res = Response(res)
await res.write(writer) return res
await writer.aclose()
if self.debug and req: # pragma: no cover
print('{method} {path} {status_code}'.format(
method=req.method, path=req.path,
status_code=res.status_code))
async def _invoke_handler(self, f_or_coro, *args, **kwargs): async def _invoke_handler(self, f_or_coro, *args, **kwargs):
ret = f_or_coro(*args, **kwargs) ret = f_or_coro(*args, **kwargs)

View File

@@ -6,32 +6,29 @@ from microdot import Microdot as BaseMicrodot
from microdot import Request from microdot import Request
class WSGIRequest(Request): class Microdot(BaseMicrodot):
def __init__(self, app, environ): def wsgi_app(self, environ, start_response):
url = environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '') path = environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '')
if 'QUERY_STRING' in environ and environ['QUERY_STRING']: if 'QUERY_STRING' in environ and environ['QUERY_STRING']:
url += '?' + environ['QUERY_STRING'] path += '?' + environ['QUERY_STRING']
headers = {} headers = {}
for k, v in environ.items(): for k, v in environ.items():
if k.startswith('HTTP_'): if k.startswith('HTTP_'):
h = '-'.join([p.title() for p in k[5:].split('_')]) h = '-'.join([p.title() for p in k[5:].split('_')])
headers[h] = v headers[h] = v
super().__init__( req = Request(
app, self,
(environ['REMOTE_ADDR'], int(environ.get('REMOTE_PORT', '0'))), (environ['REMOTE_ADDR'], int(environ.get('REMOTE_PORT', '0'))),
environ['REQUEST_METHOD'], environ['REQUEST_METHOD'],
url, path,
environ['SERVER_PROTOCOL'], environ['SERVER_PROTOCOL'],
headers, headers,
stream=environ['wsgi.input']) stream=environ['wsgi.input'])
self.environ = environ req.environ = environ
class Microdot(BaseMicrodot):
def wsgi_app(self, environ, start_response):
req = WSGIRequest(self, environ)
res = self.dispatch_request(req) res = self.dispatch_request(req)
res.complete() res.complete()
reason = res.reason or ('OK' if res.status_code == 200 else 'N/A') reason = res.reason or ('OK' if res.status_code == 200 else 'N/A')
start_response( start_response(
str(res.status_code) + ' ' + reason, str(res.status_code) + ' ' + reason,

View File

@@ -0,0 +1,133 @@
import unittest
import sys
try:
from unittest import mock
except ImportError:
mock = None
from microdot_asgi import Microdot, Response
from tests import mock_asyncio
@unittest.skipIf(sys.implementation.name == 'micropython',
'not supported under MicroPython')
class TestMicrodotASGI(unittest.TestCase):
def test_asgi_request_with_query_string(self):
app = Microdot()
@app.post('/foo/bar')
async def index(req):
self.assertEqual(req.app, app)
self.assertEqual(req.client_addr, ('1.2.3.4', 1234))
self.assertEqual(req.method, 'POST')
self.assertEqual(req.http_version, 'HTTP/1.1')
self.assertEqual(req.path, '/foo/bar')
self.assertEqual(req.args, {'baz': ['1']})
self.assertEqual(req.cookies, {'session': 'xyz'})
self.assertEqual(req.body, b'body')
class R:
def __init__(self):
self.i = 0
self.body = [b're', b'sp', b'on', b'se', b'']
async def read(self, n):
data = self.body[self.i]
self.i += 1
return data
return Response(body=R(), headers={'Content-Length': '8'})
scope = {
'type': 'http',
'path': '/foo/bar',
'query_string': b'baz=1',
'headers': [('Authorization', 'Bearer 123'),
('Cookie', 'session=xyz'),
('Content-Length', 4)],
'client': ['1.2.3.4', 1234],
'method': 'POST',
'http_version': '1.1',
}
async def receive():
return {
'type': 'http.request',
'body': b'body',
'more_body': False,
}
async def send(packet):
if packet['type'] == 'http.response.start':
self.assertEqual(packet['status'], 200)
self.assertEqual(
packet['headers'],
[('Content-Length', '8'), ('Content-Type', 'text/plain')])
elif packet['type'] == 'http.response.body':
self.assertIn(packet['body'], [b're', b'sp', b'on', b'se'])
original_buffer_size = Response.send_file_buffer_size
Response.send_file_buffer_size = 2
mock_asyncio.run(app(scope, receive, send))
Response.send_file_buffer_size = original_buffer_size
def test_wsgi_request_without_query_string(self):
app = Microdot()
@app.route('/foo/bar')
async def index(req):
self.assertEqual(req.path, '/foo/bar')
self.assertEqual(req.args, {})
return 'response'
scope = {
'type': 'http',
'path': '/foo/bar',
'headers': [('Authorization', 'Bearer 123'),
('Cookie', 'session=xyz'),
('Content-Length', 4)],
'client': ['1.2.3.4', 1234],
'method': 'POST',
'http_version': '1.1',
}
async def receive():
return {
'type': 'http.request',
'body': b'body',
'more_body': False,
}
async def send(packet):
pass
mock_asyncio.run(app(scope, receive, send))
def test_shutdown(self):
app = Microdot()
@app.route('/shutdown')
async def shutdown(request):
request.app.shutdown()
scope = {
'type': 'http',
'path': '/shutdown',
'client': ['1.2.3.4', 1234],
'method': 'GET',
'http_version': '1.1',
}
async def receive():
pass
async def send(packet):
pass
with mock.patch('microdot_asgi.os.kill') as kill:
mock_asyncio.run(app(scope, receive, send))
kill.assert_called()

View File

@@ -11,13 +11,27 @@ try:
except ImportError: except ImportError:
mock = None mock = None
from microdot_wsgi import Microdot, WSGIRequest from microdot_wsgi import Microdot
@unittest.skipIf(sys.implementation.name == 'micropython', @unittest.skipIf(sys.implementation.name == 'micropython',
'not supported under MicroPython') 'not supported under MicroPython')
class TestMicrodotWSGI(unittest.TestCase): class TestMicrodotWSGI(unittest.TestCase):
def test_wsgi_request_with_query_string(self): def test_wsgi_request_with_query_string(self):
app = Microdot()
@app.post('/foo/bar')
def index(req):
self.assertEqual(req.app, app)
self.assertEqual(req.client_addr, ('1.2.3.4', 1234))
self.assertEqual(req.method, 'POST')
self.assertEqual(req.http_version, 'HTTP/1.1')
self.assertEqual(req.path, '/foo/bar')
self.assertEqual(req.args, {'baz': ['1']})
self.assertEqual(req.cookies, {'session': 'xyz'})
self.assertEqual(req.body, b'body')
return 'response'
environ = { environ = {
'SCRIPT_NAME': '/foo', 'SCRIPT_NAME': '/foo',
'PATH_INFO': '/bar', 'PATH_INFO': '/bar',
@@ -31,18 +45,25 @@ class TestMicrodotWSGI(unittest.TestCase):
'SERVER_PROTOCOL': 'HTTP/1.1', 'SERVER_PROTOCOL': 'HTTP/1.1',
'wsgi.input': io.BytesIO(b'body'), 'wsgi.input': io.BytesIO(b'body'),
} }
app = Microdot()
req = WSGIRequest(app, environ)
self.assertEqual(req.app, app)
self.assertEqual(req.client_addr, ('1.2.3.4', 1234))
self.assertEqual(req.method, 'POST')
self.assertEqual(req.http_version, 'HTTP/1.1')
self.assertEqual(req.path, '/foo/bar')
self.assertEqual(req.args, {'baz': ['1']})
self.assertEqual(req.cookies, {'session': 'xyz'})
self.assertEqual(req.body, b'body')
def test_wsgi_request_withiout_query_string(self): def start_response(status, headers):
self.assertEqual(status, '200 OK')
self.assertEqual(
headers,
[('Content-Length', '8'), ('Content-Type', 'text/plain')])
r = app(environ, start_response)
self.assertEqual(next(r), b'response')
def test_wsgi_request_without_query_string(self):
app = Microdot()
@app.route('/foo/bar')
def index(req):
self.assertEqual(req.path, '/foo/bar')
self.assertEqual(req.args, {})
return 'response'
environ = { environ = {
'SCRIPT_NAME': '/foo', 'SCRIPT_NAME': '/foo',
'PATH_INFO': '/bar', 'PATH_INFO': '/bar',
@@ -52,37 +73,11 @@ class TestMicrodotWSGI(unittest.TestCase):
'SERVER_PROTOCOL': 'HTTP/1.1', 'SERVER_PROTOCOL': 'HTTP/1.1',
'wsgi.input': io.BytesIO(b''), 'wsgi.input': io.BytesIO(b''),
} }
app = Microdot()
req = WSGIRequest(app, environ)
self.assertEqual(req.path, '/foo/bar')
self.assertEqual(req.args, {})
def test_wsgi_app(self):
app = Microdot()
@app.route('/foo')
def foo(request):
return 'bar'
environ = {
'PATH_INFO': '/foo',
'REMOTE_ADDR': '1.2.3.4',
'REMOTE_PORT': '1234',
'REQUEST_METHOD': 'GET',
'SERVER_PROTOCOL': 'HTTP/1.1',
'wsgi.input': io.BytesIO(b''),
}
def start_response(status, headers): def start_response(status, headers):
self.assertEqual(status, '200 OK') pass
self.assertEqual(headers, [('Content-Length', '3'),
('Content-Type', 'text/plain')])
res = app(environ, start_response) app(environ, start_response)
body = b''
for b in res:
body += b
self.assertEqual(body, b'bar')
def test_shutdown(self): def test_shutdown(self):
app = Microdot() app = Microdot()