diff --git a/examples/hello_asgi.py b/examples/hello_asgi.py new file mode 100644 index 0000000..39e1b01 --- /dev/null +++ b/examples/hello_asgi.py @@ -0,0 +1,37 @@ +try: + import uasyncio as asyncio +except ImportError: + import asyncio +from microdot_asgi import Microdot, Response + +app = Microdot() + +htmldoc = ''' + + + Microdot Example Page + + +
+

Microdot Example Page

+

Hello from Microdot!

+

Click to shutdown the server

+
+ + +''' + + +@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) diff --git a/examples/hello_async.py b/examples/hello_async.py index 3aabc55..1ef055d 100644 --- a/examples/hello_async.py +++ b/examples/hello_async.py @@ -24,7 +24,7 @@ htmldoc = ''' @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') diff --git a/src/microdot.py b/src/microdot.py index 2511947..978c7ea 100644 --- a/src/microdot.py +++ b/src/microdot.py @@ -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) diff --git a/src/microdot_asgi.py b/src/microdot_asgi.py new file mode 100644 index 0000000..a9cf0ad --- /dev/null +++ b/src/microdot_asgi.py @@ -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) diff --git a/src/microdot_asyncio.py b/src/microdot_asyncio.py index 6f1be73..c7c48f7 100644 --- a/src/microdot_asyncio.py +++ b/src/microdot_asyncio.py @@ -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) diff --git a/src/microdot_wsgi.py b/src/microdot_wsgi.py index 0d73c1c..d6ffbee 100644 --- a/src/microdot_wsgi.py +++ b/src/microdot_wsgi.py @@ -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, diff --git a/tests/microdot_asgi/test_microdot_asgi.py b/tests/microdot_asgi/test_microdot_asgi.py new file mode 100644 index 0000000..c620065 --- /dev/null +++ b/tests/microdot_asgi/test_microdot_asgi.py @@ -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() diff --git a/tests/microdot_wsgi/test_microdot_wsgi.py b/tests/microdot_wsgi/test_microdot_wsgi.py index c8caf36..645a8ef 100644 --- a/tests/microdot_wsgi/test_microdot_wsgi.py +++ b/tests/microdot_wsgi/test_microdot_wsgi.py @@ -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()