ASGI support
This commit is contained in:
37
examples/hello_asgi.py
Normal file
37
examples/hello_asgi.py
Normal 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)
|
||||
@@ -24,7 +24,7 @@ htmldoc = '''<!DOCTYPE html>
|
||||
|
||||
@app.route('/')
|
||||
async def hello(request):
|
||||
return Response(body=htmldoc, headers={'Content-Type': 'text/html'})
|
||||
return htmldoc, 200, {'Content-Type': 'text/html'}
|
||||
|
||||
|
||||
@app.route('/shutdown')
|
||||
|
||||
@@ -440,6 +440,8 @@ class Response():
|
||||
stream.write('{header}: {value}\r\n'.format(
|
||||
header=header, value=value).encode())
|
||||
stream.write(b'\r\n')
|
||||
|
||||
# body
|
||||
for body in self.body_iter():
|
||||
stream.write(body)
|
||||
|
||||
|
||||
126
src/microdot_asgi.py
Normal file
126
src/microdot_asgi.py
Normal 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)
|
||||
@@ -133,18 +133,27 @@ class Response(BaseResponse):
|
||||
await stream.awrite(b'\r\n')
|
||||
|
||||
# body
|
||||
async for body in self.body_iter():
|
||||
await stream.awrite(body)
|
||||
|
||||
async def body_iter(self):
|
||||
if self.body:
|
||||
if hasattr(self.body, 'read'):
|
||||
while True:
|
||||
buf = self.body.read(self.send_file_buffer_size)
|
||||
if _iscoroutine(buf): # pragma: no cover
|
||||
buf = await 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:
|
||||
break
|
||||
if hasattr(self.body, 'close'): # pragma: no cover
|
||||
self.body.close()
|
||||
result = self.body.close()
|
||||
if _iscoroutine(result):
|
||||
await result
|
||||
else:
|
||||
await stream.awrite(self.body)
|
||||
yield self.body
|
||||
|
||||
|
||||
class Microdot(BaseMicrodot):
|
||||
@@ -201,7 +210,7 @@ class Microdot(BaseMicrodot):
|
||||
writer.awrite = MethodType(awrite, writer)
|
||||
writer.aclose = MethodType(aclose, writer)
|
||||
|
||||
await self.dispatch_request(reader, writer)
|
||||
await self.handle_request(reader, writer)
|
||||
|
||||
if self.debug: # pragma: no cover
|
||||
print('Starting async server on {host}:{port}...'.format(
|
||||
@@ -251,13 +260,23 @@ class Microdot(BaseMicrodot):
|
||||
def shutdown(self):
|
||||
self.server.close()
|
||||
|
||||
async def dispatch_request(self, reader, writer):
|
||||
async def handle_request(self, reader, writer):
|
||||
req = None
|
||||
try:
|
||||
req = await Request.create(self, reader,
|
||||
writer.get_extra_info('peername'))
|
||||
except Exception as exc: # pragma: no cover
|
||||
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.content_length > req.max_content_length:
|
||||
if 413 in self.error_handlers:
|
||||
@@ -310,12 +329,7 @@ class Microdot(BaseMicrodot):
|
||||
res = Response(*res)
|
||||
elif not isinstance(res, Response):
|
||||
res = Response(res)
|
||||
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))
|
||||
return res
|
||||
|
||||
async def _invoke_handler(self, f_or_coro, *args, **kwargs):
|
||||
ret = f_or_coro(*args, **kwargs)
|
||||
|
||||
@@ -6,32 +6,29 @@ from microdot import Microdot as BaseMicrodot
|
||||
from microdot import Request
|
||||
|
||||
|
||||
class WSGIRequest(Request):
|
||||
def __init__(self, app, environ):
|
||||
url = environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '')
|
||||
class Microdot(BaseMicrodot):
|
||||
def wsgi_app(self, environ, start_response):
|
||||
path = environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '')
|
||||
if 'QUERY_STRING' in environ and environ['QUERY_STRING']:
|
||||
url += '?' + environ['QUERY_STRING']
|
||||
path += '?' + environ['QUERY_STRING']
|
||||
headers = {}
|
||||
for k, v in environ.items():
|
||||
if k.startswith('HTTP_'):
|
||||
h = '-'.join([p.title() for p in k[5:].split('_')])
|
||||
headers[h] = v
|
||||
super().__init__(
|
||||
app,
|
||||
req = Request(
|
||||
self,
|
||||
(environ['REMOTE_ADDR'], int(environ.get('REMOTE_PORT', '0'))),
|
||||
environ['REQUEST_METHOD'],
|
||||
url,
|
||||
path,
|
||||
environ['SERVER_PROTOCOL'],
|
||||
headers,
|
||||
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.complete()
|
||||
|
||||
reason = res.reason or ('OK' if res.status_code == 200 else 'N/A')
|
||||
start_response(
|
||||
str(res.status_code) + ' ' + reason,
|
||||
|
||||
133
tests/microdot_asgi/test_microdot_asgi.py
Normal file
133
tests/microdot_asgi/test_microdot_asgi.py
Normal 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()
|
||||
@@ -11,13 +11,27 @@ try:
|
||||
except ImportError:
|
||||
mock = None
|
||||
|
||||
from microdot_wsgi import Microdot, WSGIRequest
|
||||
from microdot_wsgi import Microdot
|
||||
|
||||
|
||||
@unittest.skipIf(sys.implementation.name == 'micropython',
|
||||
'not supported under MicroPython')
|
||||
class TestMicrodotWSGI(unittest.TestCase):
|
||||
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 = {
|
||||
'SCRIPT_NAME': '/foo',
|
||||
'PATH_INFO': '/bar',
|
||||
@@ -31,18 +45,25 @@ class TestMicrodotWSGI(unittest.TestCase):
|
||||
'SERVER_PROTOCOL': 'HTTP/1.1',
|
||||
'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 = {
|
||||
'SCRIPT_NAME': '/foo',
|
||||
'PATH_INFO': '/bar',
|
||||
@@ -52,37 +73,11 @@ class TestMicrodotWSGI(unittest.TestCase):
|
||||
'SERVER_PROTOCOL': 'HTTP/1.1',
|
||||
'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):
|
||||
self.assertEqual(status, '200 OK')
|
||||
self.assertEqual(headers, [('Content-Length', '3'),
|
||||
('Content-Type', 'text/plain')])
|
||||
pass
|
||||
|
||||
res = app(environ, start_response)
|
||||
body = b''
|
||||
for b in res:
|
||||
body += b
|
||||
self.assertEqual(body, b'bar')
|
||||
app(environ, start_response)
|
||||
|
||||
def test_shutdown(self):
|
||||
app = Microdot()
|
||||
|
||||
Reference in New Issue
Block a user