14 Commits

Author SHA1 Message Date
Miguel Grinberg
aac022ba43 Release 0.9.0 2022-06-04 16:48:03 +01:00
Miguel Grinberg
c18ccccb8e Run linter on examples 2022-06-04 16:41:40 +01:00
Miguel Grinberg
bcbad51675 Documentation updates 2022-06-04 16:41:02 +01:00
Miguel Grinberg
d71665fd38 Stream responses (Fixes #44) 2022-06-04 15:56:13 +01:00
Miguel Grinberg
4182ba6380 Uvicorn support for ASGI implementation 2022-06-04 15:08:30 +01:00
Miguel Grinberg
5b5eb907d8 Add Python 3.10 to build 2022-05-26 10:50:55 +01:00
Miguel Grinberg
71009b4978 Return 204 when view function returns None 2022-05-26 10:50:55 +01:00
Miguel Grinberg
35c72125a0 Make body_iter async generator compatible with MicroPython 2022-05-26 10:50:55 +01:00
Miguel Grinberg
7e8ecb1997 ASGI support 2022-05-25 23:47:37 +01:00
Miguel Grinberg
1ae51ccdf7 WSGI support 2022-05-25 00:31:18 +01:00
Miguel Grinberg
0ca1e01e00 Version 0.8.3.dev0 2022-04-20 10:15:24 +01:00
Miguel Grinberg
5f7efcc3f8 Release 0.8.2 2022-04-20 10:15:17 +01:00
Mark Blakeney
0f278321c8 Remove stray/debug remnant print() (#38) 2022-04-20 10:13:38 +01:00
Miguel Grinberg
acf20cc20c Version 0.8.2.dev0 2022-03-18 23:51:38 +00:00
24 changed files with 882 additions and 52 deletions

View File

@@ -21,7 +21,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ['3.6', '3.7', '3.8', '3.9']
python: ['3.6', '3.7', '3.8', '3.9', '3.10']
fail-fast: false
runs-on: ${{ matrix.os }}
steps:

View File

@@ -1,5 +1,19 @@
# Microdot change log
**Release 0.9.0** - 2022-06-04
- Streaming responses [#44](https://github.com/miguelgrinberg/microdot/issues/44) ([commit](https://github.com/miguelgrinberg/microdot/commit/d71665fd388c92a50198faf0d761235f0138797a))
- Return 204 when view function returns None ([commit](https://github.com/miguelgrinberg/microdot/commit/71009b49781ce356155df661a66dc98170f35d63))
- ASGI support ([commit](https://github.com/miguelgrinberg/microdot/commit/7e8ecb199717dd90c6cb374cb0d24b54dd6ea33e))
- WSGI support ([commit](https://github.com/miguelgrinberg/microdot/commit/1ae51ccdf75991a2958b06f7a3439d64f92f1b69))
- Documentation updates ([commit](https://github.com/miguelgrinberg/microdot/commit/bcbad516751f1ea9928f4a6d0e8843a4334b885a))
- Add Python 3.10 to build ([commit](https://github.com/miguelgrinberg/microdot/commit/5b5eb907d83d94dde544b266e6659071e4d47ee1))
- Run linter on examples ([commit](https://github.com/miguelgrinberg/microdot/commit/c18ccccb8e0744d8670433aeeba068c5654f32df))
**Release 0.8.2** - 2022-04-20
- Remove debugging print statement [#38](https://github.com/miguelgrinberg/microdot/issues/38) ([commit](https://github.com/miguelgrinberg/microdot/commit/0f278321c8bd65c5cb67425eb837e6581cbb0054)) (thanks **Mark Blakeney**!)
**Release 0.8.1** - 2022-03-18
- Optimizations for request streams and bodies ([commit](https://github.com/miguelgrinberg/microdot/commit/29a9f6f46c737aa0fd452766c23bd83008594ac4))

View File

@@ -60,3 +60,31 @@ and coroutines.
:inherited-members:
:members:
``microdot_wsgi`` module
------------------------
The ``microdot_wsgi`` module provides an extended ``Microdot`` class that
implements the WSGI protocol and can be used with a complaint WSGI web server
such as `Gunicorn <https://gunicorn.org/>`_ or
`uWSGI <https://uwsgi-docs.readthedocs.io/en/latest/>`_.
``Microdot`` class
~~~~~~~~~~~~~~~~~~
.. autoclass:: microdot_wsgi.Microdot
:members:
:exclude-members: shutdown, run
``microdot_asgi`` module
------------------------
The ``microdot_asgi`` module provides an extended ``Microdot`` class that
implements the ASGI protocol and can be used with a complaint ASGI server such
as `Uvicorn <https://www.uvicorn.org/>`_.
``Microdot`` class
~~~~~~~~~~~~~~~~~~
.. autoclass:: microdot_asgi.Microdot
:members:
:exclude-members: shutdown, run

View File

@@ -5,8 +5,12 @@ Microdot can be installed with ``pip``::
pip install microdot
For platforms that do not support or cannot run ``pip``, you can also manually
copy and install the ``microdot.py`` and ``microdot_asyncio.py`` source files.
For MicroPython, you can install with ``upip``.
On platforms where ``pip`` or ``upip`` are not viable options, you can manually
copy and install the ``microdot.py`` and ``microdot_asyncio.py`` source files
from the `GitHub reposutory <https://github.com/miguelgrinberg/microdot>`_
into your project directory.
Examples
--------

BIN
examples/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
examples/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
examples/3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,4 +1,4 @@
from microdot import Microdot, Response
from microdot import Microdot
app = Microdot()
@@ -20,7 +20,7 @@ htmldoc = '''<!DOCTYPE html>
@app.route('/')
def hello(request):
return Response(body=htmldoc, headers={'Content-Type': 'text/html'})
return htmldoc, 200, {'Content-Type': 'text/html'}
@app.route('/shutdown')

36
examples/hello_asgi.py Normal file
View File

@@ -0,0 +1,36 @@
from microdot_asgi import Microdot
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__':
print('''Use an ASGI web server to run this applicaton.
Example:
uvicorn hello_asgi:app
''')

View File

@@ -1,8 +1,4 @@
try:
import uasyncio as asyncio
except ImportError:
import asyncio
from microdot_asyncio import Microdot, Response
from microdot_asyncio import Microdot
app = Microdot()
@@ -24,7 +20,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')

36
examples/hello_wsgi.py Normal file
View File

@@ -0,0 +1,36 @@
from microdot_wsgi import Microdot
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('/')
def hello(request):
return htmldoc, 200, {'Content-Type': 'text/html'}
@app.route('/shutdown')
def shutdown(request):
request.app.shutdown()
return 'The server is shutting down...'
if __name__ == '__main__':
print('''Use a WSGI web server to run this applicaton.
Example:
gunicorn hello_wsgi:app
''')

45
examples/video_stream.py Normal file
View File

@@ -0,0 +1,45 @@
try:
import utime as time
except ImportError:
import time
from microdot import Microdot
app = Microdot()
frames = []
for file in ['1.jpg', '2.jpg', '3.jpg']:
with open(file, 'rb') as f:
frames.append(f.read())
@app.route('/')
def index(request):
return '''<!doctype html>
<html>
<head>
<title>Microdot Video Streaming</title>
</head>
<body>
<h1>Microdot Video Streaming</h1>
<img src="/video_feed">
</body>
</html>''', 200, {'Content-Type': 'text/html'}
@app.route('/video_feed')
def video_feed(request):
def stream():
yield b'--frame\r\n'
while True:
for frame in frames:
yield b'Content-Type: image/jpeg\r\n\r\n' + frame + \
b'\r\n--frame\r\n'
time.sleep(1)
return stream(), 200, {'Content-Type':
'multipart/x-mixed-replace; boundary=frame'}
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -0,0 +1,64 @@
import sys
try:
import uasyncio as asyncio
except ImportError:
import asyncio
from microdot_asyncio import Microdot
app = Microdot()
frames = []
for file in ['1.jpg', '2.jpg', '3.jpg']:
with open(file, 'rb') as f:
frames.append(f.read())
@app.route('/')
def index(request):
return '''<!doctype html>
<html>
<head>
<title>Microdot Video Streaming</title>
</head>
<body>
<h1>Microdot Video Streaming</h1>
<img src="/video_feed">
</body>
</html>''', 200, {'Content-Type': 'text/html'}
@app.route('/video_feed')
async def video_feed(request):
if sys.implementation.name != 'micropython':
# CPython supports yielding async generators
async def stream():
yield b'--frame\r\n'
while True:
for frame in frames:
yield b'Content-Type: image/jpeg\r\n\r\n' + frame + \
b'\r\n--frame\r\n'
await asyncio.sleep(1)
else:
# MicroPython can only use class-based async generators
class stream():
def __init__(self):
self.i = 0
def __aiter__(self):
return self
async def __anext__(self):
await asyncio.sleep(1)
self.i = (self.i + 1) % len(frames)
return b'Content-Type: image/jpeg\r\n\r\n' + \
frames[self.i] + b'\r\n--frame\r\n'
return stream(), 200, {'Content-Type':
'multipart/x-mixed-replace; boundary=frame'}
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -1,6 +1,6 @@
[metadata]
name = microdot
version = 0.8.1
version = 0.9.0
author = Miguel Grinberg
author_email = miguel.grinberg@gmail.com
description = The impossibly small web framework for MicroPython
@@ -25,3 +25,4 @@ package_dir =
py_modules =
microdot
microdot_asyncio
microdot_wsgi

View File

@@ -352,7 +352,10 @@ class Response():
"""An HTTP response class.
:param body: The body of the response. If a dictionary or list is given,
a JSON formatter is used to generate the body.
a JSON formatter is used to generate the body. If a file-like
object or a generator is given, a streaming response is used.
If a string is given, it is encoded from UTF-8. Else, the
body should be a byte sequence.
:param status_code: The numeric HTTP status code of the response. The
default is 200.
:param headers: A dictionary of headers to include in the response.
@@ -373,6 +376,9 @@ class Response():
send_file_buffer_size = 1024
def __init__(self, body='', status_code=200, headers=None, reason=None):
if body is None and status_code == 200:
body = ''
status_code = 204
self.status_code = status_code
self.headers = headers.copy() if headers else {}
self.reason = reason
@@ -382,7 +388,7 @@ class Response():
elif isinstance(body, str):
self.body = body.encode()
else:
# this applies to bytes or file-like objects
# this applies to bytes, file-like objects or generators
self.body = body
def set_cookie(self, cookie, value, path=None, domain=None, expires=None,
@@ -442,18 +448,35 @@ class Response():
stream.write(b'\r\n')
# body
can_flush = hasattr(stream, 'flush')
try:
for body in self.body_iter():
if isinstance(body, str):
body = body.encode()
stream.write(body)
if can_flush: # pragma: no cover
stream.flush()
except OSError as exc: # pragma: no cover
if exc.errno == 32: # errno.EPIPE
pass
else:
raise
def body_iter(self):
if self.body:
if hasattr(self.body, 'read'):
while True:
buf = self.body.read(self.send_file_buffer_size)
if len(buf):
stream.write(buf)
yield buf
if len(buf) < self.send_file_buffer_size:
break
if hasattr(self.body, 'close'): # pragma: no cover
self.body.close()
elif hasattr(self.body, '__next__'):
yield from self.body
else:
stream.write(self.body)
yield self.body
@classmethod
def redirect(cls, location, status_code=302):
@@ -796,11 +819,11 @@ class Microdot():
try:
sock, addr = self.server.accept()
except OSError as exc: # pragma: no cover
if exc.args[0] == errno.ECONNABORTED:
if exc.errno == errno.ECONNABORTED:
break
else:
raise
create_thread(self.dispatch_request, sock, addr)
create_thread(self.handle_request, sock, addr)
def shutdown(self):
"""Request a server shutdown. The server will then exit its request
@@ -827,7 +850,7 @@ class Microdot():
break
return f
def dispatch_request(self, sock, addr):
def handle_request(self, sock, addr):
if not hasattr(sock, 'readline'): # pragma: no cover
stream = sock.makefile("rwb")
else:
@@ -838,6 +861,25 @@ class Microdot():
req = Request.create(self, stream, addr)
except Exception as exc: # pragma: no cover
print_exception(exc)
res = self.dispatch_request(req)
res.write(stream)
try:
stream.close()
except OSError as exc: # pragma: no cover
if exc.errno == 32: # errno.EPIPE
pass
else:
raise
if stream != sock: # pragma: no cover
sock.close()
if self.shutdown_requested: # pragma: no cover
self.server.close()
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))
def dispatch_request(self, req):
if req:
if req.content_length > req.max_content_length:
if 413 in self.error_handlers:
@@ -884,16 +926,7 @@ class Microdot():
res = Response(*res)
elif not isinstance(res, Response):
res = Response(res)
res.write(stream)
stream.close()
if stream != sock: # pragma: no cover
sock.close()
if self.shutdown_requested: # pragma: no cover
self.server.close()
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
redirect = Response.redirect

141
src/microdot_asgi.py Normal file
View File

@@ -0,0 +1,141 @@
import asyncio
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):
def __init__(self):
super().__init__()
self.embedded_server = False
async def asgi_app(self, scope, receive, send):
"""An ASGI application."""
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()]})
cancelled = False
async def cancel_monitor():
nonlocal cancelled
while True:
event = await receive()
if event['type'] == 'http.disconnect': # pragma: no branch
cancelled = True
break
asyncio.ensure_future(cancel_monitor())
body_iter = res.body_iter().__aiter__()
try:
body = await body_iter.__anext__()
while not cancelled: # pragma: no branch
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):
if self.embedded_server: # pragma: no cover
super().shutdown()
else:
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
"""Normally you would not start the server by invoking this method.
Instead, start your chosen ASGI web server and pass the ``Microdot``
instance as the ASGI application.
"""
self.embedded_server = True
super().run(host=host, port=port, debug=debug, **options)

View File

@@ -79,7 +79,6 @@ class Request(BaseRequest):
# body
body = b''
print(Request.max_body_length)
if content_length and content_length <= Request.max_body_length:
body = await client_stream.readexactly(content_length)
stream = None
@@ -108,7 +107,10 @@ class Response(BaseResponse):
"""An HTTP response class.
:param body: The body of the response. If a dictionary or list is given,
a JSON formatter is used to generate the body.
a JSON formatter is used to generate the body. If a file-like
object or an async generator is given, a streaming response is
used. If a string is given, it is encoded from UTF-8. Else,
the body should be a byte sequence.
:param status_code: The numeric HTTP status code of the response. The
default is 200.
:param headers: A dictionary of headers to include in the response.
@@ -134,18 +136,52 @@ class Response(BaseResponse):
await stream.awrite(b'\r\n')
# body
if self.body:
if hasattr(self.body, 'read'):
while True:
buf = self.body.read(self.send_file_buffer_size)
if len(buf):
await stream.awrite(buf)
if len(buf) < self.send_file_buffer_size:
break
if hasattr(self.body, 'close'): # pragma: no cover
self.body.close()
try:
async for body in self.body_iter():
if isinstance(body, str):
body = body.encode()
await stream.awrite(body)
except OSError as exc: # pragma: no cover
if exc.errno == 32 or exc.args[0] == 'Connection lost':
pass
else:
await stream.awrite(self.body)
raise
def body_iter(self):
if hasattr(self.body, '__anext__'):
return self.body
response = self
class iter:
def __aiter__(self):
if response.body:
self.i = 0
else:
self.i = -1
return self
async def __anext__(self):
if self.i == -1:
raise StopAsyncIteration
if self.i == 0:
if not hasattr(response.body, 'read'):
self.i = -1
return response.body
else:
self.i = 1
buf = response.body.read(response.send_file_buffer_size)
if _iscoroutine(buf): # pragma: no cover
buf = await buf
if len(buf) < response.send_file_buffer_size:
self.i = -1
if hasattr(response.body, 'close'): # pragma: no cover
result = response.body.close()
if _iscoroutine(result):
await result
return buf
return iter()
class Microdot(BaseMicrodot):
@@ -202,7 +238,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(
@@ -252,13 +288,29 @@ 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)
try:
await writer.aclose()
except OSError as exc: # pragma: no cover
if exc.errno == 32: # errno.EPIPE
pass
else:
raise
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:
@@ -311,12 +363,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)

59
src/microdot_wsgi.py Normal file
View File

@@ -0,0 +1,59 @@
import os
import signal
from microdot import * # noqa: F401, F403
from microdot import Microdot as BaseMicrodot
from microdot import Request
class Microdot(BaseMicrodot):
def __init__(self):
super().__init__()
self.embedded_server = False
def wsgi_app(self, environ, start_response):
"""A WSGI application callable."""
path = environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '')
if 'QUERY_STRING' in environ and 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
req = Request(
self,
(environ['REMOTE_ADDR'], int(environ.get('REMOTE_PORT', '0'))),
environ['REQUEST_METHOD'],
path,
environ['SERVER_PROTOCOL'],
headers,
stream=environ['wsgi.input'])
req.environ = 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,
[(name, value) for name, value in res.headers.items()])
return res.body_iter()
def __call__(self, environ, start_response):
return self.wsgi_app(environ, start_response)
def shutdown(self):
if self.embedded_server: # pragma: no cover
super().shutdown()
else:
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
"""Normally you would not start the server by invoking this method.
Instead, start your chosen WSGI web server and pass the ``Microdot``
instance as the WSGI callable.
"""
self.embedded_server = True
super().run(host=host, port=port, debug=debug, **options)

View File

@@ -286,3 +286,21 @@ class TestMicrodot(unittest.TestCase):
self.assertIn(b'Content-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n501'))
def test_streaming(self):
app = Microdot()
@app.route('/')
def index(req):
def stream():
yield 'foo'
yield b'bar'
return stream()
mock_socket.clear_requests()
fd = mock_socket.add_request('GET', '/')
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n'))
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nfoobar'))

View File

@@ -88,6 +88,18 @@ class TestResponse(unittest.TestCase):
self.assertIn(b'Content-Type: application/json\r\n', response)
self.assertTrue(response.endswith(b'\r\n\r\n[1, "2"]'))
def test_create_from_none(self):
res = Response(None)
self.assertEqual(res.status_code, 204)
self.assertEqual(res.body, b'')
fd = io.BytesIO()
res.write(fd)
response = fd.getvalue()
self.assertIn(b'HTTP/1.0 204 N/A\r\n', response)
self.assertIn(b'Content-Length: 0\r\n', response)
self.assertIn(b'Content-Type: text/plain\r\n', response)
self.assertTrue(response.endswith(b'\r\n\r\n'))
def test_create_from_other(self):
res = Response(123)
self.assertEqual(res.status_code, 200)

View File

@@ -0,0 +1,161 @@
import unittest
import sys
try:
import asyncio
except ImportError:
pass
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',
}
event_index = 0
async def receive():
nonlocal event_index
if event_index == 0:
event_index = 1
return {
'type': 'http.request',
'body': b'body',
'more_body': False,
}
await asyncio.sleep(0.1)
return {
'type': 'http.disconnect',
}
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', b''])
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',
}
event_index = 0
async def receive():
nonlocal event_index
if event_index == 0:
event_index = 1
return {
'type': 'http.request',
'body': b'body',
'more_body': False,
}
await asyncio.sleep(0.1)
return {
'type': 'http.disconnect',
}
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

@@ -264,3 +264,33 @@ class TestMicrodotAsync(unittest.TestCase):
self.assertIn(b'Content-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n501'))
def test_streaming(self):
app = Microdot()
@app.route('/')
def index(req):
class stream():
def __init__(self):
self.i = 0
self.data = ['foo', b'bar']
def __aiter__(self):
return self
async def __anext__(self):
if self.i >= len(self.data):
raise StopAsyncIteration
data = self.data[self.i]
self.i += 1
return data
return stream()
mock_socket.clear_requests()
fd = mock_socket.add_request('GET', '/')
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n'))
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nfoobar'))

View File

@@ -0,0 +1,104 @@
import unittest
import sys
try:
import uio as io
except ImportError:
import io
try:
from unittest import mock
except ImportError:
mock = None
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',
'QUERY_STRING': 'baz=1',
'HTTP_AUTHORIZATION': 'Bearer 123',
'HTTP_COOKIE': 'session=xyz',
'HTTP_CONTENT_LENGTH': '4',
'REMOTE_ADDR': '1.2.3.4',
'REMOTE_PORT': '1234',
'REQUEST_METHOD': 'POST',
'SERVER_PROTOCOL': 'HTTP/1.1',
'wsgi.input': io.BytesIO(b'body'),
}
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',
'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):
pass
app(environ, start_response)
def test_shutdown(self):
app = Microdot()
@app.route('/shutdown')
def shutdown(request):
request.app.shutdown()
environ = {
'PATH_INFO': '/shutdown',
'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):
pass
with mock.patch('microdot_wsgi.os.kill') as kill:
app(environ, start_response)
kill.assert_called()

View File

@@ -1,5 +1,5 @@
[tox]
envlist=flake8,py36,py37,py38,py39,upy
envlist=flake8,py36,py37,py38,py39,py310,upy
skipsdist=True
skip_missing_interpreters=True
@@ -9,6 +9,7 @@ python =
3.7: py37
3.8: py38
3.9: py39
3.10: py310
pypy3: pypy3
[testenv]
@@ -23,7 +24,7 @@ deps=
deps=
flake8
commands=
flake8 --ignore=W503 --exclude tests/libs src tests
flake8 --ignore=W503 --exclude tests/libs src tests examples
[testenv:upy]
whitelist_externals=sh