23 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
Miguel Grinberg
453e133cc2 Release 0.8.1 2022-03-18 23:51:28 +00:00
Miguel Grinberg
29a9f6f46c Optimizations for request streams and bodies 2022-02-21 18:11:19 +01:00
Miguel Grinberg
9d3222ae4b Version 0.8.1.dev0 2022-02-18 17:41:16 +00:00
Miguel Grinberg
f23a6be2db Release 0.8.0 2022-02-18 17:40:59 +00:00
Miguel Grinberg
992fa722c1 Support streamed request payloads (Fixes #26) 2022-02-18 17:32:14 +00:00
Steve Li
e16fb94b2d Use case insensitive comparisons for HTTP headers (#33) 2022-01-31 12:10:23 +00:00
Miguel Grinberg
c130d8f2d4 simplified hello_async.py example 2022-01-22 23:23:31 +00:00
Miguel Grinberg
bd82c4deab More robust logic to read request body (Fixes #31) 2021-10-23 19:03:31 +01:00
Miguel Grinberg
7bc5d724f0 Version 0.7.3.dev0 2021-09-28 17:23:17 +01:00
28 changed files with 1029 additions and 111 deletions

View File

@@ -21,7 +21,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, macos-latest, windows-latest] 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 fail-fast: false
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:

View File

@@ -1,5 +1,30 @@
# Microdot change log # 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))
**Release 0.8.0** - 2022-02-18
- Support streamed request payloads [#26](https://github.com/miguelgrinberg/microdot/issues/26) ([commit](https://github.com/miguelgrinberg/microdot/commit/992fa722c1312c0ac0ee9fbd5e23ad7b52d3caca))
- Use case insensitive comparisons for HTTP headers [#33](https://github.com/miguelgrinberg/microdot/issues/33) ([commit](https://github.com/miguelgrinberg/microdot/commit/e16fb94b2d1e88ef681d70f7f456c37ee9859df6)) (thanks **Steve Li**!)
- More robust logic to read request body [#31](https://github.com/miguelgrinberg/microdot/issues/31) ([commit](https://github.com/miguelgrinberg/microdot/commit/bd82c4deabf40d37e6b7397b08e8eb40ba2b6a42))
- Simplified `hello_async.py` example ([commit](https://github.com/miguelgrinberg/microdot/commit/c130d8f2d45dcce9606dda25d31d653ce91faf92))
**Release 0.7.2** - 2021-09-28 **Release 0.7.2** - 2021-09-28
- Document a security risk in the send_file function ([commit](https://github.com/miguelgrinberg/microdot/commit/d29ed6aaa1f2080fcf471bf6ae0f480f95ff1716)) (thanks **Ky Tran**!) - Document a security risk in the send_file function ([commit](https://github.com/miguelgrinberg/microdot/commit/d29ed6aaa1f2080fcf471bf6ae0f480f95ff1716)) (thanks **Ky Tran**!)

View File

@@ -1,33 +0,0 @@
#!/bin/bash
VERSION=$1
if [[ "$VERSION" == "" ]]; then
echo Usage: $0 "<version>"
exit 1
fi
git diff --cached --exit-code >/dev/null
if [[ "$?" != "0" ]]; then
echo Commit your changes before using this script.
exit 1
fi
set -e
for PKG in microdot*; do
echo Building $PKG...
cd $PKG
sed -i "" "s/version.*$/version=\"$VERSION\",/" setup.py
git add setup.py
rm -rf dist
python setup.py sdist bdist_wheel --universal
cd ..
done
git commit -m "Release v$VERSION"
git tag v$VERSION
git push --tags origin master
for PKG in microdot*; do
echo Releasing $PKG...
cd $PKG
twine upload dist/*
cd ..
done

View File

@@ -60,3 +60,31 @@ and coroutines.
:inherited-members: :inherited-members:
: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 pip install microdot
For platforms that do not support or cannot run ``pip``, you can also manually For MicroPython, you can install with ``upip``.
copy and install the ``microdot.py`` and ``microdot_asyncio.py`` source files.
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 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() app = Microdot()
@@ -20,7 +20,7 @@ htmldoc = '''<!DOCTYPE html>
@app.route('/') @app.route('/')
def hello(request): 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')

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: from microdot_asyncio import Microdot
import uasyncio as asyncio
except ImportError:
import asyncio
from microdot_asyncio import Microdot, Response
app = Microdot() app = Microdot()
@@ -24,7 +20,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')
@@ -33,8 +29,4 @@ async def shutdown(request):
return 'The server is shutting down...' return 'The server is shutting down...'
async def main(): app.run(debug=True)
await app.start_server(debug=True)
asyncio.run(main())

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] [metadata]
name = microdot name = microdot
version = 0.7.2 version = 0.9.0
author = Miguel Grinberg author = Miguel Grinberg
author_email = miguel.grinberg@gmail.com author_email = miguel.grinberg@gmail.com
description = The impossibly small web framework for MicroPython description = The impossibly small web framework for MicroPython
@@ -25,3 +25,4 @@ package_dir =
py_modules = py_modules =
microdot microdot
microdot_asyncio microdot_asyncio
microdot_wsgi

View File

@@ -181,7 +181,8 @@ class Request():
:var cookies: A dictionary with the cookies included in the request. :var cookies: A dictionary with the cookies included in the request.
:var content_length: The parsed ``Content-Length`` header. :var content_length: The parsed ``Content-Length`` header.
:var content_type: The parsed ``Content-Type`` header. :var content_type: The parsed ``Content-Type`` header.
:var body: A stream from where the body can be read. :var stream: The input stream, containing the request body.
:var body: The body of the request, as bytes.
:var json: The parsed JSON body, as a dictionary or list, or ``None`` if :var json: The parsed JSON body, as a dictionary or list, or ``None`` if
the request does not have a JSON body. the request does not have a JSON body.
:var form: The parsed form submission body, as a :class:`MultiDict` object, :var form: The parsed form submission body, as a :class:`MultiDict` object,
@@ -198,6 +199,17 @@ class Request():
#: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed #: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed
max_content_length = 16 * 1024 max_content_length = 16 * 1024
#: Specify the maximum payload size that can be stored in ``body``.
#: Requests with payloads that are larger than this size and up to
#: ``max_content_length`` bytes will be accepted, but the application will
#: only be able to access the body of the request by reading from
#: ``stream``. Set to 0 if you always access the body as a stream.
#:
#: Example::
#:
#: Request.max_body_length = 4 * 1024 # up to 4KB bodies read
max_body_length = 16 * 1024
#: Specify the maximum length allowed for a line in the request. Requests #: Specify the maximum length allowed for a line in the request. Requests
#: with longer lines will not be correctly interpreted. Applications can #: with longer lines will not be correctly interpreted. Applications can
#: change this maximum as necessary. #: change this maximum as necessary.
@@ -211,7 +223,7 @@ class Request():
pass pass
def __init__(self, app, client_addr, method, url, http_version, headers, def __init__(self, app, client_addr, method, url, http_version, headers,
body): body=None, stream=None):
self.app = app self.app = app
self.client_addr = client_addr self.client_addr = client_addr
self.method = method self.method = method
@@ -228,15 +240,19 @@ class Request():
self.content_length = 0 self.content_length = 0
self.content_type = None self.content_type = None
for header, value in self.headers.items(): for header, value in self.headers.items():
if header == 'Content-Length': header = header.lower()
if header == 'content-length':
self.content_length = int(value) self.content_length = int(value)
elif header == 'Content-Type': elif header == 'content-type':
self.content_type = value self.content_type = value
elif header == 'Cookie': elif header == 'cookie':
for cookie in value.split(';'): for cookie in value.split(';'):
name, value = cookie.strip().split('=', 1) name, value = cookie.strip().split('=', 1)
self.cookies[name] = value self.cookies[name] = value
self.body = body self._body = body
self.body_used = False
self._stream = stream
self.stream_used = False
self._json = None self._json = None
self._form = None self._form = None
self.g = Request.G() self.g = Request.G()
@@ -261,7 +277,6 @@ class Request():
# headers # headers
headers = {} headers = {}
content_length = 0
while True: while True:
line = Request._safe_readline(client_stream).strip().decode() line = Request._safe_readline(client_stream).strip().decode()
if line == '': if line == '':
@@ -269,15 +284,9 @@ class Request():
header, value = line.split(':', 1) header, value = line.split(':', 1)
value = value.strip() value = value.strip()
headers[header] = value headers[header] = value
if header == 'Content-Length':
content_length = int(value)
# body
body = client_stream.read(content_length) if content_length and \
content_length <= Request.max_content_length else b''
return Request(app, client_addr, method, url, http_version, headers, return Request(app, client_addr, method, url, http_version, headers,
body) stream=client_stream)
def _parse_urlencoded(self, urlencoded): def _parse_urlencoded(self, urlencoded):
data = MultiDict() data = MultiDict()
@@ -285,6 +294,30 @@ class Request():
data[urldecode(k)] = urldecode(v) data[urldecode(k)] = urldecode(v)
return data return data
@property
def body(self):
if self.stream_used:
raise RuntimeError('Cannot use both stream and body')
if self._body is None:
self._body = b''
if self.content_length and \
self.content_length <= Request.max_body_length:
while len(self._body) < self.content_length:
data = self._stream.read(
self.content_length - len(self._body))
if len(data) == 0: # pragma: no cover
raise EOFError()
self._body += data
self.body_used = True
return self._body
@property
def stream(self):
if self.body_used:
raise RuntimeError('Cannot use both stream and body')
self.stream_used = True
return self._stream
@property @property
def json(self): def json(self):
if self._json is None: if self._json is None:
@@ -319,7 +352,10 @@ class Response():
"""An HTTP response class. """An HTTP response class.
:param body: The body of the response. If a dictionary or list is given, :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 :param status_code: The numeric HTTP status code of the response. The
default is 200. default is 200.
:param headers: A dictionary of headers to include in the response. :param headers: A dictionary of headers to include in the response.
@@ -340,6 +376,9 @@ class Response():
send_file_buffer_size = 1024 send_file_buffer_size = 1024
def __init__(self, body='', status_code=200, headers=None, reason=None): 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.status_code = status_code
self.headers = headers.copy() if headers else {} self.headers = headers.copy() if headers else {}
self.reason = reason self.reason = reason
@@ -349,7 +388,7 @@ class Response():
elif isinstance(body, str): elif isinstance(body, str):
self.body = body.encode() self.body = body.encode()
else: else:
# this applies to bytes or file-like objects # this applies to bytes, file-like objects or generators
self.body = body self.body = body
def set_cookie(self, cookie, value, path=None, domain=None, expires=None, def set_cookie(self, cookie, value, path=None, domain=None, expires=None,
@@ -409,18 +448,35 @@ class Response():
stream.write(b'\r\n') stream.write(b'\r\n')
# body # 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 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 len(buf): if len(buf):
stream.write(buf) 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() self.body.close()
elif hasattr(self.body, '__next__'):
yield from self.body
else: else:
stream.write(self.body) yield self.body
@classmethod @classmethod
def redirect(cls, location, status_code=302): def redirect(cls, location, status_code=302):
@@ -763,11 +819,11 @@ class Microdot():
try: try:
sock, addr = self.server.accept() sock, addr = self.server.accept()
except OSError as exc: # pragma: no cover except OSError as exc: # pragma: no cover
if exc.args[0] == errno.ECONNABORTED: if exc.errno == errno.ECONNABORTED:
break break
else: else:
raise raise
create_thread(self.dispatch_request, sock, addr) create_thread(self.handle_request, sock, addr)
def shutdown(self): def shutdown(self):
"""Request a server shutdown. The server will then exit its request """Request a server shutdown. The server will then exit its request
@@ -794,7 +850,7 @@ class Microdot():
break break
return f return f
def dispatch_request(self, sock, addr): def handle_request(self, sock, addr):
if not hasattr(sock, 'readline'): # pragma: no cover if not hasattr(sock, 'readline'): # pragma: no cover
stream = sock.makefile("rwb") stream = sock.makefile("rwb")
else: else:
@@ -805,6 +861,25 @@ class Microdot():
req = Request.create(self, stream, addr) req = Request.create(self, stream, addr)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
print_exception(exc) 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:
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:
@@ -851,16 +926,7 @@ class Microdot():
res = Response(*res) res = Response(*res)
elif not isinstance(res, Response): elif not isinstance(res, Response):
res = Response(res) res = Response(res)
res.write(stream) return res
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))
redirect = Response.redirect 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

@@ -10,6 +10,12 @@ try:
import uasyncio as asyncio import uasyncio as asyncio
except ImportError: except ImportError:
import asyncio import asyncio
try:
import uio as io
except ImportError:
import io
from microdot import Microdot as BaseMicrodot from microdot import Microdot as BaseMicrodot
from microdot import print_exception from microdot import print_exception
from microdot import Request as BaseRequest from microdot import Request as BaseRequest
@@ -20,6 +26,23 @@ def _iscoroutine(coro):
return hasattr(coro, 'send') and hasattr(coro, 'throw') return hasattr(coro, 'send') and hasattr(coro, 'throw')
class _AsyncBytesIO:
def __init__(self, data):
self.stream = io.BytesIO(data)
async def read(self, n=-1):
return self.stream.read(n)
async def readline(self): # pragma: no cover
return self.stream.readline()
async def readexactly(self, n): # pragma: no cover
return self.stream.read(n)
async def readuntil(self, separator=b'\n'): # pragma: no cover
return self.stream.readuntil(separator=separator)
class Request(BaseRequest): class Request(BaseRequest):
@staticmethod @staticmethod
async def create(app, client_stream, client_addr): async def create(app, client_stream, client_addr):
@@ -51,15 +74,26 @@ class Request(BaseRequest):
header, value = line.split(':', 1) header, value = line.split(':', 1)
value = value.strip() value = value.strip()
headers[header] = value headers[header] = value
if header == 'Content-Length': if header.lower() == 'content-length':
content_length = int(value) content_length = int(value)
# body # body
body = await client_stream.read(content_length) if content_length and \ body = b''
content_length <= Request.max_content_length else b'' if content_length and content_length <= Request.max_body_length:
body = await client_stream.readexactly(content_length)
stream = None
else:
body = b''
stream = client_stream
return Request(app, client_addr, method, url, http_version, headers, return Request(app, client_addr, method, url, http_version, headers,
body) body=body, stream=stream)
@property
def stream(self):
if self._stream is None:
self._stream = _AsyncBytesIO(self._body)
return self._stream
@staticmethod @staticmethod
async def _safe_readline(stream): async def _safe_readline(stream):
@@ -73,7 +107,10 @@ class Response(BaseResponse):
"""An HTTP response class. """An HTTP response class.
:param body: The body of the response. If a dictionary or list is given, :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 :param status_code: The numeric HTTP status code of the response. The
default is 200. default is 200.
:param headers: A dictionary of headers to include in the response. :param headers: A dictionary of headers to include in the response.
@@ -99,18 +136,52 @@ class Response(BaseResponse):
await stream.awrite(b'\r\n') await stream.awrite(b'\r\n')
# body # body
if self.body: try:
if hasattr(self.body, 'read'): async for body in self.body_iter():
while True: if isinstance(body, str):
buf = self.body.read(self.send_file_buffer_size) body = body.encode()
if len(buf): await stream.awrite(body)
await stream.awrite(buf) except OSError as exc: # pragma: no cover
if len(buf) < self.send_file_buffer_size: if exc.errno == 32 or exc.args[0] == 'Connection lost':
break pass
if hasattr(self.body, 'close'): # pragma: no cover
self.body.close()
else: 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): class Microdot(BaseMicrodot):
@@ -167,7 +238,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(
@@ -217,13 +288,29 @@ 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)
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:
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:
@@ -276,12 +363,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)

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-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\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')) 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

@@ -91,14 +91,38 @@ class TestRequest(unittest.TestCase):
Request.max_readline = saved_max_readline Request.max_readline = saved_max_readline
def test_stream(self):
fd = get_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '19'},
body='foo=bar&abc=def&x=y')
req = Request.create('app', fd, 'addr')
self.assertEqual(req.stream.read(), b'foo=bar&abc=def&x=y')
with self.assertRaises(RuntimeError):
req.body
def test_body(self):
fd = get_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '19'},
body='foo=bar&abc=def&x=y')
req = Request.create('app', fd, 'addr')
self.assertEqual(req.body, b'foo=bar&abc=def&x=y')
with self.assertRaises(RuntimeError):
req.stream
def test_large_payload(self): def test_large_payload(self):
saved_max_content_length = Request.max_content_length saved_max_content_length = Request.max_content_length
Request.max_content_length = 16 saved_max_body_length = Request.max_body_length
Request.max_content_length = 32
Request.max_body_length = 16
fd = get_request_fd('GET', '/foo', headers={ fd = get_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded'}, 'Content-Type': 'application/x-www-form-urlencoded'},
body='foo=bar&abc=def&x=y') body='foo=bar&abc=def&x=y')
req = Request.create('app', fd, 'addr') req = Request.create('app', fd, 'addr')
self.assertEqual(req.body, b'') self.assertEqual(req.body, b'')
self.assertEqual(req.stream.read(), b'foo=bar&abc=def&x=y')
Request.max_content_length = saved_max_content_length Request.max_content_length = saved_max_content_length
Request.max_body_length = saved_max_body_length

View File

@@ -88,6 +88,18 @@ class TestResponse(unittest.TestCase):
self.assertIn(b'Content-Type: application/json\r\n', response) self.assertIn(b'Content-Type: application/json\r\n', response)
self.assertTrue(response.endswith(b'\r\n\r\n[1, "2"]')) 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): def test_create_from_other(self):
res = Response(123) res = Response(123)
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
@@ -167,6 +179,9 @@ class TestResponse(unittest.TestCase):
self.assertEqual(res.status_code, 301) self.assertEqual(res.status_code, 301)
self.assertEqual(res.headers['Location'], '/foo') self.assertEqual(res.headers['Location'], '/foo')
with self.assertRaises(ValueError):
Response.redirect('/foo\x0d\x0a\x0d\x0a<p>Foo</p>')
def test_send_file(self): def test_send_file(self):
files = [ files = [
('test.txt', 'text/plain'), ('test.txt', 'text/plain'),

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-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\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')) 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

@@ -101,14 +101,30 @@ class TestRequestAsync(unittest.TestCase):
Request.max_readline = saved_max_readline Request.max_readline = saved_max_readline
def test_stream(self):
fd = get_async_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '19'},
body='foo=bar&abc=def&x=y')
req = _run(Request.create('app', fd, 'addr'))
self.assertEqual(req.body, b'foo=bar&abc=def&x=y')
data = _run(req.stream.read())
self.assertEqual(data, b'foo=bar&abc=def&x=y')
def test_large_payload(self): def test_large_payload(self):
saved_max_content_length = Request.max_content_length saved_max_content_length = Request.max_content_length
Request.max_content_length = 16 saved_max_body_length = Request.max_body_length
Request.max_content_length = 32
Request.max_body_length = 16
fd = get_async_request_fd('GET', '/foo', headers={ fd = get_async_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded'}, 'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '19'},
body='foo=bar&abc=def&x=y') body='foo=bar&abc=def&x=y')
req = _run(Request.create('app', fd, 'addr')) req = _run(Request.create('app', fd, 'addr'))
self.assertEqual(req.body, b'') self.assertEqual(req.body, b'')
data = _run(req.stream.read())
self.assertEqual(data, b'foo=bar&abc=def&x=y')
Request.max_content_length = saved_max_content_length Request.max_content_length = saved_max_content_length
Request.max_body_length = saved_max_body_length

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

@@ -56,7 +56,10 @@ class FakeStreamAsync:
async def readline(self): async def readline(self):
return self.stream.readline() return self.stream.readline()
async def read(self, n): async def read(self, n=-1):
return self.stream.read(n)
async def readexactly(self, n):
return self.stream.read(n) return self.stream.read(n)
async def awrite(self, data): async def awrite(self, data):

View File

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