Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac022ba43 | ||
|
|
c18ccccb8e | ||
|
|
bcbad51675 | ||
|
|
d71665fd38 | ||
|
|
4182ba6380 | ||
|
|
5b5eb907d8 | ||
|
|
71009b4978 | ||
|
|
35c72125a0 | ||
|
|
7e8ecb1997 | ||
|
|
1ae51ccdf7 | ||
|
|
0ca1e01e00 | ||
|
|
5f7efcc3f8 | ||
|
|
0f278321c8 | ||
|
|
acf20cc20c | ||
|
|
453e133cc2 | ||
|
|
29a9f6f46c | ||
|
|
9d3222ae4b | ||
|
|
f23a6be2db | ||
|
|
992fa722c1 | ||
|
|
e16fb94b2d | ||
|
|
c130d8f2d4 | ||
|
|
bd82c4deab | ||
|
|
7bc5d724f0 | ||
|
|
f23c78533e | ||
|
|
d29ed6aaa1 | ||
|
|
8e5fb92ff1 | ||
|
|
06015934b8 | ||
|
|
568cd51fd2 | ||
|
|
2fe9793389 | ||
|
|
de9c991a9a | ||
|
|
d75449eb32 | ||
|
|
e508abc333 | ||
|
|
5003a5b3d9 | ||
|
|
4ed101dfc6 | ||
|
|
833fecb105 | ||
|
|
d527bdb7c3 | ||
|
|
2516b296a7 | ||
|
|
5061145f5c | ||
|
|
122c638bae | ||
|
|
bd74bcab74 | ||
|
|
5cd3ace516 | ||
|
|
da32f23e35 | ||
|
|
0641466faa | ||
|
|
dd3fc20507 | ||
|
|
46963ba464 | ||
|
|
1a8db51cb3 | ||
|
|
d903c42370 | ||
|
|
8b4ebbd953 | ||
|
|
a82ed55f56 | ||
|
|
ac87f0542f |
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -2,10 +2,10 @@ name: build
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- main
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: lint
|
name: lint
|
||||||
@@ -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:
|
||||||
|
|||||||
53
CHANGES.md
53
CHANGES.md
@@ -1,5 +1,58 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
- Document a security risk in the send_file function ([commit](https://github.com/miguelgrinberg/microdot/commit/d29ed6aaa1f2080fcf471bf6ae0f480f95ff1716)) (thanks **Ky Tran**!)
|
||||||
|
- Validate redirect URLs ([commit](https://github.com/miguelgrinberg/microdot/commit/8e5fb92ff1ccd50972b0c1cb5a6c3bd5eb54d86b)) (thanks **Ky Tran**!)
|
||||||
|
- Return a 400 error when request object could not be created ([commit](https://github.com/miguelgrinberg/microdot/commit/06015934b834622d39f52b3e13d16bfee9dc8e5a))
|
||||||
|
|
||||||
|
**Release 0.7.1** - 2021-09-27
|
||||||
|
|
||||||
|
- Breaking change: Limit the size of each request line to 2KB. A different maximum can be set in `Request.max_readline`. ([commit](https://github.com/miguelgrinberg/microdot/commit/de9c991a9ab836d57d5c08bf4282f99f073b502a)) (thanks **Ky Tran**!)
|
||||||
|
|
||||||
|
**Release 0.7.0** - 2021-09-27
|
||||||
|
|
||||||
|
- Breaking change: Limit the size of the request body to 16KB. A different maximum can be set in `Request.max_content_length`. ([commit](https://github.com/miguelgrinberg/microdot/commit/5003a5b3d948a7cf365857b419bebf6e388593a1))
|
||||||
|
- Add documentation for `request.client_addr` [#27](https://github.com/miguelgrinberg/microdot/issues/27) ([commit](https://github.com/miguelgrinberg/microdot/commit/833fecb105ce456b95f1d2a6ea96dceca1075814)) (thanks **Mark Blakeney**!)
|
||||||
|
- Added documentation for reason argument in the Response object ([commit](https://github.com/miguelgrinberg/microdot/commit/d527bdb7c32ab918a1ecf6956cf3a9f544504354))
|
||||||
|
|
||||||
|
**Release 0.6.0** - 2021-08-11
|
||||||
|
|
||||||
|
- Better handling of content types in form and json methods [#24](https://github.com/miguelgrinberg/microdot/issues/24) ([commit](https://github.com/miguelgrinberg/microdot/commit/da32f23e35f871470a40638e7000e84b0ff6d17f))
|
||||||
|
- Accept a custom reason phrase for the HTTP response [#25](https://github.com/miguelgrinberg/microdot/issues/25) ([commit](https://github.com/miguelgrinberg/microdot/commit/bd74bcab74f283c89aadffc8f9c20d6ff0f771ce))
|
||||||
|
- Make mime type check for form submissions more robust ([commit](https://github.com/miguelgrinberg/microdot/commit/dd3fc20507715a23d0fa6fa3aae3715c8fbc0351))
|
||||||
|
- Copy client headers to avoid write back [#23](https://github.com/miguelgrinberg/microdot/issues/23) ([commit](https://github.com/miguelgrinberg/microdot/commit/0641466faa9dda0c54f78939ac05993c0812e84a)) (thanks **Mark Blakeney**!)
|
||||||
|
- Work around a bug in uasyncio's create_server() function ([commit](https://github.com/miguelgrinberg/microdot/commit/46963ba4644d7abc8dc653c99bc76222af526964))
|
||||||
|
- More unit tests ([commit](https://github.com/miguelgrinberg/microdot/commit/5cd3ace5166ec549579b0b1149ae3d7be195974a))
|
||||||
|
- Installation instructions ([commit](https://github.com/miguelgrinberg/microdot/commit/1a8db51cb3754308da6dcc227512dcdeb4ce4557))
|
||||||
|
- Run tests with pytest ([commit](https://github.com/miguelgrinberg/microdot/commit/8b4ebbd9535b3c083fb2a955284609acba07f05e))
|
||||||
|
- Deprecated the microdot-asyncio package ([commit](https://github.com/miguelgrinberg/microdot/commit/a82ed55f56e14fbcea93e8171af86ab42657fa96))
|
||||||
|
|
||||||
**Release 0.5.0** - 2021-06-06
|
**Release 0.5.0** - 2021-06-06
|
||||||
|
|
||||||
- [Documentation](https://microdot.readthedocs.io/en/latest/) site ([commit](https://github.com/miguelgrinberg/microdot/commit/12cd60305b7b48ab151da52661fc5988684dbcd8))
|
- [Documentation](https://microdot.readthedocs.io/en/latest/) site ([commit](https://github.com/miguelgrinberg/microdot/commit/12cd60305b7b48ab151da52661fc5988684dbcd8))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# microdot
|
# microdot
|
||||||
[](https://github.com/miguelgrinberg/microdot/actions) [](https://codecov.io/gh/miguelgrinberg/microdot)
|
[](https://github.com/miguelgrinberg/microdot/actions) [](https://codecov.io/gh/miguelgrinberg/microdot)
|
||||||
|
|
||||||
A minimalistic Python web framework for microcontrollers inspired by Flask
|
A minimalistic Python web framework for microcontrollers inspired by Flask
|
||||||
|
|
||||||
|
|||||||
9
SECURITY.md
Normal file
9
SECURITY.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you think you've found a vulnerability on this project, please send me (Miguel Grinberg) an email at miguel.grinberg@gmail.com with a description of the problem. I will personally review the issue and respond to you with next steps.
|
||||||
|
|
||||||
|
If the issue is highly sensitive, you are welcome to encrypt your message. Here is my [PGP key](https://keyserver.ubuntu.com/pks/lookup?search=miguel.grinberg%40gmail.com&fingerprint=on&op=index).
|
||||||
|
|
||||||
|
Please do not disclose vulnerabilities publicly before discussing how to proceed with me.
|
||||||
@@ -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
|
|
||||||
28
docs/api.rst
28
docs/api.rst
@@ -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
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
#
|
#
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
sys.path.insert(0, os.path.abspath('../microdot'))
|
sys.path.insert(0, os.path.abspath('../src'))
|
||||||
sys.path.insert(1, os.path.abspath('../microdot-asyncio'))
|
|
||||||
|
|
||||||
|
|
||||||
# -- Project information -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
Microdot
|
Microdot
|
||||||
========
|
========
|
||||||
|
|
||||||
Microdot is a minimalistic Python web framework for microcontrollers inspired
|
Microdot is a minimalistic Python web framework inspired by
|
||||||
by `Flask <https://flask.palletsprojects.com/>`_, and designed to run on
|
`Flask <https://flask.palletsprojects.com/>`_, and designed to run on
|
||||||
systems with limited resources such as microcontrollers. It runs on standard
|
systems with limited resources such as microcontrollers. It runs on standard
|
||||||
Python and on `MicroPython <https://micropython.org>`_.
|
Python and on `MicroPython <https://micropython.org>`_.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
Installation
|
||||||
|
------------
|
||||||
|
|
||||||
|
Microdot can be installed with ``pip``::
|
||||||
|
|
||||||
|
pip install microdot
|
||||||
|
|
||||||
|
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
|
Examples
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
|||||||
BIN
examples/1.jpg
Normal file
BIN
examples/1.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
examples/2.jpg
Normal file
BIN
examples/2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
examples/3.jpg
Normal file
BIN
examples/3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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
36
examples/hello_asgi.py
Normal 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
|
||||||
|
''')
|
||||||
@@ -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
36
examples/hello_wsgi.py
Normal 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
45
examples/video_stream.py
Normal 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)
|
||||||
64
examples/video_stream_async.py
Normal file
64
examples/video_stream_async.py
Normal 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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[metadata]
|
[metadata]
|
||||||
name = microdot-asyncio
|
name = microdot-asyncio
|
||||||
version = 0.4.0
|
version = 0.5.0
|
||||||
author = Miguel Grinberg
|
author = Miguel Grinberg
|
||||||
author_email = miguel.grinberg@gmail.com
|
author_email = miguel.grinberg@gmail.com
|
||||||
description = AsyncIO support for the Microdot web framework'
|
description = AsyncIO support for the Microdot web framework'
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, 'microdot')
|
sys.path.insert(0, 'src')
|
||||||
sys.path.insert(1, 'microdot-asyncio')
|
|
||||||
sys.path.insert(2, 'tests/libs')
|
sys.path.insert(2, 'tests/libs')
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[metadata]
|
[metadata]
|
||||||
name = microdot
|
name = microdot
|
||||||
version = 0.5.0
|
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
|
||||||
|
|||||||
266
src/microdot.py
266
src/microdot.py
@@ -172,6 +172,7 @@ class Request():
|
|||||||
"""An HTTP request class.
|
"""An HTTP request class.
|
||||||
|
|
||||||
:var app: The application instance to which this request belongs.
|
:var app: The application instance to which this request belongs.
|
||||||
|
:var client_addr: The address of the client, as a tuple (host, port).
|
||||||
:var method: The HTTP method of the request.
|
:var method: The HTTP method of the request.
|
||||||
:var path: The path portion of the URL.
|
:var path: The path portion of the URL.
|
||||||
:var query_string: The query string portion of the URL.
|
:var query_string: The query string portion of the URL.
|
||||||
@@ -180,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,
|
||||||
@@ -188,11 +190,40 @@ class Request():
|
|||||||
:var g: A general purpose container for applications to store data during
|
:var g: A general purpose container for applications to store data during
|
||||||
the life of the request.
|
the life of the request.
|
||||||
"""
|
"""
|
||||||
|
#: Specify the maximum payload size that is accepted. Requests with larger
|
||||||
|
#: payloads will be rejected with a 413 status code. Applications can
|
||||||
|
#: change this maximum as necessary.
|
||||||
|
#:
|
||||||
|
#: Example::
|
||||||
|
#:
|
||||||
|
#: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed
|
||||||
|
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
|
||||||
|
#: with longer lines will not be correctly interpreted. Applications can
|
||||||
|
#: change this maximum as necessary.
|
||||||
|
#:
|
||||||
|
#: Example::
|
||||||
|
#:
|
||||||
|
#: Request.max_readline = 16 * 1024 # 16KB lines allowed
|
||||||
|
max_readline = 2 * 1024
|
||||||
|
|
||||||
class G:
|
class G:
|
||||||
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
|
||||||
@@ -209,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()
|
||||||
@@ -234,30 +269,24 @@ class Request():
|
|||||||
This method returns a newly created ``Request`` object.
|
This method returns a newly created ``Request`` object.
|
||||||
"""
|
"""
|
||||||
# request line
|
# request line
|
||||||
line = client_stream.readline().strip().decode()
|
line = Request._safe_readline(client_stream).strip().decode()
|
||||||
if not line: # pragma: no cover
|
if not line:
|
||||||
return None
|
return None
|
||||||
method, url, http_version = line.split()
|
method, url, http_version = line.split()
|
||||||
http_version = http_version.split('/', 1)[1]
|
http_version = http_version.split('/', 1)[1]
|
||||||
|
|
||||||
# headers
|
# headers
|
||||||
headers = {}
|
headers = {}
|
||||||
content_length = 0
|
|
||||||
while True:
|
while True:
|
||||||
line = client_stream.readline().strip().decode()
|
line = Request._safe_readline(client_stream).strip().decode()
|
||||||
if line == '':
|
if line == '':
|
||||||
break
|
break
|
||||||
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 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()
|
||||||
@@ -265,31 +294,74 @@ 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.content_type != 'application/json':
|
|
||||||
return None
|
|
||||||
if self._json is None:
|
if self._json is None:
|
||||||
|
if self.content_type is None:
|
||||||
|
return None
|
||||||
|
mime_type = self.content_type.split(';')[0]
|
||||||
|
if mime_type != 'application/json':
|
||||||
|
return None
|
||||||
self._json = json.loads(self.body.decode())
|
self._json = json.loads(self.body.decode())
|
||||||
return self._json
|
return self._json
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def form(self):
|
def form(self):
|
||||||
if self.content_type != 'application/x-www-form-urlencoded':
|
|
||||||
return None
|
|
||||||
if self._form is None:
|
if self._form is None:
|
||||||
|
if self.content_type is None:
|
||||||
|
return None
|
||||||
|
mime_type = self.content_type.split(';')[0]
|
||||||
|
if mime_type != 'application/x-www-form-urlencoded':
|
||||||
|
return None
|
||||||
self._form = self._parse_urlencoded(self.body.decode())
|
self._form = self._parse_urlencoded(self.body.decode())
|
||||||
return self._form
|
return self._form
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_readline(stream):
|
||||||
|
line = stream.readline(Request.max_readline + 1)
|
||||||
|
if len(line) > Request.max_readline:
|
||||||
|
raise ValueError('line too long')
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
class Response():
|
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.
|
||||||
|
:param reason: A custom reason phrase to add after the status code. The
|
||||||
|
default is "OK" for responses with a 200 status code and
|
||||||
|
"N/A" for any other status codes.
|
||||||
"""
|
"""
|
||||||
types_map = {
|
types_map = {
|
||||||
'css': 'text/css',
|
'css': 'text/css',
|
||||||
@@ -303,16 +375,20 @@ class Response():
|
|||||||
}
|
}
|
||||||
send_file_buffer_size = 1024
|
send_file_buffer_size = 1024
|
||||||
|
|
||||||
def __init__(self, body='', status_code=200, headers=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 or {}
|
self.headers = headers.copy() if headers else {}
|
||||||
|
self.reason = reason
|
||||||
if isinstance(body, (dict, list)):
|
if isinstance(body, (dict, list)):
|
||||||
self.body = json.dumps(body).encode()
|
self.body = json.dumps(body).encode()
|
||||||
self.headers['Content-Type'] = 'application/json'
|
self.headers['Content-Type'] = 'application/json'
|
||||||
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,
|
||||||
@@ -358,9 +434,10 @@ class Response():
|
|||||||
self.complete()
|
self.complete()
|
||||||
|
|
||||||
# status code
|
# status code
|
||||||
|
reason = self.reason if self.reason is not None else \
|
||||||
|
('OK' if self.status_code == 200 else 'N/A')
|
||||||
stream.write('HTTP/1.0 {status_code} {reason}\r\n'.format(
|
stream.write('HTTP/1.0 {status_code} {reason}\r\n'.format(
|
||||||
status_code=self.status_code,
|
status_code=self.status_code, reason=reason).encode())
|
||||||
reason='OK' if self.status_code == 200 else 'N/A').encode())
|
|
||||||
|
|
||||||
# headers
|
# headers
|
||||||
for header, value in self.headers.items():
|
for header, value in self.headers.items():
|
||||||
@@ -371,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):
|
||||||
@@ -392,6 +486,8 @@ class Response():
|
|||||||
:param status_code: The 3xx status code to use for the redirect. The
|
:param status_code: The 3xx status code to use for the redirect. The
|
||||||
default is 302.
|
default is 302.
|
||||||
"""
|
"""
|
||||||
|
if '\x0d' in location or '\x0a' in location:
|
||||||
|
raise ValueError('invalid redirect URL')
|
||||||
return cls(status_code=status_code, headers={'Location': location})
|
return cls(status_code=status_code, headers={'Location': location})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -404,6 +500,10 @@ class Response():
|
|||||||
:param content_type: The ``Content-Type`` header to use in the
|
:param content_type: The ``Content-Type`` header to use in the
|
||||||
response. If omitted, it is generated
|
response. If omitted, it is generated
|
||||||
automatically from the file extension.
|
automatically from the file extension.
|
||||||
|
|
||||||
|
Security note: The filename is assumed to be trusted. Never pass
|
||||||
|
filenames provided by the user before validating and sanitizing them
|
||||||
|
first.
|
||||||
"""
|
"""
|
||||||
if content_type is None:
|
if content_type is None:
|
||||||
ext = filename.split('.')[-1]
|
ext = filename.split('.')[-1]
|
||||||
@@ -719,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
|
||||||
@@ -750,53 +850,26 @@ 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:
|
||||||
stream = sock
|
stream = sock
|
||||||
|
|
||||||
req = Request.create(self, stream, addr)
|
req = None
|
||||||
if req:
|
try:
|
||||||
f = self.find_route(req)
|
req = Request.create(self, stream, addr)
|
||||||
try:
|
except Exception as exc: # pragma: no cover
|
||||||
res = None
|
print_exception(exc)
|
||||||
if f:
|
res = self.dispatch_request(req)
|
||||||
for handler in self.before_request_handlers:
|
res.write(stream)
|
||||||
res = handler(req)
|
try:
|
||||||
if res:
|
stream.close()
|
||||||
break
|
except OSError as exc: # pragma: no cover
|
||||||
if res is None:
|
if exc.errno == 32: # errno.EPIPE
|
||||||
res = f(req, **req.url_args)
|
pass
|
||||||
if isinstance(res, tuple):
|
else:
|
||||||
res = Response(*res)
|
raise
|
||||||
elif not isinstance(res, Response):
|
|
||||||
res = Response(res)
|
|
||||||
for handler in self.after_request_handlers:
|
|
||||||
res = handler(req, res) or res
|
|
||||||
elif 404 in self.error_handlers:
|
|
||||||
res = self.error_handlers[404](req)
|
|
||||||
else:
|
|
||||||
res = 'Not found', 404
|
|
||||||
except Exception as exc:
|
|
||||||
print_exception(exc)
|
|
||||||
res = None
|
|
||||||
if exc.__class__ in self.error_handlers:
|
|
||||||
try:
|
|
||||||
res = self.error_handlers[exc.__class__](req, exc)
|
|
||||||
except Exception as exc2: # pragma: no cover
|
|
||||||
print_exception(exc2)
|
|
||||||
if res is None:
|
|
||||||
if 500 in self.error_handlers:
|
|
||||||
res = self.error_handlers[500](req)
|
|
||||||
else:
|
|
||||||
res = 'Internal server error', 500
|
|
||||||
if isinstance(res, tuple):
|
|
||||||
res = Response(*res)
|
|
||||||
elif not isinstance(res, Response):
|
|
||||||
res = Response(res)
|
|
||||||
res.write(stream)
|
|
||||||
stream.close()
|
|
||||||
if stream != sock: # pragma: no cover
|
if stream != sock: # pragma: no cover
|
||||||
sock.close()
|
sock.close()
|
||||||
if self.shutdown_requested: # pragma: no cover
|
if self.shutdown_requested: # pragma: no cover
|
||||||
@@ -806,6 +879,55 @@ class Microdot():
|
|||||||
method=req.method, path=req.path,
|
method=req.method, path=req.path,
|
||||||
status_code=res.status_code))
|
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:
|
||||||
|
res = self.error_handlers[413](req)
|
||||||
|
else:
|
||||||
|
res = 'Payload too large', 413
|
||||||
|
else:
|
||||||
|
f = self.find_route(req)
|
||||||
|
try:
|
||||||
|
res = None
|
||||||
|
if f:
|
||||||
|
for handler in self.before_request_handlers:
|
||||||
|
res = handler(req)
|
||||||
|
if res:
|
||||||
|
break
|
||||||
|
if res is None:
|
||||||
|
res = f(req, **req.url_args)
|
||||||
|
if isinstance(res, tuple):
|
||||||
|
res = Response(*res)
|
||||||
|
elif not isinstance(res, Response):
|
||||||
|
res = Response(res)
|
||||||
|
for handler in self.after_request_handlers:
|
||||||
|
res = handler(req, res) or res
|
||||||
|
elif 404 in self.error_handlers:
|
||||||
|
res = self.error_handlers[404](req)
|
||||||
|
else:
|
||||||
|
res = 'Not found', 404
|
||||||
|
except Exception as exc:
|
||||||
|
print_exception(exc)
|
||||||
|
res = None
|
||||||
|
if exc.__class__ in self.error_handlers:
|
||||||
|
try:
|
||||||
|
res = self.error_handlers[exc.__class__](req, exc)
|
||||||
|
except Exception as exc2: # pragma: no cover
|
||||||
|
print_exception(exc2)
|
||||||
|
if res is None:
|
||||||
|
if 500 in self.error_handlers:
|
||||||
|
res = self.error_handlers[500](req)
|
||||||
|
else:
|
||||||
|
res = 'Internal server error', 500
|
||||||
|
else:
|
||||||
|
res = 'Bad request', 400
|
||||||
|
if isinstance(res, tuple):
|
||||||
|
res = Response(*res)
|
||||||
|
elif not isinstance(res, Response):
|
||||||
|
res = Response(res)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
redirect = Response.redirect
|
redirect = Response.redirect
|
||||||
send_file = Response.send_file
|
send_file = Response.send_file
|
||||||
|
|||||||
141
src/microdot_asgi.py
Normal file
141
src/microdot_asgi.py
Normal 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)
|
||||||
@@ -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):
|
||||||
@@ -34,7 +57,7 @@ class Request(BaseRequest):
|
|||||||
object.
|
object.
|
||||||
"""
|
"""
|
||||||
# request line
|
# request line
|
||||||
line = (await client_stream.readline()).strip().decode()
|
line = (await Request._safe_readline(client_stream)).strip().decode()
|
||||||
if not line: # pragma: no cover
|
if not line: # pragma: no cover
|
||||||
return None
|
return None
|
||||||
method, url, http_version = line.split()
|
method, url, http_version = line.split()
|
||||||
@@ -44,39 +67,65 @@ class Request(BaseRequest):
|
|||||||
headers = {}
|
headers = {}
|
||||||
content_length = 0
|
content_length = 0
|
||||||
while True:
|
while True:
|
||||||
line = (await client_stream.readline()).strip().decode()
|
line = (await Request._safe_readline(
|
||||||
|
client_stream)).strip().decode()
|
||||||
if line == '':
|
if line == '':
|
||||||
break
|
break
|
||||||
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) \
|
body = b''
|
||||||
if 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
|
||||||
|
async def _safe_readline(stream):
|
||||||
|
line = (await stream.readline())
|
||||||
|
if len(line) > Request.max_readline:
|
||||||
|
raise ValueError('line too long')
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
class Response(BaseResponse):
|
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.
|
||||||
|
:param reason: A custom reason phrase to add after the status code. The
|
||||||
|
default is "OK" for responses with a 200 status code and
|
||||||
|
"N/A" for any other status codes.
|
||||||
"""
|
"""
|
||||||
async def write(self, stream):
|
async def write(self, stream):
|
||||||
self.complete()
|
self.complete()
|
||||||
|
|
||||||
# status code
|
# status code
|
||||||
|
reason = self.reason if self.reason is not None else \
|
||||||
|
('OK' if self.status_code == 200 else 'N/A')
|
||||||
await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format(
|
await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format(
|
||||||
status_code=self.status_code,
|
status_code=self.status_code, reason=reason).encode())
|
||||||
reason='OK' if self.status_code == 200 else 'N/A').encode())
|
|
||||||
|
|
||||||
# headers
|
# headers
|
||||||
for header, value in self.headers.items():
|
for header, value in self.headers.items():
|
||||||
@@ -87,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'):
|
|
||||||
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):
|
||||||
@@ -155,14 +238,21 @@ 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(
|
||||||
host=host, port=port))
|
host=host, port=port))
|
||||||
|
|
||||||
self.server = await asyncio.start_server(serve, host, port)
|
self.server = await asyncio.start_server(serve, host, port)
|
||||||
await self.server.wait_closed()
|
while True:
|
||||||
|
try:
|
||||||
|
await self.server.wait_closed()
|
||||||
|
break
|
||||||
|
except AttributeError: # pragma: no cover
|
||||||
|
# the task hasn't been initialized in the server object yet
|
||||||
|
# wait a bit and try again
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
def run(self, host='0.0.0.0', port=5000, debug=False):
|
def run(self, host='0.0.0.0', port=5000, debug=False):
|
||||||
"""Start the web server. This function does not normally return, as
|
"""Start the web server. This function does not normally return, as
|
||||||
@@ -198,59 +288,83 @@ 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 = await Request.create(self, reader,
|
req = None
|
||||||
writer.get_extra_info('peername'))
|
try:
|
||||||
if req:
|
req = await Request.create(self, reader,
|
||||||
f = self.find_route(req)
|
writer.get_extra_info('peername'))
|
||||||
try:
|
except Exception as exc: # pragma: no cover
|
||||||
res = None
|
print_exception(exc)
|
||||||
if f:
|
|
||||||
for handler in self.before_request_handlers:
|
res = await self.dispatch_request(req)
|
||||||
res = await self._invoke_handler(handler, req)
|
await res.write(writer)
|
||||||
if res:
|
try:
|
||||||
break
|
await writer.aclose()
|
||||||
if res is None:
|
except OSError as exc: # pragma: no cover
|
||||||
res = await self._invoke_handler(
|
if exc.errno == 32: # errno.EPIPE
|
||||||
f, req, **req.url_args)
|
pass
|
||||||
if isinstance(res, tuple):
|
else:
|
||||||
res = Response(*res)
|
raise
|
||||||
elif not isinstance(res, Response):
|
|
||||||
res = Response(res)
|
|
||||||
for handler in self.after_request_handlers:
|
|
||||||
res = await self._invoke_handler(
|
|
||||||
handler, req, res) or res
|
|
||||||
elif 404 in self.error_handlers:
|
|
||||||
res = await self._invoke_handler(
|
|
||||||
self.error_handlers[404], req)
|
|
||||||
else:
|
|
||||||
res = 'Not found', 404
|
|
||||||
except Exception as exc:
|
|
||||||
print_exception(exc)
|
|
||||||
res = None
|
|
||||||
if exc.__class__ in self.error_handlers:
|
|
||||||
try:
|
|
||||||
res = await self._invoke_handler(
|
|
||||||
self.error_handlers[exc.__class__], req, exc)
|
|
||||||
except Exception as exc2: # pragma: no cover
|
|
||||||
print_exception(exc2)
|
|
||||||
if res is None:
|
|
||||||
if 500 in self.error_handlers:
|
|
||||||
res = await self._invoke_handler(
|
|
||||||
self.error_handlers[500], req)
|
|
||||||
else:
|
|
||||||
res = 'Internal server error', 500
|
|
||||||
if isinstance(res, tuple):
|
|
||||||
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
|
if self.debug and req: # pragma: no cover
|
||||||
print('{method} {path} {status_code}'.format(
|
print('{method} {path} {status_code}'.format(
|
||||||
method=req.method, path=req.path,
|
method=req.method, path=req.path,
|
||||||
status_code=res.status_code))
|
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:
|
||||||
|
res = await self._invoke_handler(
|
||||||
|
self.error_handlers[413], req)
|
||||||
|
else:
|
||||||
|
res = 'Payload too large', 413
|
||||||
|
else:
|
||||||
|
f = self.find_route(req)
|
||||||
|
try:
|
||||||
|
res = None
|
||||||
|
if f:
|
||||||
|
for handler in self.before_request_handlers:
|
||||||
|
res = await self._invoke_handler(handler, req)
|
||||||
|
if res:
|
||||||
|
break
|
||||||
|
if res is None:
|
||||||
|
res = await self._invoke_handler(
|
||||||
|
f, req, **req.url_args)
|
||||||
|
if isinstance(res, tuple):
|
||||||
|
res = Response(*res)
|
||||||
|
elif not isinstance(res, Response):
|
||||||
|
res = Response(res)
|
||||||
|
for handler in self.after_request_handlers:
|
||||||
|
res = await self._invoke_handler(
|
||||||
|
handler, req, res) or res
|
||||||
|
elif 404 in self.error_handlers:
|
||||||
|
res = await self._invoke_handler(
|
||||||
|
self.error_handlers[404], req)
|
||||||
|
else:
|
||||||
|
res = 'Not found', 404
|
||||||
|
except Exception as exc:
|
||||||
|
print_exception(exc)
|
||||||
|
res = None
|
||||||
|
if exc.__class__ in self.error_handlers:
|
||||||
|
try:
|
||||||
|
res = await self._invoke_handler(
|
||||||
|
self.error_handlers[exc.__class__], req, exc)
|
||||||
|
except Exception as exc2: # pragma: no cover
|
||||||
|
print_exception(exc2)
|
||||||
|
if res is None:
|
||||||
|
if 500 in self.error_handlers:
|
||||||
|
res = await self._invoke_handler(
|
||||||
|
self.error_handlers[500], req)
|
||||||
|
else:
|
||||||
|
res = 'Internal server error', 500
|
||||||
|
else:
|
||||||
|
res = 'Bad request', 400
|
||||||
|
if isinstance(res, tuple):
|
||||||
|
res = Response(*res)
|
||||||
|
elif not isinstance(res, Response):
|
||||||
|
res = Response(res)
|
||||||
|
return res
|
||||||
|
|
||||||
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)
|
||||||
if _iscoroutine(ret):
|
if _iscoroutine(ret):
|
||||||
|
|||||||
59
src/microdot_wsgi.py
Normal file
59
src/microdot_wsgi.py
Normal 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)
|
||||||
@@ -65,6 +65,19 @@ class TestMicrodot(unittest.TestCase):
|
|||||||
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\nbar'))
|
self.assertTrue(fd.response.endswith(b'\r\n\r\nbar'))
|
||||||
|
|
||||||
|
def test_empty_request(self):
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
mock_socket.clear_requests()
|
||||||
|
fd = mock_socket.FakeStream(b'\n')
|
||||||
|
mock_socket._requests.append(fd)
|
||||||
|
self._add_shutdown(app)
|
||||||
|
app.run()
|
||||||
|
self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n'))
|
||||||
|
self.assertIn(b'Content-Length: 11\r\n', fd.response)
|
||||||
|
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
|
||||||
|
self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request'))
|
||||||
|
|
||||||
def test_method_decorators(self):
|
def test_method_decorators(self):
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@@ -182,6 +195,42 @@ class TestMicrodot(unittest.TestCase):
|
|||||||
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\n404'))
|
self.assertTrue(fd.response.endswith(b'\r\n\r\n404'))
|
||||||
|
|
||||||
|
def test_413(self):
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
mock_socket.clear_requests()
|
||||||
|
fd = mock_socket.add_request('GET', '/foo', body='x' * 17000)
|
||||||
|
self._add_shutdown(app)
|
||||||
|
app.run()
|
||||||
|
self.assertTrue(fd.response.startswith(b'HTTP/1.0 413 N/A\r\n'))
|
||||||
|
self.assertIn(b'Content-Length: 17\r\n', fd.response)
|
||||||
|
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
|
||||||
|
self.assertTrue(fd.response.endswith(b'\r\n\r\nPayload too large'))
|
||||||
|
|
||||||
|
def test_413_handler(self):
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def handle_413(req):
|
||||||
|
return '413', 400
|
||||||
|
|
||||||
|
mock_socket.clear_requests()
|
||||||
|
fd = mock_socket.add_request('GET', '/foo', body='x' * 17000)
|
||||||
|
self._add_shutdown(app)
|
||||||
|
app.run()
|
||||||
|
self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n'))
|
||||||
|
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\n413'))
|
||||||
|
|
||||||
def test_500(self):
|
def test_500(self):
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@@ -237,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'))
|
||||||
|
|||||||
@@ -78,3 +78,51 @@ class TestRequest(unittest.TestCase):
|
|||||||
body='foo=bar&abc=def&x=%2f%%')
|
body='foo=bar&abc=def&x=%2f%%')
|
||||||
req = Request.create('app', fd, 'addr')
|
req = Request.create('app', fd, 'addr')
|
||||||
self.assertIsNone(req.form)
|
self.assertIsNone(req.form)
|
||||||
|
|
||||||
|
def test_large_line(self):
|
||||||
|
saved_max_readline = Request.max_readline
|
||||||
|
Request.max_readline = 16
|
||||||
|
|
||||||
|
fd = get_request_fd('GET', '/foo', headers={
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body='foo=bar&abc=def&x=y')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
Request.create('app', fd, 'addr')
|
||||||
|
|
||||||
|
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):
|
||||||
|
saved_max_content_length = Request.max_content_length
|
||||||
|
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={
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body='foo=bar&abc=def&x=y')
|
||||||
|
req = Request.create('app', fd, 'addr')
|
||||||
|
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_body_length = saved_max_body_length
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -112,6 +124,28 @@ class TestResponse(unittest.TestCase):
|
|||||||
self.assertEqual(res.headers, {'X-Test': 'Foo'})
|
self.assertEqual(res.headers, {'X-Test': 'Foo'})
|
||||||
self.assertEqual(res.body, b'foo')
|
self.assertEqual(res.body, b'foo')
|
||||||
|
|
||||||
|
def test_create_with_reason(self):
|
||||||
|
res = Response('foo', reason='ALL GOOD!')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers, {})
|
||||||
|
self.assertEqual(res.reason, 'ALL GOOD!')
|
||||||
|
self.assertEqual(res.body, b'foo')
|
||||||
|
fd = io.BytesIO()
|
||||||
|
res.write(fd)
|
||||||
|
response = fd.getvalue()
|
||||||
|
self.assertIn(b'HTTP/1.0 200 ALL GOOD!\r\n', response)
|
||||||
|
|
||||||
|
def test_create_with_status_and_reason(self):
|
||||||
|
res = Response('not found', 404, reason='NOT FOUND')
|
||||||
|
self.assertEqual(res.status_code, 404)
|
||||||
|
self.assertEqual(res.headers, {})
|
||||||
|
self.assertEqual(res.reason, 'NOT FOUND')
|
||||||
|
self.assertEqual(res.body, b'not found')
|
||||||
|
fd = io.BytesIO()
|
||||||
|
res.write(fd)
|
||||||
|
response = fd.getvalue()
|
||||||
|
self.assertIn(b'HTTP/1.0 404 NOT FOUND\r\n', response)
|
||||||
|
|
||||||
def test_cookies(self):
|
def test_cookies(self):
|
||||||
res = Response('ok')
|
res = Response('ok')
|
||||||
res.set_cookie('foo1', 'bar1')
|
res.set_cookie('foo1', 'bar1')
|
||||||
@@ -145,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'),
|
||||||
|
|||||||
161
tests/microdot_asgi/test_microdot_asgi.py
Normal file
161
tests/microdot_asgi/test_microdot_asgi.py
Normal 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()
|
||||||
@@ -76,6 +76,19 @@ class TestMicrodotAsync(unittest.TestCase):
|
|||||||
self.assertIn(b'Content-Type: text/plain\r\n', fd2.response)
|
self.assertIn(b'Content-Type: text/plain\r\n', fd2.response)
|
||||||
self.assertTrue(fd2.response.endswith(b'\r\n\r\nbar-async'))
|
self.assertTrue(fd2.response.endswith(b'\r\n\r\nbar-async'))
|
||||||
|
|
||||||
|
def test_empty_request(self):
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
mock_socket.clear_requests()
|
||||||
|
fd = mock_socket.FakeStream(b'\n')
|
||||||
|
mock_socket._requests.append(fd)
|
||||||
|
self._add_shutdown(app)
|
||||||
|
app.run()
|
||||||
|
self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n'))
|
||||||
|
self.assertIn(b'Content-Length: 11\r\n', fd.response)
|
||||||
|
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
|
||||||
|
self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request'))
|
||||||
|
|
||||||
def test_before_after_request(self):
|
def test_before_after_request(self):
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@@ -160,6 +173,42 @@ class TestMicrodotAsync(unittest.TestCase):
|
|||||||
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\n404'))
|
self.assertTrue(fd.response.endswith(b'\r\n\r\n404'))
|
||||||
|
|
||||||
|
def test_413(self):
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
mock_socket.clear_requests()
|
||||||
|
fd = mock_socket.add_request('GET', '/foo', body='x' * 17000)
|
||||||
|
self._add_shutdown(app)
|
||||||
|
app.run()
|
||||||
|
self.assertTrue(fd.response.startswith(b'HTTP/1.0 413 N/A\r\n'))
|
||||||
|
self.assertIn(b'Content-Length: 17\r\n', fd.response)
|
||||||
|
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
|
||||||
|
self.assertTrue(fd.response.endswith(b'\r\n\r\nPayload too large'))
|
||||||
|
|
||||||
|
def test_413_handler(self):
|
||||||
|
app = Microdot()
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index(req):
|
||||||
|
return 'foo'
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
async def handle_413(req):
|
||||||
|
return '413', 400
|
||||||
|
|
||||||
|
mock_socket.clear_requests()
|
||||||
|
fd = mock_socket.add_request('GET', '/foo', body='x' * 17000)
|
||||||
|
self._add_shutdown(app)
|
||||||
|
app.run()
|
||||||
|
self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n'))
|
||||||
|
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\n413'))
|
||||||
|
|
||||||
def test_500(self):
|
def test_500(self):
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@@ -215,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'))
|
||||||
|
|||||||
@@ -88,3 +88,43 @@ class TestRequestAsync(unittest.TestCase):
|
|||||||
body='foo=bar&abc=def&x=%2f%%')
|
body='foo=bar&abc=def&x=%2f%%')
|
||||||
req = _run(Request.create('app', fd, 'addr'))
|
req = _run(Request.create('app', fd, 'addr'))
|
||||||
self.assertIsNone(req.form)
|
self.assertIsNone(req.form)
|
||||||
|
|
||||||
|
def test_large_line(self):
|
||||||
|
saved_max_readline = Request.max_readline
|
||||||
|
Request.max_readline = 16
|
||||||
|
|
||||||
|
fd = get_async_request_fd('GET', '/foo', headers={
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body='foo=bar&abc=def&x=y')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
_run(Request.create('app', fd, 'addr'))
|
||||||
|
|
||||||
|
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):
|
||||||
|
saved_max_content_length = Request.max_content_length
|
||||||
|
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={
|
||||||
|
'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'')
|
||||||
|
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_body_length = saved_max_body_length
|
||||||
|
|||||||
@@ -85,6 +85,26 @@ class TestResponseAsync(unittest.TestCase):
|
|||||||
self.assertIn(b'Content-Type: application/json\r\n', fd.response)
|
self.assertIn(b'Content-Type: application/json\r\n', fd.response)
|
||||||
self.assertTrue(fd.response.endswith(b'\r\n\r\n[1, "2"]'))
|
self.assertTrue(fd.response.endswith(b'\r\n\r\n[1, "2"]'))
|
||||||
|
|
||||||
|
def test_create_with_reason(self):
|
||||||
|
res = Response('foo', reason='ALL GOOD!')
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.headers, {})
|
||||||
|
self.assertEqual(res.reason, 'ALL GOOD!')
|
||||||
|
self.assertEqual(res.body, b'foo')
|
||||||
|
fd = FakeStreamAsync()
|
||||||
|
_run(res.write(fd))
|
||||||
|
self.assertIn(b'HTTP/1.0 200 ALL GOOD!\r\n', fd.response)
|
||||||
|
|
||||||
|
def test_create_with_status_and_reason(self):
|
||||||
|
res = Response('not found', 404, reason='NOT FOUND')
|
||||||
|
self.assertEqual(res.status_code, 404)
|
||||||
|
self.assertEqual(res.headers, {})
|
||||||
|
self.assertEqual(res.reason, 'NOT FOUND')
|
||||||
|
self.assertEqual(res.body, b'not found')
|
||||||
|
fd = FakeStreamAsync()
|
||||||
|
_run(res.write(fd))
|
||||||
|
self.assertIn(b'HTTP/1.0 404 NOT FOUND\r\n', fd.response)
|
||||||
|
|
||||||
def test_send_file(self):
|
def test_send_file(self):
|
||||||
res = Response.send_file('tests/files/test.txt',
|
res = Response.send_file('tests/files/test.txt',
|
||||||
content_type='text/html')
|
content_type='text/html')
|
||||||
|
|||||||
104
tests/microdot_wsgi/test_microdot_wsgi.py
Normal file
104
tests/microdot_wsgi/test_microdot_wsgi.py
Normal 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()
|
||||||
@@ -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):
|
||||||
|
|||||||
15
tox.ini
15
tox.ini
@@ -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,21 +9,22 @@ 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]
|
||||||
commands=
|
commands=
|
||||||
pip install -e microdot
|
pip install -e .
|
||||||
pip install -e microdot-asyncio
|
pytest -p no:logging --cov=src --cov-branch --cov-report=term-missing
|
||||||
coverage run --branch --include="microdot*.py" -m unittest tests
|
deps=
|
||||||
coverage report --show-missing
|
pytest
|
||||||
deps=coverage
|
pytest-cov
|
||||||
|
|
||||||
[testenv:flake8]
|
[testenv:flake8]
|
||||||
deps=
|
deps=
|
||||||
flake8
|
flake8
|
||||||
commands=
|
commands=
|
||||||
flake8 --ignore=W503 --exclude tests/libs microdot microdot-asyncio tests
|
flake8 --ignore=W503 --exclude tests/libs src tests examples
|
||||||
|
|
||||||
[testenv:upy]
|
[testenv:upy]
|
||||||
whitelist_externals=sh
|
whitelist_externals=sh
|
||||||
|
|||||||
Reference in New Issue
Block a user