This commit is contained in:
Miguel Grinberg
2023-12-22 20:26:07 +00:00
committed by GitHub
parent 7a329d98a8
commit 20ea305fe7
114 changed files with 3868 additions and 6410 deletions

View File

@@ -1,5 +0,0 @@
[run]
omit=
src/microdot_websocket_alt.py
src/microdot_asgi_websocket.py
src/microdot_ssl.py

View File

@@ -1,3 +0,0 @@
[flake8]
select = C,E,F,W,B,B950
per-file-ignores = ./*/__init__.py:F401

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.7', '3.8', '3.9', '3.10', '3.11'] python: ['3.8', '3.9', '3.10', '3.11', '3.12']
fail-fast: false fail-fast: false
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:

View File

@@ -3,9 +3,9 @@
*“The impossibly small web framework for Python and MicroPython”* *“The impossibly small web framework for Python and MicroPython”*
Microdot is a minimalistic Python web framework inspired by Flask, and designed Microdot is a minimalistic Python web framework inspired by Flask. Given its
to run on systems with limited resources such as microcontrollers. It runs on small size, it can run on systems with limited resources such as
standard Python and on MicroPython. microcontrollers. Both standard Python (CPython) and MicroPython are supported.
```python ```python
from microdot import Microdot from microdot import Microdot
@@ -13,13 +13,24 @@ from microdot import Microdot
app = Microdot() app = Microdot()
@app.route('/') @app.route('/')
def index(request): async def index(request):
return 'Hello, world!' return 'Hello, world!'
app.run() app.run()
``` ```
## Migrating to Microdot 2
Version 2 of Microdot incorporates feedback received from users of earlier
releases, and attempts to improve and correct some design decisions that have
proven to be problematic.
For this reason most applications built for earlier versions will need to be
updated to work correctly with Microdot 2. The
[Migration Guide](https://microdot.readthedocs.io/en/stable/migrating.html)
describes the backwards incompatible changes that were made.
## Resources ## Resources
- [Documentation](https://microdot.readthedocs.io/en/latest/) - [Documentation](https://microdot.readthedocs.io/en/stable/)
- [Change Log](https://github.com/miguelgrinberg/microdot/blob/main/CHANGES.md) - [Change Log](https://github.com/miguelgrinberg/microdot/blob/main/CHANGES.md)

View File

@@ -13,103 +13,53 @@ API Reference
.. autoclass:: microdot.Response .. autoclass:: microdot.Response
:members: :members:
.. autoclass:: microdot.NoCaseDict
:members:
.. autoclass:: microdot.MultiDict ``websocket`` extension
:members:
``microdot_asyncio`` module
---------------------------
.. autoclass:: microdot_asyncio.Microdot
:inherited-members:
:members:
.. autoclass:: microdot_asyncio.Request
:inherited-members:
:members:
.. autoclass:: microdot_asyncio.Response
:inherited-members:
:members:
``microdot_utemplate`` module
-----------------------------
.. automodule:: microdot_utemplate
:members:
``microdot_jinja`` module
-------------------------
.. automodule:: microdot_jinja
:members:
``microdot_session`` module
---------------------------
.. automodule:: microdot_session
:members:
``microdot_cors`` module
------------------------
.. automodule:: microdot_cors
:members:
``microdot_websocket`` module
------------------------------
.. automodule:: microdot_websocket
:members:
``microdot_asyncio_websocket`` module
-------------------------------------
.. automodule:: microdot_asyncio_websocket
:members:
``microdot_asgi_websocket`` module
-------------------------------------
.. automodule:: microdot_asgi_websocket
:members:
``microdot_ssl`` module
----------------------- -----------------------
.. automodule:: microdot_ssl .. automodule:: microdot.websocket
:members: :members:
``microdot_test_client`` module ``utemplate`` templating extension
------------------------------- ----------------------------------
.. autoclass:: microdot_test_client.TestClient .. automodule:: microdot.utemplate
:members: :members:
.. autoclass:: microdot_test_client.TestResponse ``jinja`` templating extension
------------------------------
.. automodule:: microdot.jinja
:members: :members:
``microdot_asyncio_test_client`` module ``session`` extension
--------------------------------------- ---------------------
.. autoclass:: microdot_asyncio_test_client.TestClient .. automodule:: microdot.session
:members: :members:
.. autoclass:: microdot_asyncio_test_client.TestResponse ``cors`` extension
------------------
.. automodule:: microdot.cors
:members: :members:
``microdot_wsgi`` module ``test_client`` extension
------------------------ -------------------------
.. autoclass:: microdot_wsgi.Microdot .. automodule:: microdot.test_client
:members:
``asgi`` extension
------------------
.. autoclass:: microdot.asgi.Microdot
:members: :members:
:exclude-members: shutdown, run :exclude-members: shutdown, run
``microdot_asgi`` module ``wsgi`` extension
------------------------ -------------------
.. autoclass:: microdot_asgi.Microdot .. autoclass:: microdot.wsgi.Microdot
:members: :members:
:exclude-members: shutdown, run :exclude-members: shutdown, run

View File

@@ -2,11 +2,11 @@ Core Extensions
--------------- ---------------
Microdot is a highly extensible web application framework. The extensions Microdot is a highly extensible web application framework. The extensions
described in this section are maintained as part of the Microdot project and described in this section are maintained as part of the Microdot project in
can be obtained from the same source code repository. the same source code repository.
Asynchronous Support with Asyncio WebSocket Support
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~
.. list-table:: .. list-table::
:align: left :align: left
@@ -15,35 +15,71 @@ Asynchronous Support with Asyncio
- | CPython & MicroPython - | CPython & MicroPython
* - Required Microdot source files * - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_ - | `websocket.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/websocket.py>`_
| `microdot_asyncio.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_asyncio.py>`_
* - Required external dependencies * - Required external dependencies
- | CPython: None - | None
| MicroPython: `uasyncio <https://github.com/micropython/micropython/tree/master/extmod/uasyncio>`_
* - Examples * - Examples
- | `hello_async.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_async.py>`_ - | `echo.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo.py>`_
Microdot can be extended to use an asynchronous programming model based on the The WebSocket extension gives the application the ability to handle WebSocket
``asyncio`` package. When the :class:`Microdot <microdot_asyncio.Microdot>` requests. The :func:`with_websocket <microdot.websocket.with_websocket>`
class is imported from the ``microdot_asyncio`` package, an asynchronous server decorator is used to mark a route handler as a WebSocket handler. Decorated
is used, and handlers can be defined as coroutines. routes receive a WebSocket object as a second argument. The WebSocket object
provides ``send()`` and ``receive()`` asynchronous methods to send and receive
messages respectively.
The example that follows uses ``asyncio`` coroutines for concurrency:: Example::
from microdot_asyncio import Microdot @app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
app = Microdot() Server-Sent Events Support
~~~~~~~~~~~~~~~~~~~~~~~~~~
@app.route('/') .. list-table::
async def hello(request): :align: left
return 'Hello, world!'
app.run() * - Compatibility
- | CPython & MicroPython
Rendering HTML Templates * - Required Microdot source files
~~~~~~~~~~~~~~~~~~~~~~~~ - | `sse.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/sse.py>`_
* - Required external dependencies
- | None
* - Examples
- | `counter.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/sse/counter.py>`_
The Server-Sent Events (SSE) extension simplifies the creation of a streaming
endpoint that follows the SSE web standard. The :func:`with_sse <microdot.sse.with_sse>`
decorator is used to mark a route as an SSE handler. Decorated routes receive
an SSE object as second argument. The SSE object provides a ``send()``
asynchronous method to send an event to the client.
Example::
@app.route('/events')
@with_sse
async def events(request, sse):
for i in range(10):
await asyncio.sleep(1)
await sse.send({'counter': i}) # unnamed event
await sse.send('end', event='comment') # named event
.. note::
The SSE protocol is unidirectional, so there is no ``receive()`` method in
the SSE object. For bidirectional communication with the client, use the
WebSocket extension.
Rendering Templates
~~~~~~~~~~~~~~~~~~~
Many web applications use HTML templates for rendering content to clients. Many web applications use HTML templates for rendering content to clients.
Microdot includes extensions to render templates with the Microdot includes extensions to render templates with the
@@ -61,35 +97,41 @@ Using the uTemplate Engine
- | CPython & MicroPython - | CPython & MicroPython
* - Required Microdot source files * - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_ - | `utemplate.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/utemplate.py>`_
| `microdot_utemplate.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_utemplate.py>`_
* - Required external dependencies * - Required external dependencies
- | `utemplate <https://github.com/pfalcon/utemplate/tree/master/utemplate>`_ - | `utemplate <https://github.com/pfalcon/utemplate/tree/master/utemplate>`_
* - Examples * - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello.py>`_ - | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello.py>`_
| `hello_utemplate_async.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_utemplate_async.py>`_
The :func:`render_template <microdot_utemplate.render_template>` function is The :class:`Template <microdot.utemplate.Template>` class is used to load a
used to render HTML templates with the uTemplate engine. The first argument is template. The argument is the template filename, relative to the templates
the template filename, relative to the templates directory, which is directory, which is *templates* by default.
*templates* by default. Any additional arguments are passed to the template
engine to be used as arguments. The ``Template`` object has a :func:`render() <microdot.utemplate.Template.render>`
method that renders the template to a string. This method receives any
arguments that are used by the template.
Example:: Example::
from microdot_utemplate import render_template from microdot.utemplate import Template
@app.get('/') @app.get('/')
def index(req): async def index(req):
return render_template('index.html') return Template('index.html').render()
The ``Template`` object also has a :func:`generate() <microdot.utemplate.Template.generate>`
method, which returns a generator instead of a string. The
:func:`render_async() <microdot.utemplate.Template.render_async>` and
:func:`generate_async() <microdot.utemplate.Template.generate_async>` methods
are the asynchronous versions of these two methods.
The default location from where templates are loaded is the *templates* The default location from where templates are loaded is the *templates*
subdirectory. This location can be changed with the subdirectory. This location can be changed with the
:func:`init_templates <microdot_utemplate.init_templates>` function:: :func:`init_templates <microdot.utemplate.init_templates>` function::
from microdot_utemplate import init_templates from microdot.utemplate import init_templates
init_templates('my_templates') init_templates('my_templates')
@@ -103,8 +145,7 @@ Using the Jinja Engine
- | CPython only - | CPython only
* - Required Microdot source files * - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_ - | `jinja.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/jinja.py>`_
| `microdot_jinja.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_jinja.py>`_
* - Required external dependencies * - Required external dependencies
- | `Jinja2 <https://jinja.palletsprojects.com/>`_ - | `Jinja2 <https://jinja.palletsprojects.com/>`_
@@ -112,28 +153,40 @@ Using the Jinja Engine
* - Examples * - Examples
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello.py>`_ - | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello.py>`_
The :func:`render_template <microdot_jinja.render_template>` function is used The :class:`Template <microdot.jinja.Template>` class is used to load a
to render HTML templates with the Jinja engine. The first argument is the template. The argument is the template filename, relative to the templates
template filename, relative to the templates directory, which is *templates* by directory, which is *templates* by default.
default. Any additional arguments are passed to the template engine to be used
as arguments. The ``Template`` object has a :func:`render() <microdot.jinja.Template.render>`
method that renders the template to a string. This method receives any
arguments that are used by the template.
Example:: Example::
from microdot_jinja import render_template from microdot.jinja import Template
@app.get('/') @app.get('/')
def index(req): async def index(req):
return render_template('index.html') return Template('index.html').render()
The ``Template`` object also has a :func:`generate() <microdot.jinja.Template.generate>`
method, which returns a generator instead of a string.
The default location from where templates are loaded is the *templates* The default location from where templates are loaded is the *templates*
subdirectory. This location can be changed with the subdirectory. This location can be changed with the
:func:`init_templates <microdot_jinja.init_templates>` function:: :func:`init_templates <microdot.utemplate.init_templates>` function::
from microdot_jinja import init_templates from microdot.jinja import init_templates
init_templates('my_templates') init_templates('my_templates')
The ``init_templates()`` function also accepts ``enable_async`` argument, which
can be set to ``True`` if asynchronous rendering of templates is desired. If
this option is enabled, then the
:func:`render_async() <microdot.utemplate.Template.render_async>` and
:func:`generate_async() <microdot.utemplate.Template.generate_async>` methods
must be used.
.. note:: .. note::
The Jinja extension is not compatible with MicroPython. The Jinja extension is not compatible with MicroPython.
@@ -147,56 +200,48 @@ Maintaining Secure User Sessions
- | CPython & MicroPython - | CPython & MicroPython
* - Required Microdot source files * - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_ - | `session.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/session.py>`_
| `microdot_session.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_session.py>`_
* - Required external dependencies * - Required external dependencies
- | CPython: `PyJWT <https://pyjwt.readthedocs.io/>`_ - | CPython: `PyJWT <https://pyjwt.readthedocs.io/>`_
| MicroPython: `jwt.py <https://github.com/micropython/micropython-lib/blob/master/python-ecosys/pyjwt/jwt.py>`_, | MicroPython: `jwt.py <https://github.com/micropython/micropython-lib/blob/master/python-ecosys/pyjwt/jwt.py>`_,
`hmac <https://github.com/micropython/micropython-lib/blob/master/python-stdlib/hmac/hmac.py>`_ `hmac.py <https://github.com/micropython/micropython-lib/blob/master/python-stdlib/hmac/hmac.py>`_
* - Examples * - Examples
- | `login.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/sessions/login.py>`_ - | `login.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/sessions/login.py>`_
The session extension provides a secure way for the application to maintain The session extension provides a secure way for the application to maintain
user sessions. The session is stored as a signed cookie in the client's user sessions. The session data is stored as a signed cookie in the client's
browser, in `JSON Web Token (JWT) <https://en.wikipedia.org/wiki/JSON_Web_Token>`_ browser, in `JSON Web Token (JWT) <https://en.wikipedia.org/wiki/JSON_Web_Token>`_
format. format.
To work with user sessions, the application first must configure the secret key To work with user sessions, the application first must configure a secret key
that will be used to sign the session cookies. It is very important that this that will be used to sign the session cookies. It is very important that this
key is kept secret. An attacker who is in possession of this key can generate key is kept secret, as its name implies. An attacker who is in possession of
valid user session cookies with any contents. this key can generate valid user session cookies with any contents.
To set the secret key, use the :func:`set_session_secret_key <microdot_session.set_session_secret_key>` function:: To initialize the session extension and configure the secret key, create a
:class:`Session <microdot.session.Session>` object::
from microdot_session import set_session_secret_key Session(app, secret_key='top-secret')
set_session_secret_key('top-secret!') The :func:`with_session <microdot.session.with_session>` decorator is the
most convenient way to retrieve the session at the start of a request::
To :func:`get_session <microdot_session.get_session>`, from microdot import Microdot, redirect
:func:`update_session <microdot_session.update_session>` and from microdot.session import Session, with_session
:func:`delete_session <microdot_session.delete_session>` functions are used
inside route handlers to retrieve, store and delete session data respectively.
The :func:`with_session <microdot_session.with_session>` decorator is provided
as a convenient way to retrieve the session at the start of a route handler.
Example::
from microdot import Microdot
from microdot_session import set_session_secret_key, with_session, \
update_session, delete_session
app = Microdot() app = Microdot()
set_session_secret_key('top-secret') Session(app, secret_key='top-secret')
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
@with_session @with_session
def index(req, session): async def index(req, session):
username = session.get('username') username = session.get('username')
if req.method == 'POST': if req.method == 'POST':
username = req.form.get('username') username = req.form.get('username')
update_session(req, {'username': username}) session['username'] = username
session.save()
return redirect('/') return redirect('/')
if username is None: if username is None:
return 'Not logged in' return 'Not logged in'
@@ -204,10 +249,15 @@ Example::
return 'Logged in as ' + username return 'Logged in as ' + username
@app.post('/logout') @app.post('/logout')
def logout(req): @with_session
delete_session(req) async def logout(req, session):
session.delete()
return redirect('/') return redirect('/')
The :func:`save() <microdot.session.SessionDict.save>` and
:func:`delete() <microdot.session.SessionDict.delete>` methods are used to update
and destroy the user session respectively.
Cross-Origin Resource Sharing (CORS) Cross-Origin Resource Sharing (CORS)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -218,8 +268,7 @@ Cross-Origin Resource Sharing (CORS)
- | CPython & MicroPython - | CPython & MicroPython
* - Required Microdot source files * - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_ - | `cors.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/cors.py>`_
| `microdot_cors.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_cors.py>`_
* - Required external dependencies * - Required external dependencies
- | None - | None
@@ -234,18 +283,18 @@ resources from each other. For example, a web application running on
``https://example.com`` can access resources from ``https://api.example.com``. ``https://example.com`` can access resources from ``https://api.example.com``.
To enable CORS support, create an instance of the To enable CORS support, create an instance of the
:class:`CORS <microdot_cors.CORS>` class and configure the desired options. :class:`CORS <microdot.cors.CORS>` class and configure the desired options.
Example:: Example::
from microdot import Microdot from microdot import Microdot
from microdot_cors import CORS from microdot.cors import CORS
app = Microdot() app = Microdot()
cors = CORS(app, allowed_origins=['https://example.com'], cors = CORS(app, allowed_origins=['https://example.com'],
allow_credentials=True) allow_credentials=True)
WebSocket Support Testing with the Test Client
~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. list-table:: .. list-table::
:align: left :align: left
@@ -254,96 +303,18 @@ WebSocket Support
- | CPython & MicroPython - | CPython & MicroPython
* - Required Microdot source files * - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_ - | `test_client.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/test_client.py>`_
| `microdot_websocket.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_websocket.py>`_
* - Required external dependencies * - Required external dependencies
- | None - | None
* - Examples The Microdot Test Client is a utility class that can be used in tests to send
- | `echo.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo.py>`_ requests into the application without having to start a web server.
| `echo_wsgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo_wsgi.py>`_
The WebSocket extension provides a way for the application to handle WebSocket
requests. The :func:`websocket <microdot_websocket.with_websocket>` decorator
is used to mark a route handler as a WebSocket handler. The handler receives
a WebSocket object as a second argument. The WebSocket object provides
``send()`` and ``receive()`` methods to send and receive messages respectively.
Example::
@app.route('/echo')
@with_websocket
def echo(request, ws):
while True:
message = ws.receive()
ws.send(message)
.. note::
An unsupported *microdot_websocket_alt.py* module, with the same
interface, is also provided. This module uses the native WebSocket support
in MicroPython that powers the WebREPL, and may provide slightly better
performance for MicroPython low-end boards. This module is not compatible
with CPython.
Asynchronous WebSocket
~~~~~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_
| `microdot_asyncio.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_asyncio.py>`_
| `microdot_websocket.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_websocket.py>`_
| `microdot_asyncio_websocket.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_asyncio_websocket.py>`_
* - Required external dependencies
- | CPython: None
| MicroPython: `uasyncio <https://github.com/micropython/micropython/tree/master/extmod/uasyncio>`_
* - Examples
- | `echo_async.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo_async.py>`_
This extension has the same interface as the synchronous WebSocket extension,
but the ``receive()`` and ``send()`` methods are asynchronous.
.. note::
An unsupported *microdot_asgi_websocket.py* module, with the same
interface, is also provided. This module must be used instead of
*microdot_asyncio_websocket.py* when the ASGI support is used. The
`echo_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo_asgi.py>`_
example shows how to use this module.
HTTPS Support
~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_
| `microdot_ssl.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_ssl.py>`_
* - Examples
- | `hello_tls.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/tls/hello_tls.py>`_
| `hello_async_tls.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/tls/hello_async_tls.py>`_
The ``run()`` function accepts an optional ``ssl`` argument, through which an
initialized ``SSLContext`` object can be passed. MicroPython does not currently
have a ``SSLContext`` implementation, so the ``microdot_ssl`` module provides
a basic implementation that can be used to create a context.
Example:: Example::
from microdot import Microdot from microdot import Microdot
from microdot_ssl import create_ssl_context from microdot.test_client import TestClient
app = Microdot() app = Microdot()
@@ -351,88 +322,13 @@ Example::
def index(req): def index(req):
return 'Hello, World!' return 'Hello, World!'
sslctx = create_ssl_context('cert.der', 'key.der')
app.run(port=4443, debug=True, ssl=sslctx)
.. note::
The ``microdot_ssl`` module is only needed for MicroPython. When used under
CPython, this module creates a standard ``SSLContext`` instance.
.. note::
The ``uasyncio`` library for MicroPython does not currently support TLS, so
this feature is not available for asynchronous applications on that
platform. The ``asyncio`` library for CPython is fully supported.
Test Client
~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_
| `microdot_test_client.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_test_client.py>`_
* - Required external dependencies
- | None
The Microdot Test Client is a utility class that can be used during testing to
send requests into the application.
Example::
from microdot import Microdot
from microdot_test_client import TestClient
app = Microdot()
@app.route('/')
def index(req):
return 'Hello, World!'
def test_app():
client = TestClient(app)
response = client.get('/')
assert response.text == 'Hello, World!'
See the documentation for the :class:`TestClient <microdot_test_client.TestClient>`
class for more details.
Asynchronous Test Client
~~~~~~~~~~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_
| `microdot_asyncio.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_asyncio.py>`_
| `microdot_test_client.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_test_client.py>`_
| `microdot_asyncio_test_client.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_asyncio_test_client.py>`_
* - Required external dependencies
- | None
Similar to the :class:`TestClient <microdot_test_client.TestClient>` class
above, but for asynchronous applications.
Example usage::
from microdot_asyncio_test_client import TestClient
async def test_app(): async def test_app():
client = TestClient(app) client = TestClient(app)
response = await client.get('/') response = await client.get('/')
assert response.text == 'Hello, World!' assert response.text == 'Hello, World!'
See the :class:`reference documentation <microdot_asyncio_test_client.TestClient>` See the documentation for the :class:`TestClient <microdot.test_client.TestClient>`
for details. class for more details.
Deploying on a Production Web Server Deploying on a Production Web Server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -440,54 +336,7 @@ Deploying on a Production Web Server
The ``Microdot`` class creates its own simple web server. This is enough for an The ``Microdot`` class creates its own simple web server. This is enough for an
application deployed with MicroPython, but when using CPython it may be useful application deployed with MicroPython, but when using CPython it may be useful
to use a separate, battle-tested web server. To address this need, Microdot to use a separate, battle-tested web server. To address this need, Microdot
provides extensions that implement the WSGI and ASGI protocols. provides extensions that implement the ASGI and WSGI protocols.
Using a WSGI Web Server
^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython only
* - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_
| `microdot_wsgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_wsgi.py>`_
* - Required external dependencies
- | A WSGI web server, such as `Gunicorn <https://gunicorn.org/>`_.
* - Examples
- | `hello_wsgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_wsgi.py>`_
The ``microdot_wsgi`` module provides an extended ``Microdot`` class that
implements the WSGI protocol and can be used with a compliant WSGI web server
such as `Gunicorn <https://gunicorn.org/>`_ or
`uWSGI <https://uwsgi-docs.readthedocs.io/en/latest/>`_.
To use a WSGI web server, the application must import the
:class:`Microdot <microdot_wsgi.Microdot>` class from the ``microdot_wsgi``
module::
from microdot_wsgi import Microdot
app = Microdot()
@app.route('/')
def index(req):
return 'Hello, World!'
The ``app`` application instance created from this class is a WSGI application
that can be used with any complaint WSGI web server. If the above application
is stored in a file called *test.py*, then the following command runs the
web application using the Gunicorn web server::
gunicorn test:app
When using this WSGI adapter, the ``environ`` dictionary provided by the web
server is available to request handlers as ``request.environ``.
Using an ASGI Web Server Using an ASGI Web Server
^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
@@ -499,25 +348,25 @@ Using an ASGI Web Server
- | CPython only - | CPython only
* - Required Microdot source files * - Required Microdot source files
- | `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot.py>`_ - | `asgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/asgi.py>`_
| `microdot_asyncio.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_asyncio.py>`_
| `microdot_asgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot_asgi.py>`_
* - Required external dependencies * - Required external dependencies
- | An ASGI web server, such as `Uvicorn <https://uvicorn.org/>`_. - | An ASGI web server, such as `Uvicorn <https://uvicorn.org/>`_.
* - Examples * - Examples
- | `hello_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_asgi.py>`_ - | `hello_asgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_asgi.py>`_
| `hello_asgi.py (uTemplate) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello_asgi.py>`_
| `hello_asgi.py (Jinja) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello_asgi.py>`_
| `echo_asgi.py (WebSocket) <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo_asgi.py>`_
The ``microdot_asgi`` module provides an extended ``Microdot`` class that The ``asgi`` module provides an extended ``Microdot`` class that
implements the ASGI protocol and can be used with a compliant ASGI server such implements the ASGI protocol and can be used with a compliant ASGI server such
as `Uvicorn <https://www.uvicorn.org/>`_. as `Uvicorn <https://www.uvicorn.org/>`_.
To use an ASGI web server, the application must import the To use an ASGI web server, the application must import the
:class:`Microdot <microdot_asgi.Microdot>` class from the ``microdot_asgi`` :class:`Microdot <microdot.asgi.Microdot>` class from the ``asgi`` module::
module::
from microdot_asgi import Microdot from microdot.asgi import Microdot
app = Microdot() app = Microdot()
@@ -525,12 +374,67 @@ module::
async def index(req): async def index(req):
return 'Hello, World!' return 'Hello, World!'
The ``app`` application instance created from this class is an ASGI application The ``app`` application instance created from this class can be used as the
that can be used with any complaint ASGI web server. If the above application ASGI callable with any complaint ASGI web server. If the above example
is stored in a file called *test.py*, then the following command runs the application was stored in a file called *test.py*, then the following command
web application using the Uvicorn web server:: runs the web application using the Uvicorn web server::
uvicorn test:app uvicorn test:app
When using this ASGI adapter, the ``scope`` dictionary provided by the web When using the ASGI support, the ``scope`` dictionary provided by the web
server is available to request handlers as ``request.asgi_scope``. server is available to request handlers as ``request.asgi_scope``.
Using a WSGI Web Server
^^^^^^^^^^^^^^^^^^^^^^^
.. list-table::
:align: left
* - Compatibility
- | CPython only
* - Required Microdot source files
- | `wsgi.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/wsgi.py>`_
* - Required external dependencies
- | A WSGI web server, such as `Gunicorn <https://gunicorn.org/>`_.
* - Examples
- | `hello_wsgi.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello_wsgi.py>`_
| `hello_wsgi.py (uTemplate) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/utemplate/hello_wsgi.py>`_
| `hello_wsgi.py (Jinja) <https://github.com/miguelgrinberg/microdot/blob/main/examples/templates/jinja/hello_wsgi.py>`_
| `echo_wsgi.py (WebSocket) <https://github.com/miguelgrinberg/microdot/blob/main/examples/websocket/echo_wsgi.py>`_
The ``wsgi`` module provides an extended ``Microdot`` class that implements the
WSGI protocol and can be used with a compliant WSGI web server such as
`Gunicorn <https://gunicorn.org/>`_ or
`uWSGI <https://uwsgi-docs.readthedocs.io/en/latest/>`_.
To use a WSGI web server, the application must import the
:class:`Microdot <microdot.wsgi.Microdot>` class from the ``wsgi`` module::
from microdot.wsgi import Microdot
app = Microdot()
@app.route('/')
def index(req):
return 'Hello, World!'
The ``app`` application instance created from this class can be used as a WSGI
callbable with any complaint WSGI web server. If the above application
was stored in a file called *test.py*, then the following command runs the
web application using the Gunicorn web server::
gunicorn test:app
When using the WSGI support, the ``environ`` dictionary provided by the web
server is available to request handlers as ``request.environ``.
.. note::
In spite of WSGI being a synchronous protocol, the Microdot application
internally runs under an asyncio event loop. For that reason, the
recommendation to prefer ``async def`` handlers over ``def`` still applies
under WSGI. Consult the :ref:`Concurrency` section for a discussion of how
the two types of functions are handled by Microdot.

110
docs/freezing.rst Normal file
View File

@@ -0,0 +1,110 @@
Cross-Compiling and Freezing Microdot (MicroPython Only)
--------------------------------------------------------
Microdot is a fairly small framework, so its size is not something you need to
be concerned about unless you are working with MicroPython on hardware with a
very small amount of disk space and/or RAM. In such cases every byte counts, so
this section provides some recommendations on how to keep Microdot's footprint
as small as possible.
Choosing What Modules to Install
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Microdot has a modular design that allows you to only install the modules that
your application needs.
For minimal web application support based on the core Microdot web server
without extensions, you can just copy `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/microdot.py>`_
to the source directory on your device. The core Microdot web server does not
have any dependencies, so you don't need to install anything else.
If your application uses some of the provided extensions to the core web
server, then instead of installing *microdot.py* you'll need to create a
*microdot* subdirectory and install the following files in it:
- `__init__.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/__init__.py>`_
- `microdot.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/microdot.py>`_
- Any extension modules that you need from the `microdot <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot>`_ source directory.
Some of the extensions also have dependencies of their own, so you may need to
install those in your device as well (outside of the ``microdot``
subdirectory). Consult the documentation of each extension to learn if any
third-party dependencies are required.
Cross-Compiling
~~~~~~~~~~~~~~~
An issue that is common with low-end microcontroller boards is that they do not
have enough RAM for the MicroPython compiler to compile the source files, but
once the code is compiled they are able to run it without problems.
To address this, MicroPython allows you to cross-compile source files on your
desktop or laptop computer and then upload their compiled versions to the
device. A good strategy is to cross-compile all the dependencies that are used
by your application, since these are not going to be updated very often. If the
goal is to minimize the use of RAM, you can also opt to cross-compile your
application source files.
The MicroPython cross-compiler is available as a package that you can install
on standard Python. You must determine the version of MicroPython that you will
be running on your device, and install the compiler that matches that version.
For example, if you plan to use MicroPython 1.21.0 on your device, you can
install the cross-compiler for this version with the following command::
pip install mpy-cross==1.21.0
Then run the cross-compiler for each source file that you want to compile.
Since the cross-compilation happens on your computer, you will need to have
copies of all the source files you need to compile locally on your disk. Here
is how you can compile the *microdot.py* file, assuming you have a copy in the
current directory in your computer::
mpy-cross microdot.py
The cross-compiler will create a file with the same name as the source file,
but with the extension changed to *.mpy*.
Once you have all your dependencies compiled, you can replace the *.py* files
in your device with their corresponding *.mpy* versions. MicroPython
automatically recognizes *.mpy* files, so there is no need to make any changes
to any source code to start using compiled files.
Freezing
~~~~~~~~
The ultimate option to reduce the size of a MicroPython application is to
"freeze" it. Freezing is a process that takes MicroPython source code (either
dependencies, application code or both), pre-compiles it and incorporates it
into a custom-built MicroPython firmware that is flashed to the device.
Freezing MicroPython modules to firmware has the advantage that the code is
imported directly from the device's ROM, leaving more RAM available for
application use.
The process to create a custom firmware is unfortunately non-trivial and
different depending on the device, so you will need to consult the MicroPython
documentation that applies to your device to learn how to do this.
The part of the process that is common to all devices is the creation of a
`manifest file <https://docs.micropython.org/en/latest/reference/manifest.html>`_
to tell the MicroPython firmware builder which packages and modules to freeze.
For a minimal installation of Microdot consisting only in its *microdot.py*
source file, the manifest file that you need use to build the firmware must
include the following declaration::
module('microdot')
If instead you are working with a version of Microdot that includes some or all
of its extensions, then the manifest file must reference the ``microdot``
package plus any third-party dependencies that are needed. Below is a manifest
file for a complete Microdot installation that includes all the extensions::
package('microdot')
package('utemplate') # required only if templates are used
module('pyjwt') # required only if user sessions are used
In this example, the *microdot* and *utemplate* packages must be available in
the directory where the manifest file is located so that the MicroPython build
can find them. The `pyjwt` module is part of the MicroPython standard library
and will be downloaded as part of the build.

View File

@@ -9,15 +9,17 @@ Microdot
*"The impossibly small web framework for Python and MicroPython"* *"The impossibly small web framework for Python and MicroPython"*
Microdot is a minimalistic Python web framework inspired by Microdot is a minimalistic Python web framework inspired by
`Flask <https://flask.palletsprojects.com/>`_, and designed to run on `Flask <https://flask.palletsprojects.com/>`_. Given its size, it can run on
systems with limited resources such as microcontrollers. It runs on standard systems with limited resources such as microcontrollers. Both standard Python
Python and on `MicroPython <https://micropython.org>`_. (CPython) and `MicroPython <https://micropython.org>`_ are supported.
.. toctree:: .. toctree::
:maxdepth: 3 :maxdepth: 3
intro intro
extensions extensions
migrating
freezing
api api
* :ref:`genindex` * :ref:`genindex`

View File

@@ -1,26 +1,49 @@
Installation Installation
------------ ------------
For standard Python (CPython) projects, Microdot and all of its core extensions The installation method is different depending on the version of Python.
can be installed with ``pip``::
CPython Installation
~~~~~~~~~~~~~~~~~~~~
For use with standard Python (CPython) projects, Microdot and all of its core
extensions are installed with ``pip``::
pip install microdot pip install microdot
For MicroPython, you can install it with ``upip`` if that option is available, MicroPython Installation
but the recommended approach is to manually copy *microdot.py* and any ~~~~~~~~~~~~~~~~~~~~~~~~
desired optional extension source files from the
For MicroPython, the recommended approach is to manually copy the necessary
source files from the
`GitHub repository <https://github.com/miguelgrinberg/microdot/tree/main/src>`_ `GitHub repository <https://github.com/miguelgrinberg/microdot/tree/main/src>`_
into your device, possibly after into your device, ideally after
`compiling <https://docs.micropython.org/en/latest/reference/mpyfiles.html>`_ `compiling <https://docs.micropython.org/en/latest/reference/mpyfiles.html>`_
them to *.mpy* files. These source files can also be them to *.mpy* files. These source files can also be
`frozen <https://docs.micropython.org/en/latest/develop/optimizations.html?highlight=frozen#frozen-bytecode>`_ `frozen <https://docs.micropython.org/en/latest/develop/optimizations.html?highlight=frozen#frozen-bytecode>`_
and incorporated into a custom MicroPython firmware. and incorporated into a custom MicroPython firmware.
Use the following guidelines to know what files to copy:
- For a minimal setup with only the base web server functionality, copy
`microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
into your project.
- For a configuration that includes one or more optional extensions, create a
*microdot* directory in your device and copy the following files:
- `__init__.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/__init__.py>`_
- `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
- any needed `extensions <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot>`_.
Getting Started Getting Started
--------------- ---------------
This section describes the main features of Microdot in an informal manner. For This section describes the main features of Microdot in an informal manner.
detailed reference information, consult the :ref:`API Reference`.
For detailed reference information, consult the :ref:`API Reference`.
If you are familiar with releases of Microdot before 2.x, review the
:ref:`Migration Guide <Migrating to Microdot 2.x from Older Releases>`.
A Simple Microdot Web Server A Simple Microdot Web Server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -32,7 +55,7 @@ The following is an example of a simple web server::
app = Microdot() app = Microdot()
@app.route('/') @app.route('/')
def index(request): async def index(request):
return 'Hello, world!' return 'Hello, world!'
app.run() app.run()
@@ -46,17 +69,23 @@ application.
The ``route()`` decorator takes the path portion of the URL as an The ``route()`` decorator takes the path portion of the URL as an
argument, and maps it to the decorated function, so that the function is called argument, and maps it to the decorated function, so that the function is called
when the client requests the URL. The function is passed a when the client requests the URL.
:class:`Request <microdot.Request>` object as an argument, which provides
access to the information passed by the client. The value returned by the When the function is called, it is passed a :class:`Request <microdot.Request>`
function is sent back to the client as the response. object as an argument, which provides access to the information passed by the
client. The value returned by the function is sent back to the client as the
response.
Microdot is an asynchronous framework that uses the ``asyncio`` package. Route
handler functions can be defined as ``async def`` or ``def`` functions, but
``async def`` functions are recommended for performance.
The :func:`run() <microdot.Microdot.run>` method starts the application's web The :func:`run() <microdot.Microdot.run>` method starts the application's web
server on port 5000 (or the port number passed in the ``port`` argument). This server on port 5000 by default. This method blocks while it waits for
method blocks while it waits for connections from clients. connections from clients.
Running with CPython Running with CPython
~~~~~~~~~~~~~~~~~~~~ ^^^^^^^^^^^^^^^^^^^^
.. list-table:: .. list-table::
:align: left :align: left
@@ -71,17 +100,18 @@ Running with CPython
- | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello.py>`_ - | `hello.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/hello/hello.py>`_
When using CPython, you can start the web server by running the script that When using CPython, you can start the web server by running the script that
defines and runs the application instance:: has the ``app.run()`` call at the bottom::
python main.py python main.py
While the script is running, you can open a web browser and navigate to After starting the script, open a web browser and navigate to
*http://localhost:5000/*, which is the default address for the Microdot web *http://localhost:5000/* to access the application at the default address for
server. From other computers in the same network, use the IP address or the Microdot web server. From other computers in the same network, use the IP
hostname of the computer running the script instead of ``localhost``. address or hostname of the computer running the script instead of
``localhost``.
Running with MicroPython Running with MicroPython
~~~~~~~~~~~~~~~~~~~~~~~~ ^^^^^^^^^^^^^^^^^^^^^^^^
.. list-table:: .. list-table::
:align: left :align: left
@@ -97,11 +127,13 @@ Running with MicroPython
| `gpio.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/gpio/gpio.py>`_ | `gpio.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/gpio/gpio.py>`_
When using MicroPython, you can upload a *main.py* file containing the web When using MicroPython, you can upload a *main.py* file containing the web
server code to your device along with *microdot.py*. MicroPython will server code to your device, along with the required Microdot files, as defined
automatically run *main.py* when the device is powered on, so the web server in the :ref:`MicroPython Installation` section.
will automatically start. The application can be accessed on port 5000 at the
device's IP address. As indicated above, the port can be changed by passing the MicroPython will automatically run *main.py* when the device is powered on, so
``port`` argument to the ``run()`` method. the web server will automatically start. The application can be accessed on
port 5000 at the device's IP address. As indicated above, the port can be
changed by passing the ``port`` argument to the ``run()`` method.
.. note:: .. note::
Microdot does not configure the network interface of the device in which it Microdot does not configure the network interface of the device in which it
@@ -109,6 +141,41 @@ device's IP address. As indicated above, the port can be changed by passing the
advance, for example to a Wi-Fi access point, this must be configured before advance, for example to a Wi-Fi access point, this must be configured before
the ``run()`` method is invoked. the ``run()`` method is invoked.
Web Server Configuration
^^^^^^^^^^^^^^^^^^^^^^^^
The :func:`run() <microdot.Microdot.run>` method supports a few arguments to
configure the web server.
- ``port``: The port number to listen on. Pass the desired port number in this
argument to use a port different than the default of 5000. For example::
app.run(port=6000)
- ``host``: The IP address of the network interface to listen on. By default
the server listens on all available interfaces. To listen only on the local
loopback interface, pass ``'127.0.0.1'`` as value for this argument.
- ``debug``: when set to ``True``, the server ouputs logging information to the
console. The default is ``False``.
- ``ssl``: an ``SSLContext`` instance that configures the server to use TLS
encryption, or ``None`` to disable TLS use. The default is ``None``. The
following example demonstrates how to configure the server with an SSL
certificate stored in *cert.pem* and *key.pem* files::
import ssl
# ...
sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sslctx.load_cert_chain('cert.pem', 'key.pem')
app.run(port=4443, debug=True, ssl=sslctx)
.. note::
The ``ssl`` argument can only be used with CPython at this time, because
MicroPython's asyncio module does not currently support SSL certificates or
TLS encryption. Work on this is
`in progress <https://github.com/micropython/micropython/pull/11897>`_.
Defining Routes Defining Routes
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
@@ -119,7 +186,7 @@ to the decorator is the path portion of the URL.
The following example creates a route for the root URL of the application:: The following example creates a route for the root URL of the application::
@app.route('/') @app.route('/')
def index(request): async def index(request):
return 'Hello, world!' return 'Hello, world!'
When a client requests the root URL (for example, *http://localhost:5000/*), When a client requests the root URL (for example, *http://localhost:5000/*),
@@ -127,11 +194,11 @@ Microdot will call the ``index()`` function, passing it a
:class:`Request <microdot.Request>` object. The return value of the function :class:`Request <microdot.Request>` object. The return value of the function
is the response that is sent to the client. is the response that is sent to the client.
Below is a another example, this one with a route for a URL with two components Below is another example, this one with a route for a URL with two components
in its path:: in its path::
@app.route('/users/active') @app.route('/users/active')
def active_users(request): async def active_users(request):
return 'Active users: Susan, Joe, and Bob' return 'Active users: Susan, Joe, and Bob'
The complete URL that maps to this route is The complete URL that maps to this route is
@@ -144,46 +211,49 @@ request.
Choosing the HTTP Method Choosing the HTTP Method
^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
All the example routes shown above are associated with ``GET`` requests. But All the example routes shown above are associated with ``GET`` requests, which
applications often need to define routes for other HTTP methods, such as are the default. Applications often need to define routes for other HTTP
``POST``, ``PUT``, ``PATCH`` and ``DELETE``. The ``route()`` decorator takes a methods, such as ``POST``, ``PUT``, ``PATCH`` and ``DELETE``. The ``route()``
``methods`` optional argument, in which the application can provide a list of decorator takes a ``methods`` optional argument, in which the application can
HTTP methods that the route should be associated with on the given path. provide a list of HTTP methods that the route should be associated with on the
given path.
The following example defines a route that handles ``GET`` and ``POST`` The following example defines a route that handles ``GET`` and ``POST``
requests within the same function:: requests within the same function::
@app.route('/invoices', methods=['GET', 'POST']) @app.route('/invoices', methods=['GET', 'POST'])
def invoices(request): async def invoices(request):
if request.method == 'GET': if request.method == 'GET':
return 'get invoices' return 'get invoices'
elif request.method == 'POST': elif request.method == 'POST':
return 'create an invoice' return 'create an invoice'
In cases like the above, where a single URL is used to handle multiple HTTP As an alternative to the example above, in which a single function is used to
methods, it may be desirable to write a separate function for each HTTP method. handle multiple HTTP methods, sometimes it may be desirable to write a separate
The above example can be implemented with two routes as follows:: function for each HTTP method. The above example can be implemented with two
routes as follows::
@app.route('/invoices', methods=['GET']) @app.route('/invoices', methods=['GET'])
def get_invoices(request): async def get_invoices(request):
return 'get invoices' return 'get invoices'
@app.route('/invoices', methods=['POST']) @app.route('/invoices', methods=['POST'])
def create_invoice(request): async def create_invoice(request):
return 'create an invoice' return 'create an invoice'
Microdot provides the :func:`get() <microdot.Microdot.get>`, Microdot provides the :func:`get() <microdot.Microdot.get>`,
:func:`post() <microdot.Microdot.post>`, :func:`put() <microdot.Microdot.put>`, :func:`post() <microdot.Microdot.post>`, :func:`put() <microdot.Microdot.put>`,
:func:`patch() <microdot.Microdot.patch>`, and :func:`patch() <microdot.Microdot.patch>`, and
:func:`delete() <microdot.Microdot.delete>` decorator shortcuts as well. The :func:`delete() <microdot.Microdot.delete>` decorators as shortcuts for the
two example routes above can be written more concisely with them:: corresponding HTTP methods. The two example routes above can be written more
concisely with them::
@app.get('/invoices') @app.get('/invoices')
def get_invoices(request): async def get_invoices(request):
return 'get invoices' return 'get invoices'
@app.post('/invoices') @app.post('/invoices')
def create_invoice(request): async def create_invoice(request):
return 'create an invoice' return 'create an invoice'
Including Dynamic Components in the URL Path Including Dynamic Components in the URL Path
@@ -195,19 +265,19 @@ the following route associates all URLs that have a path following the pattern
*http://localhost:5000/users/<username>* with the ``get_user()`` function:: *http://localhost:5000/users/<username>* with the ``get_user()`` function::
@app.get('/users/<username>') @app.get('/users/<username>')
def get_user(request, username): async def get_user(request, username):
return 'User: ' + username return 'User: ' + username
As shown in the example, a path components that is enclosed in angle brackets As shown in the example, a path component that is enclosed in angle brackets
is considered dynamic. Microdot accepts any values for that section of the URL is considered a placeholder. Microdot accepts any values for that portion of
path, and passes the value received to the function as an argument after the URL path, and passes the value received to the function as an argument
the request object. after the request object.
Routes are not limited to a single dynamic component. The following route shows Routes are not limited to a single dynamic component. The following route shows
how multiple dynamic components can be included in the path:: how multiple dynamic components can be included in the path::
@app.get('/users/<firstname>/<lastname>') @app.get('/users/<firstname>/<lastname>')
def get_user(request, firstname, lastname): async def get_user(request, firstname, lastname):
return 'User: ' + firstname + ' ' + lastname return 'User: ' + firstname + ' ' + lastname
Dynamic path components are considered to be strings by default. An explicit Dynamic path components are considered to be strings by default. An explicit
@@ -216,7 +286,7 @@ a colon. The following route has two dynamic components declared as an integer
and a string respectively:: and a string respectively::
@app.get('/users/<int:id>/<string:username>') @app.get('/users/<int:id>/<string:username>')
def get_user(request, id, username): async def get_user(request, id, username):
return 'User: ' + username + ' (' + str(id) + ')' return 'User: ' + username + ' (' + str(id) + ')'
If a dynamic path component is defined as an integer, the value passed to the If a dynamic path component is defined as an integer, the value passed to the
@@ -225,10 +295,12 @@ integer in the corresponding section of the URL path, then the URL will not
match and the route will not be called. match and the route will not be called.
A special type ``path`` can be used to capture the remainder of the path as a A special type ``path`` can be used to capture the remainder of the path as a
single argument:: single argument. The difference between an argument of type ``path`` and one of
type ``string`` is that the latter stops capturing when a ``/`` appears in the
URL.
@app.get('/tests/<path:path>') @app.get('/tests/<path:path>')
def get_test(request, path): async def get_test(request, path):
return 'Test: ' + path return 'Test: ' + path
For the most control, the ``re`` type allows the application to provide a For the most control, the ``re`` type allows the application to provide a
@@ -237,7 +309,7 @@ a route that only matches usernames that begin with an upper or lower case
letter, followed by a sequence of letters or numbers:: letter, followed by a sequence of letters or numbers::
@app.get('/users/<re:[a-zA-Z][a-zA-Z0-9]*:username>') @app.get('/users/<re:[a-zA-Z][a-zA-Z0-9]*:username>')
def get_user(request, username): async def get_user(request, username):
return 'User: ' + username return 'User: ' + username
.. note:: .. note::
@@ -255,54 +327,56 @@ resource can be obtained from a cache. The
:func:`before_request() <microdot.Microdot.before_request>` decorator registers :func:`before_request() <microdot.Microdot.before_request>` decorator registers
a function to be called before the request is dispatched to the route function. a function to be called before the request is dispatched to the route function.
The following example registers a before request handler that ensures that the The following example registers a before-request handler that ensures that the
client is authenticated before the request is handled:: client is authenticated before the request is handled::
@app.before_request @app.before_request
def authenticate(request): async def authenticate(request):
user = authorize(request) user = authorize(request)
if not user: if not user:
return 'Unauthorized', 401 return 'Unauthorized', 401
request.g.user = user request.g.user = user
Before request handlers receive the request object as an argument. If the Before-request handlers receive the request object as an argument. If the
function returns a value, Microdot sends it to the client as the response, and function returns a value, Microdot sends it to the client as the response, and
does not invoke the route function. This gives before request handlers the does not invoke the route function. This gives before-request handlers the
power to intercept a request if necessary. The example above uses this power to intercept a request if necessary. The example above uses this
technique to prevent an unauthorized user from accessing the requested technique to prevent an unauthorized user from accessing the requested
resource. route.
After request handlers registered with the After-request handlers registered with the
:func:`after_request() <microdot.Microdot.after_request>` decorator are called :func:`after_request() <microdot.Microdot.after_request>` decorator are called
after the route function returns a response. Their purpose is to perform any after the route function returns a response. Their purpose is to perform any
common closing or cleanup tasks. The next example shows a combination of before common closing or cleanup tasks. The next example shows a combination of
and after request handlers that print the time it takes for a request to be before- and after-request handlers that print the time it takes for a request
handled:: to be handled::
@app.before_request @app.before_request
def start_timer(request): async def start_timer(request):
request.g.start_time = time.time() request.g.start_time = time.time()
@app.after_request @app.after_request
def end_timer(request, response): async def end_timer(request, response):
duration = time.time() - request.g.start_time duration = time.time() - request.g.start_time
print(f'Request took {duration:0.2f} seconds') print(f'Request took {duration:0.2f} seconds')
After request handlers receive the request and response objects as arguments. After-request handlers receive the request and response objects as arguments,
The function can return a modified response object to replace the original. If and they can return a modified response object to replace the original. If
the function does not return a value, then the original response object is no value is returned from an after-request handler, then the original response
used. object is used.
The after request handlers are only invoked for successful requests. The The after-request handlers are only invoked for successful requests. The
:func:`after_error_request() <microdot.Microdot.after_error_request>` :func:`after_error_request() <microdot.Microdot.after_error_request>`
decorator can be used to register a function that is called after an error decorator can be used to register a function that is called after an error
occurs. The function receives the request and the error response and is occurs. The function receives the request and the error response and is
expected to return an updated response object. expected to return an updated response object after performing any necessary
cleanup.
.. note:: .. note::
The :ref:`request.g <The "g" Object>` object is a special object that allows The :ref:`request.g <The "g" Object>` object used in many of the above
the before and after request handlers, as well as the route function to examples is a special object that allows the before- and after-request
share data during the life of the request. handlers, as well as the route function to share data during the life of the
request.
Error Handlers Error Handlers
^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
@@ -312,10 +386,11 @@ the client receives an appropriate error response. Some of the common errors
automatically handled by Microdot are: automatically handled by Microdot are:
- 400 for malformed requests. - 400 for malformed requests.
- 404 for URLs that are not defined. - 404 for URLs that are unknown.
- 405 for URLs that are defined, but not for the requested HTTP method. - 405 for URLs that are known, but not implemented for the requested HTTP
method.
- 413 for requests that are larger than the allowed size. - 413 for requests that are larger than the allowed size.
- 500 when the application raises an exception. - 500 when the application raises an unhandled exception.
While the above errors are fully complaint with the HTTP specification, the While the above errors are fully complaint with the HTTP specification, the
application might want to provide custom responses for them. The application might want to provide custom responses for them. The
@@ -324,30 +399,31 @@ functions to respond to specific error codes. The following example shows a
custom error handler for 404 errors:: custom error handler for 404 errors::
@app.errorhandler(404) @app.errorhandler(404)
def not_found(request): async def not_found(request):
return {'error': 'resource not found'}, 404 return {'error': 'resource not found'}, 404
The ``errorhandler()`` decorator has a second form, in which it takes an The ``errorhandler()`` decorator has a second form, in which it takes an
exception class as an argument. Microdot will then invoke the handler when the exception class as an argument. Microdot will invoke the handler when an
exception is an instance of the given class is raised. The next example unhandled exception that is an instance of the given class is raised. The next
provides a custom response for division by zero errors:: example provides a custom response for division by zero errors::
@app.errorhandler(ZeroDivisionError) @app.errorhandler(ZeroDivisionError)
def division_by_zero(request, exception): async def division_by_zero(request, exception):
return {'error': 'division by zero'}, 500 return {'error': 'division by zero'}, 500
When the raised exception class does not have an error handler defined, but When the raised exception class does not have an error handler defined, but
one or more of its base classes do, Microdot makes an attempt to invoke the one or more of its parent classes do, Microdot makes an attempt to invoke the
most specific handler. most specific handler.
Mounting a Sub-Application Mounting a Sub-Application
^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^
Small Microdot applications can be written an a single source file, but this Small Microdot applications can be written as a single source file, but this
is not the best option for applications that past certain size. To make it is not the best option for applications that past a certain size. To make it
simpler to write large applications, Microdot supports the concept of simpler to write large applications, Microdot supports the concept of
sub-applications that can be "mounted" on a larger application, possibly with sub-applications that can be "mounted" on a larger application, possibly with
a common URL prefix applied to all of its routes. a common URL prefix applied to all of its routes. For developers familiar with
the Flask framework, this is a similar concept to Flask's blueprints.
Consider, for example, a *customers.py* sub-application that implements Consider, for example, a *customers.py* sub-application that implements
operations on customers:: operations on customers::
@@ -357,14 +433,14 @@ operations on customers::
customers_app = Microdot() customers_app = Microdot()
@customers_app.get('/') @customers_app.get('/')
def get_customers(request): async def get_customers(request):
# return all customers # return all customers
@customers_app.post('/') @customers_app.post('/')
def new_customer(request): async def new_customer(request):
# create a new customer # create a new customer
In the same way, the *orders.py* sub-application implements operations on Similar to the above, the *orders.py* sub-application implements operations on
customer orders:: customer orders::
from microdot import Microdot from microdot import Microdot
@@ -372,21 +448,21 @@ customer orders::
orders_app = Microdot() orders_app = Microdot()
@orders_app.get('/') @orders_app.get('/')
def get_orders(request): async def get_orders(request):
# return all orders # return all orders
@orders_app.post('/') @orders_app.post('/')
def new_order(request): async def new_order(request):
# create a new order # create a new order
Now the main application, which is stored in *main.py*, can import and mount Now the main application, which is stored in *main.py*, can import and mount
the sub-applications to build the combined application:: the sub-applications to build the larger combined application::
from microdot import Microdot from microdot import Microdot
from customers import customers_app from customers import customers_app
from orders import orders_app from orders import orders_app
def create_app(): async def create_app():
app = Microdot() app = Microdot()
app.mount(customers_app, url_prefix='/customers') app.mount(customers_app, url_prefix='/customers')
app.mount(orders_app, url_prefix='/orders') app.mount(orders_app, url_prefix='/orders')
@@ -399,7 +475,7 @@ The resulting application will have the customer endpoints available at
*/customers/* and the order endpoints available at */orders/*. */customers/* and the order endpoints available at */orders/*.
.. note:: .. note::
Before request, after request and error handlers defined in the Before-request, after-request and error handlers defined in the
sub-application are also copied over to the main application at mount time. sub-application are also copied over to the main application at mount time.
Once installed in the main application, these handlers will apply to the Once installed in the main application, these handlers will apply to the
whole application and not just the sub-application in which they were whole application and not just the sub-application in which they were
@@ -416,16 +492,20 @@ during the handling of a route to gracefully shut down the server when that
request completes. The next example shows how to use this feature:: request completes. The next example shows how to use this feature::
@app.get('/shutdown') @app.get('/shutdown')
def shutdown(request): async def shutdown(request):
request.app.shutdown() request.app.shutdown()
return 'The server is shutting down...' return 'The server is shutting down...'
The request that invokes the ``shutdown()`` method will complete, and then the
server will not accept any new requests and stop once any remaining requests
complete. At this point the ``app.run()`` call will return.
The Request Object The Request Object
~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~
The :class:`Request <microdot.Request>` object encapsulates all the information The :class:`Request <microdot.Request>` object encapsulates all the information
passed by the client. It is passed as an argument to route handlers, as well as passed by the client. It is passed as an argument to route handlers, as well as
to before request, after request and error handlers. to before-request, after-request and error handlers.
Request Attributes Request Attributes
^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
@@ -448,6 +528,9 @@ The request object provides access to the request attributes, including:
the client, as a tuple (host, port). the client, as a tuple (host, port).
- :attr:`app <microdot.Request.app>`: The application instance that created the - :attr:`app <microdot.Request.app>`: The application instance that created the
request. request.
- :attr:`g <microdot.Request.g>`: The ``g`` object, where handlers can store
request-specific data to be shared among handlers. See :ref:`The "g" Object`
for details.
JSON Payloads JSON Payloads
^^^^^^^^^^^^^ ^^^^^^^^^^^^^
@@ -458,7 +541,7 @@ application can access the parsed JSON data using the
to use this attribute:: to use this attribute::
@app.post('/customers') @app.post('/customers')
def create_customer(request): async def create_customer(request):
customer = request.json customer = request.json
# do something with customer # do something with customer
return {'success': True} return {'success': True}
@@ -475,7 +558,7 @@ The request object also supports standard HTML form submissions through the
as a :class:`MultiDict <microdot.MultiDict>` object. Example:: as a :class:`MultiDict <microdot.MultiDict>` object. Example::
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
def index(req): async def index(req):
name = 'Unknown' name = 'Unknown'
if req.method == 'POST': if req.method == 'POST':
name = req.form.get('name') name = req.form.get('name')
@@ -493,9 +576,12 @@ For cases in which neither JSON nor form data is expected, the
:attr:`body <microdot.Request.body>` request attribute returns the entire body :attr:`body <microdot.Request.body>` request attribute returns the entire body
of the request as a byte sequence. of the request as a byte sequence.
If the expected body is too large to fit in memory, the application can use the If the expected body is too large to fit safely in memory, the application can
:attr:`stream <microdot.Request.stream>` request attribute to read the body use the :attr:`stream <microdot.Request.stream>` request attribute to read the
contents as a file-like object. body contents as a file-like object. The
:attr:`max_body_length <microdot.Request.max_body_length>` attribute of the
request object defines the size at which bodies are streamed instead of loaded
into memory.
Cookies Cookies
^^^^^^^ ^^^^^^^
@@ -508,41 +594,40 @@ The "g" Object
^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
Sometimes applications need to store data during the lifetime of a request, so Sometimes applications need to store data during the lifetime of a request, so
that it can be shared between the before or after request handlers and the that it can be shared between the before- and after-request handlers, the
route function. The request object provides the :attr:`g <microdot.Request.g>` route function and any error handlers. The request object provides the
attribute for that purpose. :attr:`g <microdot.Request.g>` attribute for that purpose.
In the following example, a before request handler In the following example, a before request handler authorizes the client and
authorizes the client and stores the username so that the route function can stores the username so that the route function can use it::
use it::
@app.before_request @app.before_request
def authorize(request): async def authorize(request):
username = authenticate_user(request) username = authenticate_user(request)
if not username: if not username:
return 'Unauthorized', 401 return 'Unauthorized', 401
request.g.username = username request.g.username = username
@app.get('/') @app.get('/')
def index(request): async def index(request):
return f'Hello, {request.g.username}!' return f'Hello, {request.g.username}!'
Request-Specific After Request Handlers Request-Specific After-Request Handlers
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Sometimes applications need to perform operations on the response object, Sometimes applications need to perform operations on the response object
before it is sent to the client, for example to set or remove a cookie. A good before it is sent to the client, for example to set or remove a cookie. A good
option to use for this is to define a request-specific after request handler option to use for this is to define a request-specific after-request handler
using the :func:`after_request <microdot.Microdot.after_request>` decorator. using the :func:`after_request <microdot.Microdot.after_request>` decorator.
Request-specific after request handlers are called by Microdot after the route Request-specific after-request handlers are called by Microdot after the route
function returns and all the application's after request handlers have been function returns and all the application-wide after-request handlers have been
called. called.
The next example shows how a cookie can be updated using a request-specific The next example shows how a cookie can be updated using a request-specific
after request handler defined inside a route function:: after-request handler defined inside a route function::
@app.post('/logout') @app.post('/logout')
def logout(request): async def logout(request):
@request.after_request @request.after_request
def reset_session(request, response): def reset_session(request, response):
response.set_cookie('session', '', http_only=True) response.set_cookie('session', '', http_only=True)
@@ -556,22 +641,24 @@ Request Limits
To help prevent malicious attacks, Microdot provides some configuration options To help prevent malicious attacks, Microdot provides some configuration options
to limit the amount of information that is accepted: to limit the amount of information that is accepted:
- :attr:`max_content_length <microdot.Microdot.max_content_length>`: The - :attr:`max_content_length <microdot.Request.max_content_length>`: The
maximum size accepted for the request body, in bytes. When a client sends a maximum size accepted for the request body, in bytes. When a client sends a
request that is larger than this, the server will respond with a 413 error. request that is larger than this, the server will respond with a 413 error.
The default is 16KB. The default is 16KB.
- :attr:`max_body_length <microdot.Microdot.max_body_length>`: The maximum - :attr:`max_body_length <microdot.Request.max_body_length>`: The maximum
size that is loaded in the :attr:`body <microdot.Request.body>` attribute, in size that is loaded in the :attr:`body <microdot.Request.body>` attribute, in
bytes. Requests that have a body that is larger than this size but smaller bytes. Requests that have a body that is larger than this size but smaller
than the size set for ``max_content_length`` can only be accessed through the than the size set for ``max_content_length`` can only be accessed through the
:attr:`stream <microdot.Request.stream>` attribute. The default is also 16KB. :attr:`stream <microdot.Request.stream>` attribute. The default is also 16KB.
- :attr:`max_readline <microdot.Microdot.max_readline>`: The maximum allowed - :attr:`max_readline <microdot.Request.max_readline>`: The maximum allowed
size for a request line, in bytes. The default is 2KB. size for a request line, in bytes. The default is 2KB.
The following example configures the application to accept requests with The following example configures the application to accept requests with
payloads up to 1MB big, but prevents requests that are larger than 8KB from payloads up to 1MB in size, but prevents requests that are larger than 8KB from
being loaded into memory:: being loaded into memory::
from microdot import Request
Request.max_content_length = 1024 * 1024 Request.max_content_length = 1024 * 1024
Request.max_body_length = 8 * 1024 Request.max_body_length = 8 * 1024
@@ -589,25 +676,26 @@ Route functions can return one, two or three values. The first or only value is
always returned to the client in the response body:: always returned to the client in the response body::
@app.get('/') @app.get('/')
def index(request): async def index(request):
return 'Hello, World!' return 'Hello, World!'
In the above example, Microdot issues a standard 200 status code response, and In the above example, Microdot issues a standard 200 status code response, and
inserts the necessary headers. inserts default headers.
The application can provide its own status code as a second value returned from The application can provide its own status code as a second value returned from
the route. The example below returns a 202 status code:: the route to override the 200 default. The example below returns a 202 status
code::
@app.get('/') @app.get('/')
def index(request): async def index(request):
return 'Hello, World!', 202 return 'Hello, World!', 202
The application can also return a third value, a dictionary with additional The application can also return a third value, a dictionary with additional
headers that are added to, or replace the default ones provided by Microdot. headers that are added to, or replace the default ones included by Microdot.
The next example returns an HTML response, instead of a default text response:: The next example returns an HTML response, instead of a default text response::
@app.get('/') @app.get('/')
def index(request): async def index(request):
return '<h1>Hello, World!</h1>', 202, {'Content-Type': 'text/html'} return '<h1>Hello, World!</h1>', 202, {'Content-Type': 'text/html'}
If the application needs to return custom headers, but does not need to change If the application needs to return custom headers, but does not need to change
@@ -615,7 +703,7 @@ the default status code, then it can return two values, omitting the status
code:: code::
@app.get('/') @app.get('/')
def index(request): async def index(request):
return '<h1>Hello, World!</h1>', {'Content-Type': 'text/html'} return '<h1>Hello, World!</h1>', {'Content-Type': 'text/html'}
The application can also return a :class:`Response <microdot.Response>` object The application can also return a :class:`Response <microdot.Response>` object
@@ -631,7 +719,7 @@ automatically format the response as JSON.
Example:: Example::
@app.get('/') @app.get('/')
def index(request): async def index(request):
return {'hello': 'world'} return {'hello': 'world'}
.. note:: .. note::
@@ -647,7 +735,7 @@ creates redirect responses::
from microdot import redirect from microdot import redirect
@app.get('/') @app.get('/')
def index(request): async def index(request):
return redirect('/about') return redirect('/about')
File Responses File Responses
@@ -659,7 +747,7 @@ object for a file::
from microdot import send_file from microdot import send_file
@app.get('/') @app.get('/')
def index(request): async def index(request):
return send_file('/static/index.html') return send_file('/static/index.html')
A suggested caching duration can be returned to the client in the ``max_age`` A suggested caching duration can be returned to the client in the ``max_age``
@@ -668,7 +756,7 @@ argument::
from microdot import send_file from microdot import send_file
@app.get('/') @app.get('/')
def image(request): async def image(request):
return send_file('/static/image.jpg', max_age=3600) # in seconds return send_file('/static/image.jpg', max_age=3600) # in seconds
.. note:: .. note::
@@ -678,7 +766,7 @@ argument::
the project:: the project::
@app.route('/static/<path:path>') @app.route('/static/<path:path>')
def static(request, path): async def static(request, path):
if '..' in path: if '..' in path:
# directory traversal is not allowed # directory traversal is not allowed
return 'Not found', 404 return 'Not found', 404
@@ -688,12 +776,12 @@ Streaming Responses
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
Instead of providing a response as a single value, an application can opt to Instead of providing a response as a single value, an application can opt to
return a response that is generated in chunks by returning a generator. The return a response that is generated in chunks, by returning a Python generator.
example below returns all the numbers in the fibonacci sequence below 100:: The example below returns all the numbers in the fibonacci sequence below 100::
@app.get('/fibonacci') @app.get('/fibonacci')
def fibonacci(request): async def fibonacci(request):
def generate_fibonacci(): async def generate_fibonacci():
a, b = 0, 1 a, b = 0, 1
while a < 100: while a < 100:
yield str(a) + '\n' yield str(a) + '\n'
@@ -701,6 +789,14 @@ example below returns all the numbers in the fibonacci sequence below 100::
return generate_fibonacci() return generate_fibonacci()
.. note::
Under CPython, the generator function can be a ``def`` or ``async def``
function, as well as a class-based generator.
Under MicroPython, asynchronous generator functions are not supported, so
only ``def`` generator functions can be used. Asynchronous class-based
generators are supported.
Changing the Default Response Content Type Changing the Default Response Content Type
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -728,14 +824,14 @@ object to add a properly formatted cookie header to the response.
Given that route functions do not normally work directly with the response Given that route functions do not normally work directly with the response
object, the recommended way to set a cookie is to do it in a object, the recommended way to set a cookie is to do it in a
:ref:`Request-Specific After Request Handler <Request-Specific After Request Handlers>`. :ref:`request-specific after-request handler <Request-Specific After-Request Handlers>`.
Example:: Example::
@app.get('/') @app.get('/')
def index(request): async def index(request):
@request.after_request @request.after_request
def set_cookie(request, response): async def set_cookie(request, response):
response.set_cookie('name', 'value') response.set_cookie('name', 'value')
return response return response
@@ -744,7 +840,7 @@ Example::
Another option is to create a response object directly in the route function:: Another option is to create a response object directly in the route function::
@app.get('/') @app.get('/')
def index(request): async def index(request):
response = Response('Hello, World!') response = Response('Hello, World!')
response.set_cookie('name', 'value') response.set_cookie('name', 'value')
return response return response
@@ -759,15 +855,18 @@ Another option is to create a response object directly in the route function::
Concurrency Concurrency
~~~~~~~~~~~ ~~~~~~~~~~~
By default, Microdot runs in synchronous (single-threaded) mode. However, if Microdot implements concurrency through the ``asyncio`` package. Applications
the ``threading`` module is available, each request will be started on a must ensure their handlers do not block, as this will prevent other concurrent
separate thread and requests will be handled concurrently. requests from being handled.
Be aware that most microcontroller boards support a very limited form of When running under CPython, ``async def`` handler functions run as native
multi-threading that is not appropriate for concurrent request handling. For asyncio tasks, while ``def`` handler functions are executed in a
that reason, use of the `threading <https://github.com/micropython/micropython-lib/blob/master/python-stdlib/threading/threading.py>`_ `thread executor <https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor>`_
module on microcontroller platforms is not recommended. to prevent them from blocking the asynchronous loop.
The :ref:`micropython_asyncio <Asynchronous Support with Asyncio>` extension Under MicroPython the situation is different. Most microcontroller boards
provides a more robust concurrency option that is supported even on low-end implementing MicroPython do not have threading support or executors, so ``def``
MicroPython boards. handler functions in this platform can only run in the main and only thread.
These functions will block the asynchronous loop when they take too long to
complete so ``async def`` handlers properly written to allow other handlers to
run in parallel should be preferred.

142
docs/migrating.rst Normal file
View File

@@ -0,0 +1,142 @@
Migrating to Microdot 2.x from Older Releases
---------------------------------------------
Version 2 of Microdot incorporates feedback received from users of earlier
releases, and attempts to improve and correct some design decisions that have
proven to be problematic.
For this reason most applications built for earlier versions will need to be
updated to work correctly with Microdot 2. This section describes the backwards
incompatible changes that were made.
Code reorganization
~~~~~~~~~~~~~~~~~~~
The Microdot source code has been moved into a ``microdot`` package,
eliminating the need for each extension to be named with a *microdot_* prefix.
As a result of this change, all extensions have been renamed to shorter names.
For example, the *microdot_cors.py* module is now called *cors.py*.
This change affects the way extensions are imported. Instead of this::
from microdot_cors import CORS
the import statement should be::
from microdot.cors import CORS
No more synchronous web server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In earlier releases of Microdot the core web server was built on synchronous
Python, and asynchronous support was enabled with the asyncio extension.
Microdot 2 eliminates the synchronous web server, and implements the core
server logic directly with asyncio, eliminating the need for an asyncio
extension.
Any applications built using the asyncio extension will need to update their
imports from this::
from microdot.asyncio import Microdot
to this::
from microdot import Microdot
Applications that were built using the synchronous web server do not need to
change their imports, but will now work asynchronously. Review the
:ref:`Concurrency` section to learn about the potential issues when using
``def`` function handlers, and the benefits of transitioning to ``async def``
handlers.
Removed extensions
~~~~~~~~~~~~~~~~~~
Some extensions became unnecessary and have been removed or merged with other
extensions:
- *microdot_asyncio.py*: this is now the core web server.
- *microdot_asyncio_websocket.py*: this is now the main WebSocket extension.
- *microdot_asyncio_test_client.py*: this is now the main test client
extension.
- *microdot_asgi_websocket.py*: the functionality in this extension is now
available in the ASGI extension.
- *microdot_ssl.py*: this extension was only used with the synchronous web
server, so it is not needed anymore.
- *microdot_websocket_alt.py*: this extension was only used with the
synchronous web server, so it is not needed anymore.
No more ``render_template()`` function
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The Jinja and uTemplate extensions have been redesigned to work better under
the asynchronous engine, and as a result, the ``render_template()`` function
has been eliminated.
Instead of this::
return render_template('index.html', title='Home')
use this::
return Template('index.html').render(title='Home')
As a result of this change, it is now possible to use asynchronous rendering::
return await Template('index.html').render_async(title='Home')
Also thanks to this redesign, the template can be streamed instead of returned
as a single string::
return Template('index.html').generate(title='Home')
Streamed templates also have an asynchronous version::
return await Template('index.html').generate_async(title='Home')
Class-based user sessions
~~~~~~~~~~~~~~~~~~~~~~~~~
The session extension has been completely redesigned. To initialize session
support for the application, create a ``Session`` object::
app = Microdot()
Session(app, secret_key='top-secret!')
The ``@with_session`` decorator is used to include the session in a request::
@app.get('/')
@with_session
async def index(request, session):
# ...
The ``session`` can be used as a dictionary to retrieve or change the session.
To save the session when it has been modified, call its ``save()`` method::
@app.get('/')
@with_session
async def index(request, session):
# ...
session.save()
return 'OK'
To delete the session, call its ``delete()`` method before returning from the
request.
WSGI extension redesign
~~~~~~~~~~~~~~~~~~~~~~~
Given that the synchronous web server has been removed, the WSGI extension has
been redesigned to work as a synchronous wrapper for the asynchronous web
server.
Applications using the WSGI extension continue to run under an asynchronous
loop and should try to use the recommended ``async def`` handlers, but can be
deployed with standard WSGI servers such as Gunicorn.
WebSocket support when using the WSGI extension is enabled when using a
compatible web server. At this time only Gunicorn is supported for WebSocket.
As before, the WSGI extension is not available under MicroPython.

View File

@@ -4,7 +4,7 @@ app = Microdot()
@app.get('/') @app.get('/')
def index(req): async def index(req):
return {'hello': 'world'} return {'hello': 'world'}

View File

@@ -1,4 +1,4 @@
from microdot_asgi import Microdot from microdot.asgi import Microdot
app = Microdot() app = Microdot()

View File

@@ -1,11 +0,0 @@
from microdot_asyncio import Microdot
app = Microdot()
@app.get('/')
async def index(req):
return {'hello': 'world'}
app.run()

View File

@@ -4,5 +4,5 @@ app = FastAPI()
@app.get('/') @app.get('/')
def index(): async def index():
return {'hello': 'world'} return {'hello': 'world'}

View File

@@ -4,5 +4,5 @@ app = Quart(__name__)
@app.get('/') @app.get('/')
def index(): async def index():
return {'hello': 'world'} return {'hello': 'world'}

View File

@@ -1,4 +1,4 @@
from microdot_wsgi import Microdot from microdot.wsgi import Microdot
app = Microdot() app = Microdot()

View File

@@ -0,0 +1,9 @@
pip-tools
flask
quart
fastapi
gunicorn
uvicorn
requests
psutil
humanize

View File

@@ -1,33 +1,115 @@
aiofiles==0.8.0 #
anyio==3.6.1 # This file is autogenerated by pip-compile with Python 3.12
blinker==1.5 # by the following command:
certifi==2023.7.22 #
charset-normalizer==2.1.0 # pip-compile requirements.in
click==8.1.3 #
fastapi==0.79.0 aiofiles==23.2.1
Flask==2.3.2 # via quart
gunicorn==20.1.0 annotated-types==0.6.0
h11==0.13.0 # via pydantic
anyio==3.7.1
# via
# fastapi
# starlette
blinker==1.7.0
# via
# flask
# quart
build==1.0.3
# via pip-tools
certifi==2023.11.17
# via requests
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via
# flask
# pip-tools
# quart
# uvicorn
fastapi==0.104.1
# via -r requirements.in
flask==3.0.0
# via
# -r requirements.in
# quart
gunicorn==21.2.0
# via -r requirements.in
h11==0.14.0
# via
# hypercorn
# uvicorn
# wsproto
h2==4.1.0 h2==4.1.0
# via hypercorn
hpack==4.0.0 hpack==4.0.0
humanize==4.3.0 # via h2
hypercorn==0.13.2 humanize==4.9.0
# via -r requirements.in
hypercorn==0.15.0
# via quart
hyperframe==6.0.1 hyperframe==6.0.1
idna==3.3 # via h2
idna==3.6
# via
# anyio
# requests
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 # via
MarkupSafe==2.1.1 # flask
microdot # quart
jinja2==3.1.2
# via
# flask
# quart
markupsafe==2.1.3
# via
# jinja2
# quart
# werkzeug
packaging==23.2
# via
# build
# gunicorn
pip-tools==7.3.0
# via -r requirements.in
priority==2.0.0 priority==2.0.0
psutil==5.9.1 # via hypercorn
pydantic==1.9.1 psutil==5.9.6
quart==0.18.0 # via -r requirements.in
pydantic==2.5.2
# via fastapi
pydantic-core==2.14.5
# via pydantic
pyproject-hooks==1.0.0
# via build
quart==0.19.4
# via -r requirements.in
requests==2.31.0 requests==2.31.0
sniffio==1.2.0 # via -r requirements.in
sniffio==1.3.0
# via anyio
starlette==0.27.0 starlette==0.27.0
toml==0.10.2 # via fastapi
typing_extensions==4.3.0 typing-extensions==4.9.0
urllib3==1.26.18 # via
uvicorn==0.18.2 # fastapi
Werkzeug==2.2.3 # pydantic
wsproto==1.1.0 # pydantic-core
urllib3==2.1.0
# via requests
uvicorn==0.24.0.post1
# via -r requirements.in
werkzeug==3.0.1
# via
# flask
# quart
wheel==0.42.0
# via pip-tools
wsproto==1.2.0
# via hypercorn
# The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools

View File

@@ -14,13 +14,8 @@ apps = [
), ),
( (
'micropython mem.py', 'micropython mem.py',
{'MICROPYPATH': '../../src'},
'microdot-micropython-sync'
),
(
'micropython mem_async.py',
{'MICROPYPATH': '../../src:../../libs/micropython'}, {'MICROPYPATH': '../../src:../../libs/micropython'},
'microdot-micropython-async' 'microdot-micropython'
), ),
( (
['python', '-c', 'import time; time.sleep(10)'], ['python', '-c', 'import time; time.sleep(10)'],
@@ -30,47 +25,42 @@ apps = [
( (
'python mem.py', 'python mem.py',
{'PYTHONPATH': '../../src'}, {'PYTHONPATH': '../../src'},
'microdot-cpython-sync' 'microdot-cpython'
),
(
'python mem_async.py',
{'PYTHONPATH': '../../src'},
'microdot-cpython-async'
),
(
'gunicorn --workers 1 --bind :5000 mem_wsgi:app',
{'PYTHONPATH': '../../src'},
'microdot-gunicorn-sync'
), ),
( (
'uvicorn --workers 1 --port 5000 mem_asgi:app', 'uvicorn --workers 1 --port 5000 mem_asgi:app',
{'PYTHONPATH': '../../src'}, {'PYTHONPATH': '../../src'},
'microdot-uvicorn-async' 'microdot-uvicorn'
),
(
'gunicorn --workers 1 --bind :5000 mem_wsgi:app',
{'PYTHONPATH': '../../src'},
'microdot-gunicorn'
), ),
( (
'flask run', 'flask run',
{'FLASK_APP': 'mem_flask.py'}, {'FLASK_APP': 'mem_flask.py'},
'flask-run-sync' 'flask-run'
), ),
( (
'quart run', 'quart run',
{'QUART_APP': 'mem_quart.py'}, {'QUART_APP': 'mem_quart.py'},
'quart-run-async' 'quart-run'
), ),
( (
'gunicorn --workers 1 --bind :5000 mem_flask:app', 'gunicorn --workers 1 --bind :5000 mem_flask:app',
{}, {},
'flask-gunicorn-sync' 'flask-gunicorn'
), ),
( (
'uvicorn --workers 1 --port 5000 mem_quart:app', 'uvicorn --workers 1 --port 5000 mem_quart:app',
{}, {},
'quart-uvicorn-async' 'quart-uvicorn'
), ),
( (
'uvicorn --workers 1 --port 5000 mem_fastapi:app', 'uvicorn --workers 1 --port 5000 mem_fastapi:app',
{}, {},
'fastapi-uvicorn-async' 'fastapi-uvicorn'
), ),
] ]

View File

@@ -1,5 +1,5 @@
from microdot import Microdot from microdot import Microdot
from microdot_cors import CORS from microdot.cors import CORS
app = Microdot() app = Microdot()
CORS(app, allowed_origins=['https://example.org'], allow_credentials=True) CORS(app, allowed_origins=['https://example.org'], allow_credentials=True)

View File

@@ -2,7 +2,7 @@ from microdot import Microdot
app = Microdot() app = Microdot()
htmldoc = '''<!DOCTYPE html> html = '''<!DOCTYPE html>
<html> <html>
<head> <head>
<title>Microdot Example Page</title> <title>Microdot Example Page</title>
@@ -20,12 +20,12 @@ htmldoc = '''<!DOCTYPE html>
@app.route('/') @app.route('/')
def hello(request): async def hello(request):
return htmldoc, 200, {'Content-Type': 'text/html'} return html, 200, {'Content-Type': 'text/html'}
@app.route('/shutdown') @app.route('/shutdown')
def shutdown(request): async def shutdown(request):
request.app.shutdown() request.app.shutdown()
return 'The server is shutting down...' return 'The server is shutting down...'

View File

@@ -1,8 +1,8 @@
from microdot_asgi import Microdot from microdot.asgi import Microdot
app = Microdot() app = Microdot()
htmldoc = '''<!DOCTYPE html> html = '''<!DOCTYPE html>
<html> <html>
<head> <head>
<title>Microdot Example Page</title> <title>Microdot Example Page</title>
@@ -21,7 +21,7 @@ htmldoc = '''<!DOCTYPE html>
@app.route('/') @app.route('/')
async def hello(request): async def hello(request):
return htmldoc, 200, {'Content-Type': 'text/html'} return html, 200, {'Content-Type': 'text/html'}
@app.route('/shutdown') @app.route('/shutdown')

View File

@@ -1,33 +0,0 @@
from microdot_asyncio import Microdot
app = Microdot()
htmldoc = '''<!DOCTYPE html>
<html>
<head>
<title>Microdot Example Page</title>
<meta charset="UTF-8">
</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...'
app.run(debug=True)

View File

@@ -1,8 +1,8 @@
from microdot_wsgi import Microdot from microdot.wsgi import Microdot
app = Microdot() app = Microdot()
htmldoc = '''<!DOCTYPE html> html = '''<!DOCTYPE html>
<html> <html>
<head> <head>
<title>Microdot Example Page</title> <title>Microdot Example Page</title>
@@ -21,7 +21,7 @@ htmldoc = '''<!DOCTYPE html>
@app.route('/') @app.route('/')
def hello(request): def hello(request):
return htmldoc, 200, {'Content-Type': 'text/html'} return html, 200, {'Content-Type': 'text/html'}
@app.route('/shutdown') @app.route('/shutdown')

View File

@@ -1,6 +1,5 @@
from microdot import Microdot, Response, redirect from microdot import Microdot, Response, redirect
from microdot_session import set_session_secret_key, with_session, \ from microdot.session import Session, with_session
update_session, delete_session
BASE_TEMPLATE = '''<!doctype html> BASE_TEMPLATE = '''<!doctype html>
<html> <html>
@@ -29,18 +28,19 @@ LOGGED_IN = '''<p>Hello <b>{username}</b>!</p>
</form>''' </form>'''
app = Microdot() app = Microdot()
set_session_secret_key('top-secret') Session(app, secret_key='top-secret')
Response.default_content_type = 'text/html' Response.default_content_type = 'text/html'
@app.get('/') @app.get('/')
@app.post('/') @app.post('/')
@with_session @with_session
def index(req, session): async def index(req, session):
username = session.get('username') username = session.get('username')
if req.method == 'POST': if req.method == 'POST':
username = req.form.get('username') username = req.form.get('username')
update_session(req, {'username': username}) session['username'] = username
session.save()
return redirect('/') return redirect('/')
if username is None: if username is None:
return BASE_TEMPLATE.format(content=LOGGED_OUT) return BASE_TEMPLATE.format(content=LOGGED_OUT)
@@ -50,8 +50,9 @@ def index(req, session):
@app.post('/logout') @app.post('/logout')
def logout(req): @with_session
delete_session(req) async def logout(req, session):
session.delete()
return redirect('/') return redirect('/')

16
examples/sse/counter.py Normal file
View File

@@ -0,0 +1,16 @@
import asyncio
from microdot import Microdot
from microdot.sse import with_sse
app = Microdot()
@app.route('/events')
@with_sse
async def events(request, sse):
for i in range(10):
await asyncio.sleep(1)
await sse.send({'counter': i})
app.run(debug=True)

View File

@@ -1,15 +1,14 @@
from microdot import Microdot, send_file from microdot import Microdot, send_file
app = Microdot() app = Microdot()
@app.route('/') @app.route('/')
def index(request): async def index(request):
return send_file('static/index.html') return send_file('static/index.html')
@app.route('/static/<path:path>') @app.route('/static/<path:path>')
def static(request, path): async def static(request, path):
if '..' in path: if '..' in path:
# directory traversal is not allowed # directory traversal is not allowed
return 'Not found', 404 return 'Not found', 404

View File

@@ -0,0 +1,10 @@
p {
font-family: Arial, Helvetica, sans-serif;
color: #333333;
}
h1 {
font-family: Arial, Helvetica, sans-serif;
color: #3070b3;
text-align: center;
}

View File

@@ -1,18 +0,0 @@
from microdot_asyncio import Microdot, send_file
app = Microdot()
@app.route('/')
async def index(request):
return send_file('static/index.html')
@app.route('/static/<path:path>')
async def static(request, path):
if '..' in path:
# directory traversal is not allowed
return 'Not found', 404
return send_file('static/' + path)
app.run(debug=True)

View File

@@ -1,8 +1,5 @@
try: import sys
import utime as time import asyncio
except ImportError:
import time
from microdot import Microdot from microdot import Microdot
app = Microdot() app = Microdot()
@@ -14,7 +11,7 @@ for file in ['1.jpg', '2.jpg', '3.jpg']:
@app.route('/') @app.route('/')
def index(request): async def index(request):
return '''<!doctype html> return '''<!doctype html>
<html> <html>
<head> <head>
@@ -29,14 +26,38 @@ def index(request):
@app.route('/video_feed') @app.route('/video_feed')
def video_feed(request): async def video_feed(request):
def stream(): print('Starting video stream.')
yield b'--frame\r\n'
while True: if sys.implementation.name != 'micropython':
for frame in frames: # CPython supports async generator function
yield b'Content-Type: image/jpeg\r\n\r\n' + frame + \ async def stream():
b'\r\n--frame\r\n' try:
time.sleep(1) 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)
except GeneratorExit:
print('Stopping video stream.')
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'
async def aclose(self):
print('Stopping video stream.')
return stream(), 200, {'Content-Type': return stream(), 200, {'Content-Type':
'multipart/x-mixed-replace; boundary=frame'} 'multipart/x-mixed-replace; boundary=frame'}

View File

@@ -1,65 +0,0 @@
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>
<meta charset="UTF-8">
</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

@@ -0,0 +1,18 @@
from microdot import Microdot, Response
from microdot.jinja import template, init_templates
init_templates('templates', enable_async=True)
app = Microdot()
Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = None
if req.method == 'POST':
name = req.form.get('name')
return await template('index.html').render_async(name=name)
if __name__ == '__main__':
app.run()

View File

@@ -1,18 +1,18 @@
from microdot import Microdot, Response from microdot import Microdot, Response
from microdot_jinja import render_template from microdot.jinja import template
app = Microdot() app = Microdot()
Response.default_content_type = 'text/html' Response.default_content_type = 'text/html'
@app.route('/') @app.route('/')
def index(req): async def index(req):
return render_template('page1.html', page='Page 1') return template('page1.html').render(page='Page 1')
@app.route('/page2') @app.route('/page2')
def page2(req): async def page2(req):
return render_template('page2.html', page='Page 2') return template('page2.html').render(page='Page 2')
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -1,16 +1,16 @@
from microdot import Microdot, Response from microdot import Microdot, Response
from microdot_jinja import render_template from microdot.jinja import template
app = Microdot() app = Microdot()
Response.default_content_type = 'text/html' Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
def index(req): async def index(req):
name = None name = None
if req.method == 'POST': if req.method == 'POST':
name = req.form.get('name') name = req.form.get('name')
return render_template('index.html', name=name) return template('index.html').render(name=name)
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -1,5 +1,5 @@
from microdot_asyncio import Microdot, Response from microdot.asgi import Microdot, Response
from microdot_utemplate import render_template from microdot.jinja import template
app = Microdot() app = Microdot()
Response.default_content_type = 'text/html' Response.default_content_type = 'text/html'
@@ -10,7 +10,7 @@ async def index(req):
name = None name = None
if req.method == 'POST': if req.method == 'POST':
name = req.form.get('name') name = req.form.get('name')
return render_template('index.html', name=name) return template('index.html').render(name=name)
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -0,0 +1,17 @@
from microdot.wsgi import Microdot, Response
from microdot.jinja import template
app = Microdot()
Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = None
if req.method == 'POST':
name = req.form.get('name')
return template('index.html').render(name=name)
if __name__ == '__main__':
app.run()

View File

@@ -0,0 +1,17 @@
from microdot import Microdot, Response
from microdot.jinja import template
app = Microdot()
Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = None
if req.method == 'POST':
name = req.form.get('name')
return template('index.html').generate(name=name)
if __name__ == '__main__':
app.run()

View File

@@ -0,0 +1,17 @@
from microdot import Microdot, Response
from microdot.utemplate import template
app = Microdot()
Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = None
if req.method == 'POST':
name = req.form.get('name')
return await template('index.html').render_async(name=name)
if __name__ == '__main__':
app.run()

View File

@@ -1,18 +1,18 @@
from microdot import Microdot, Response from microdot import Microdot, Response
from microdot_utemplate import render_template from microdot.utemplate import template
app = Microdot() app = Microdot()
Response.default_content_type = 'text/html' Response.default_content_type = 'text/html'
@app.route('/') @app.route('/')
def index(req): async def index(req):
return render_template('page1.html', page='Page 1') return template('page1.html').render(page='Page 1')
@app.route('/page2') @app.route('/page2')
def page2(req): async def page2(req):
return render_template('page2.html', page='Page 2') return template('page2.html').render(page='Page 2')
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -1,16 +1,16 @@
from microdot import Microdot, Response from microdot import Microdot, Response
from microdot_utemplate import render_template from microdot.utemplate import template
app = Microdot() app = Microdot()
Response.default_content_type = 'text/html' Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
def index(req): async def index(req):
name = None name = None
if req.method == 'POST': if req.method == 'POST':
name = req.form.get('name') name = req.form.get('name')
return render_template('index.html', name=name) return template('index.html').render(name=name)
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -0,0 +1,17 @@
from microdot.asgi import Microdot, Response
from microdot.utemplate import template
app = Microdot()
Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = None
if req.method == 'POST':
name = req.form.get('name')
return template('index.html').render(name=name)
if __name__ == '__main__':
app.run()

View File

@@ -0,0 +1,17 @@
from microdot.wsgi import Microdot, Response
from microdot.utemplate import template
app = Microdot()
Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = None
if req.method == 'POST':
name = req.form.get('name')
return template('index.html').render(name=name)
if __name__ == '__main__':
app.run()

View File

@@ -0,0 +1,17 @@
from microdot import Microdot, Response
from microdot.utemplate import template
app = Microdot()
Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = None
if req.method == 'POST':
name = req.form.get('name')
return template('index.html').generate(name=name)
if __name__ == '__main__':
app.run()

View File

@@ -1,23 +0,0 @@
import ssl
from microdot_asyncio import Microdot, send_file
from microdot_asyncio_websocket import with_websocket
app = Microdot()
@app.route('/')
def index(request):
return send_file('index.html')
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
data = await ws.receive()
await ws.send(data)
sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sslctx.load_cert_chain('cert.pem', 'key.pem')
app.run(port=4443, debug=True, ssl=sslctx)

View File

@@ -1,24 +0,0 @@
import sys
from microdot import Microdot, send_file
from microdot_websocket import with_websocket
from microdot_ssl import create_ssl_context
app = Microdot()
@app.route('/')
def index(request):
return send_file('index.html')
@app.route('/echo')
@with_websocket
def echo(request, ws):
while True:
data = ws.receive()
ws.send(data)
ext = 'der' if sys.implementation.name == 'micropython' else 'pem'
sslctx = create_ssl_context('cert.' + ext, 'key.' + ext)
app.run(port=4443, debug=True, ssl=sslctx)

View File

@@ -1,9 +1,9 @@
import ssl import ssl
from microdot_asyncio import Microdot from microdot import Microdot
app = Microdot() app = Microdot()
htmldoc = '''<!DOCTYPE html> html = '''<!DOCTYPE html>
<html> <html>
<head> <head>
<title>Microdot Example Page</title> <title>Microdot Example Page</title>
@@ -22,7 +22,7 @@ htmldoc = '''<!DOCTYPE html>
@app.route('/') @app.route('/')
async def hello(request): async def hello(request):
return htmldoc, 200, {'Content-Type': 'text/html'} return html, 200, {'Content-Type': 'text/html'}
@app.route('/shutdown') @app.route('/shutdown')

View File

@@ -1,37 +0,0 @@
import sys
from microdot import Microdot
from microdot_ssl import create_ssl_context
app = Microdot()
htmldoc = '''<!DOCTYPE html>
<html>
<head>
<title>Microdot Example Page</title>
<meta charset="UTF-8">
</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...'
ext = 'der' if sys.implementation.name == 'micropython' else 'pem'
sslctx = create_ssl_context('cert.' + ext, 'key.' + ext)
app.run(port=4443, debug=True, ssl=sslctx)

View File

@@ -1,36 +0,0 @@
<!doctype html>
<html>
<head>
<title>Microdot TLS WebSocket Demo</title>
<meta charset="UTF-8">
</head>
<body>
<h1>Microdot TLS WebSocket Demo</h1>
<div id="log"></div>
<br>
<form id="form">
<label for="text">Input: </label>
<input type="text" id="text" autofocus>
</form>
<script>
const log = (text, color) => {
document.getElementById('log').innerHTML += `<span style="color: ${color}">${text}</span><br>`;
};
const socket = new WebSocket('wss://' + location.host + '/echo');
socket.addEventListener('message', ev => {
log('<<< ' + ev.data, 'blue');
});
socket.addEventListener('close', ev => {
log('<<< closed');
});
document.getElementById('form').onsubmit = ev => {
ev.preventDefault();
const textField = document.getElementById('text');
log('>>> ' + textField.value, 'red');
socket.send(textField.value);
textField.value = '';
};
</script>
</body>
</html>

View File

@@ -5,12 +5,12 @@ Request.max_content_length = 1024 * 1024 # 1MB (change as needed)
@app.get('/') @app.get('/')
def index(request): async def index(request):
return send_file('index.html') return send_file('index.html')
@app.post('/upload') @app.post('/upload')
def upload(request): async def upload(request):
# obtain the filename and size from request headers # obtain the filename and size from request headers
filename = request.headers['Content-Disposition'].split( filename = request.headers['Content-Disposition'].split(
'filename=')[1].strip('"') 'filename=')[1].strip('"')
@@ -22,7 +22,7 @@ def upload(request):
# write the file to the files directory in 1K chunks # write the file to the files directory in 1K chunks
with open('files/' + filename, 'wb') as f: with open('files/' + filename, 'wb') as f:
while size > 0: while size > 0:
chunk = request.stream.read(min(size, 1024)) chunk = await request.stream.read(min(size, 1024))
f.write(chunk) f.write(chunk)
size -= len(chunk) size -= len(chunk)

View File

@@ -1,34 +0,0 @@
from microdot_asyncio import Microdot, send_file, Request
app = Microdot()
Request.max_content_length = 1024 * 1024 # 1MB (change as needed)
@app.get('/')
async def index(request):
return send_file('index.html')
@app.post('/upload')
async def upload(request):
# obtain the filename and size from request headers
filename = request.headers['Content-Disposition'].split(
'filename=')[1].strip('"')
size = int(request.headers['Content-Length'])
# sanitize the filename
filename = filename.replace('/', '_')
# write the file to the files directory in 1K chunks
with open('files/' + filename, 'wb') as f:
while size > 0:
chunk = await request.stream.read(min(size, 1024))
f.write(chunk)
size -= len(chunk)
print('Successfully saved file: ' + filename)
return ''
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -1,20 +1,20 @@
from microdot import Microdot, send_file from microdot import Microdot, send_file
from microdot_websocket import with_websocket from microdot.websocket import with_websocket
app = Microdot() app = Microdot()
@app.route('/') @app.route('/')
def index(request): async def index(request):
return send_file('index.html') return send_file('index.html')
@app.route('/echo') @app.route('/echo')
@with_websocket @with_websocket
def echo(request, ws): async def echo(request, ws):
while True: while True:
data = ws.receive() data = await ws.receive()
ws.send(data) await ws.send(data)
app.run() app.run()

View File

@@ -1,11 +1,10 @@
from microdot_asgi import Microdot, send_file from microdot.asgi import Microdot, send_file, with_websocket
from microdot_asgi_websocket import with_websocket
app = Microdot() app = Microdot()
@app.route('/') @app.route('/')
def index(request): async def index(request):
return send_file('index.html') return send_file('index.html')
@@ -15,3 +14,7 @@ async def echo(request, ws):
while True: while True:
data = await ws.receive() data = await ws.receive()
await ws.send(data) await ws.send(data)
if __name__ == '__main__':
app.run()

View File

@@ -1,20 +0,0 @@
from microdot_asyncio import Microdot, send_file
from microdot_asyncio_websocket import with_websocket
app = Microdot()
@app.route('/')
def index(request):
return send_file('index.html')
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
data = await ws.receive()
await ws.send(data)
app.run()

View File

@@ -1,17 +1,20 @@
from microdot_wsgi import Microdot, send_file from microdot.wsgi import Microdot, send_file, with_websocket
from microdot_websocket import with_websocket
app = Microdot() app = Microdot()
@app.route('/') @app.route('/')
def index(request): async def index(request):
return send_file('index.html') return send_file('index.html')
@app.route('/echo') @app.route('/echo')
@with_websocket @with_websocket
def echo(request, ws): async def echo(request, ws):
while True: while True:
data = ws.receive() data = await ws.receive()
ws.send(data) await ws.send(data)
if __name__ == '__main__':
app.run()

View File

@@ -1,8 +1,6 @@
utemplate utemplate
========= =========
*Release: 1.4.1, Source: https://github.com/pfalcon/utemplate*
`utemplate` is a lightweight and memory-efficient template engine for `utemplate` is a lightweight and memory-efficient template engine for
Python, primarily designed for use with Pycopy, a lightweight Python Python, primarily designed for use with Pycopy, a lightweight Python
implementation (https://github.com/pfalcon/pycopy). It is also fully implementation (https://github.com/pfalcon/pycopy). It is also fully

View File

@@ -1,4 +1,4 @@
# MicroPython uasyncio module # MicroPython asyncio module
# MIT license; Copyright (c) 2019 Damien P. George # MIT license; Copyright (c) 2019 Damien P. George
from .core import * from .core import *
@@ -18,6 +18,7 @@ _attrs = {
"StreamWriter": "stream", "StreamWriter": "stream",
} }
# Lazy loader, effectively does: # Lazy loader, effectively does:
# global attr # global attr
# from .mod import attr # from .mod import attr

View File

@@ -1,4 +1,4 @@
# MicroPython uasyncio module # MicroPython asyncio module
# MIT license; Copyright (c) 2019 Damien P. George # MIT license; Copyright (c) 2019 Damien P. George
from time import ticks_ms as ticks, ticks_diff, ticks_add from time import ticks_ms as ticks, ticks_diff, ticks_add
@@ -6,7 +6,7 @@ import sys, select
# Import TaskQueue and Task, preferring built-in C code over Python code # Import TaskQueue and Task, preferring built-in C code over Python code
try: try:
from _uasyncio import TaskQueue, Task from _asyncio import TaskQueue, Task
except: except:
from .task import TaskQueue, Task from .task import TaskQueue, Task
@@ -30,6 +30,7 @@ _exc_context = {"message": "Task exception wasn't retrieved", "exception": None,
################################################################################ ################################################################################
# Sleep functions # Sleep functions
# "Yield" once, then raise StopIteration # "Yield" once, then raise StopIteration
class SingletonGenerator: class SingletonGenerator:
def __init__(self): def __init__(self):
@@ -132,6 +133,7 @@ class IOQueue:
################################################################################ ################################################################################
# Main run loop # Main run loop
# Ensure the awaitable is a task # Ensure the awaitable is a task
def _promote_to_task(aw): def _promote_to_task(aw):
return aw if isinstance(aw, Task) else create_task(aw) return aw if isinstance(aw, Task) else create_task(aw)
@@ -270,9 +272,9 @@ class Loop:
return Loop._exc_handler return Loop._exc_handler
def default_exception_handler(loop, context): def default_exception_handler(loop, context):
print(context["message"]) print(context["message"], file=sys.stderr)
print("future:", context["future"], "coro=", context["future"].coro) print("future:", context["future"], "coro=", context["future"].coro, file=sys.stderr)
sys.print_exception(context["exception"]) sys.print_exception(context["exception"], sys.stderr)
def call_exception_handler(context): def call_exception_handler(context):
(Loop._exc_handler or Loop.default_exception_handler)(Loop, context) (Loop._exc_handler or Loop.default_exception_handler)(Loop, context)

View File

@@ -1,8 +1,9 @@
# MicroPython uasyncio module # MicroPython asyncio module
# MIT license; Copyright (c) 2019-2020 Damien P. George # MIT license; Copyright (c) 2019-2020 Damien P. George
from . import core from . import core
# Event class for primitive events that can be waited on, set, and cleared # Event class for primitive events that can be waited on, set, and cleared
class Event: class Event:
def __init__(self): def __init__(self):
@@ -23,7 +24,8 @@ class Event:
def clear(self): def clear(self):
self.state = False self.state = False
async def wait(self): # async
def wait(self):
if not self.state: if not self.state:
# Event not set, put the calling task on the event's waiting queue # Event not set, put the calling task on the event's waiting queue
self.waiting.push(core.cur_task) self.waiting.push(core.cur_task)
@@ -38,16 +40,16 @@ class Event:
# that asyncio will poll until a flag is set. # that asyncio will poll until a flag is set.
# Note: Unlike Event, this is self-clearing after a wait(). # Note: Unlike Event, this is self-clearing after a wait().
try: try:
import uio import io
class ThreadSafeFlag(uio.IOBase): class ThreadSafeFlag(io.IOBase):
def __init__(self): def __init__(self):
self.state = 0 self.state = 0
def ioctl(self, req, flags): def ioctl(self, req, flags):
if req == 3: # MP_STREAM_POLL if req == 3: # MP_STREAM_POLL
return self.state * flags return self.state * flags
return None return -1 # Other requests are unsupported
def set(self): def set(self):
self.state = 1 self.state = 1

View File

@@ -1,10 +1,10 @@
# MicroPython uasyncio module # MicroPython asyncio module
# MIT license; Copyright (c) 2019-2022 Damien P. George # MIT license; Copyright (c) 2019-2022 Damien P. George
from . import core from . import core
def _run(waiter, aw): async def _run(waiter, aw):
try: try:
result = await aw result = await aw
status = True status = True
@@ -61,7 +61,8 @@ class _Remove:
pass pass
async def gather(*aws, return_exceptions=False): # async
def gather(*aws, return_exceptions=False):
if not aws: if not aws:
return [] return []
@@ -122,7 +123,7 @@ async def gather(*aws, return_exceptions=False):
# Either this gather was cancelled, or one of the sub-tasks raised an exception with # Either this gather was cancelled, or one of the sub-tasks raised an exception with
# return_exceptions==False, so reraise the exception here. # return_exceptions==False, so reraise the exception here.
if state is not 0: if state:
raise state raise state
# Return the list of return values of each sub-task. # Return the list of return values of each sub-task.

View File

@@ -1,8 +1,9 @@
# MicroPython uasyncio module # MicroPython asyncio module
# MIT license; Copyright (c) 2019-2020 Damien P. George # MIT license; Copyright (c) 2019-2020 Damien P. George
from . import core from . import core
# Lock class for primitive mutex capability # Lock class for primitive mutex capability
class Lock: class Lock:
def __init__(self): def __init__(self):
@@ -28,7 +29,8 @@ class Lock:
# No Task waiting so unlock # No Task waiting so unlock
self.state = 0 self.state = 0
async def acquire(self): # async
def acquire(self):
if self.state != 0: if self.state != 0:
# Lock unavailable, put the calling Task on the waiting queue # Lock unavailable, put the calling Task on the waiting queue
self.waiting.push(core.cur_task) self.waiting.push(core.cur_task)

View File

@@ -1,7 +1,7 @@
# This list of package files doesn't include task.py because that's provided # This list of package files doesn't include task.py because that's provided
# by the C module. # by the C module.
package( package(
"uasyncio", "asyncio",
( (
"__init__.py", "__init__.py",
"core.py", "core.py",
@@ -13,3 +13,6 @@ package(
base_path="..", base_path="..",
opt=3, opt=3,
) )
# Backwards-compatible uasyncio module.
module("uasyncio.py", opt=3)

View File

@@ -1,4 +1,4 @@
# MicroPython uasyncio module # MicroPython asyncio module
# MIT license; Copyright (c) 2019-2020 Damien P. George # MIT license; Copyright (c) 2019-2020 Damien P. George
from . import core from . import core
@@ -26,7 +26,8 @@ class Stream:
# TODO yield? # TODO yield?
self.s.close() self.s.close()
async def read(self, n=-1): # async
def read(self, n=-1):
r = b"" r = b""
while True: while True:
yield core._io_queue.queue_read(self.s) yield core._io_queue.queue_read(self.s)
@@ -38,11 +39,13 @@ class Stream:
return r return r
r += r2 r += r2
async def readinto(self, buf): # async
def readinto(self, buf):
yield core._io_queue.queue_read(self.s) yield core._io_queue.queue_read(self.s)
return self.s.readinto(buf) return self.s.readinto(buf)
async def readexactly(self, n): # async
def readexactly(self, n):
r = b"" r = b""
while n: while n:
yield core._io_queue.queue_read(self.s) yield core._io_queue.queue_read(self.s)
@@ -54,7 +57,8 @@ class Stream:
n -= len(r2) n -= len(r2)
return r return r
async def readline(self): # async
def readline(self):
l = b"" l = b""
while True: while True:
yield core._io_queue.queue_read(self.s) yield core._io_queue.queue_read(self.s)
@@ -73,10 +77,11 @@ class Stream:
buf = buf[ret:] buf = buf[ret:]
self.out_buf += buf self.out_buf += buf
async def drain(self): # async
def drain(self):
if not self.out_buf: if not self.out_buf:
# Drain must always yield, so a tight loop of write+drain can't block the scheduler. # Drain must always yield, so a tight loop of write+drain can't block the scheduler.
return await core.sleep_ms(0) return (yield from core.sleep_ms(0))
mv = memoryview(self.out_buf) mv = memoryview(self.out_buf)
off = 0 off = 0
while off < len(mv): while off < len(mv):
@@ -93,9 +98,11 @@ StreamWriter = Stream
# Create a TCP stream connection to a remote host # Create a TCP stream connection to a remote host
async def open_connection(host, port): #
from uerrno import EINPROGRESS # async
import usocket as socket def open_connection(host, port):
from errno import EINPROGRESS
import socket
ai = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0] # TODO this is blocking! ai = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0] # TODO this is blocking!
s = socket.socket(ai[0], ai[1], ai[2]) s = socket.socket(ai[0], ai[1], ai[2])
@@ -120,20 +127,30 @@ class Server:
await self.wait_closed() await self.wait_closed()
def close(self): def close(self):
# Note: the _serve task must have already started by now due to the sleep
# in start_server, so `state` won't be clobbered at the start of _serve.
self.state = True
self.task.cancel() self.task.cancel()
async def wait_closed(self): async def wait_closed(self):
await self.task await self.task
async def _serve(self, s, cb): async def _serve(self, s, cb):
self.state = False
# Accept incoming connections # Accept incoming connections
while True: while True:
try: try:
yield core._io_queue.queue_read(s) yield core._io_queue.queue_read(s)
except core.CancelledError: except core.CancelledError as er:
# Shutdown server # The server task was cancelled, shutdown server and close socket.
s.close() s.close()
return if self.state:
# If the server was explicitly closed, ignore the cancellation.
return
else:
# Otherwise e.g. the parent task was cancelled, propagate
# cancellation.
raise er
try: try:
s2, addr = s.accept() s2, addr = s.accept()
except: except:
@@ -147,7 +164,7 @@ class Server:
# Helper function to start a TCP stream server, running as a new task # Helper function to start a TCP stream server, running as a new task
# TODO could use an accept-callback on socket read activity instead of creating a task # TODO could use an accept-callback on socket read activity instead of creating a task
async def start_server(cb, host, port, backlog=5): async def start_server(cb, host, port, backlog=5):
import usocket as socket import socket
# Create and bind server socket. # Create and bind server socket.
host = socket.getaddrinfo(host, port)[0] # TODO this is blocking! host = socket.getaddrinfo(host, port)[0] # TODO this is blocking!
@@ -160,6 +177,16 @@ async def start_server(cb, host, port, backlog=5):
# Create and return server object and task. # Create and return server object and task.
srv = Server() srv = Server()
srv.task = core.create_task(srv._serve(s, cb)) srv.task = core.create_task(srv._serve(s, cb))
try:
# Ensure that the _serve task has been scheduled so that it gets to
# handle cancellation.
await core.sleep_ms(0)
except core.CancelledError as er:
# If the parent task is cancelled during this first sleep, then
# we will leak the task and it will sit waiting for the socket, so
# cancel it.
srv.task.cancel()
raise er
return srv return srv

View File

@@ -1,4 +1,4 @@
# MicroPython uasyncio module # MicroPython asyncio module
# MIT license; Copyright (c) 2019-2020 Damien P. George # MIT license; Copyright (c) 2019-2020 Damien P. George
# This file contains the core TaskQueue based on a pairing heap, and the core Task class. # This file contains the core TaskQueue based on a pairing heap, and the core Task class.

View File

@@ -0,0 +1,8 @@
# This module just allows `import uasyncio` to work. It lazy-loads from
# `asyncio` without duplicating its globals dict.
def __getattr__(attr):
import asyncio
return getattr(asyncio, attr)

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import sys import sys
try: try:
import ffi import ffi
except ImportError: except ImportError:
@@ -6,6 +7,7 @@ except ImportError:
_cache = {} _cache = {}
def open(name, maxver=10, extra=()): def open(name, maxver=10, extra=()):
if not ffi: if not ffi:
return None return None
@@ -13,16 +15,18 @@ def open(name, maxver=10, extra=()):
return _cache[name] return _cache[name]
except KeyError: except KeyError:
pass pass
def libs(): def libs():
if sys.platform == "linux": if sys.platform == "linux":
yield '%s.so' % name yield "%s.so" % name
for i in range(maxver, -1, -1): for i in range(maxver, -1, -1):
yield '%s.so.%u' % (name, i) yield "%s.so.%u" % (name, i)
else: else:
for ext in ('dylib', 'dll'): for ext in ("dylib", "dll"):
yield '%s.%s' % (name, ext) yield "%s.%s" % (name, ext)
for n in extra: for n in extra:
yield n yield n
err = None err = None
for n in libs(): for n in libs():
try: try:
@@ -33,9 +37,11 @@ def open(name, maxver=10, extra=()):
err = e err = e
raise err raise err
def libc(): def libc():
return open("libc", 6) return open("libc", 6)
# Find out bitness of the platform, even if long ints are not supported # Find out bitness of the platform, even if long ints are not supported
# TODO: All bitness differences should be removed from micropython-lib, and # TODO: All bitness differences should be removed from micropython-lib, and
# this snippet too. # this snippet too.

View File

@@ -1,76 +1,79 @@
from utime import * from utime import *
from ucollections import namedtuple from micropython import const
import ustruct
import uctypes
import ffi
import ffilib
import array
libc = ffilib.libc() _TS_YEAR = const(0)
_TS_MON = const(1)
_TS_MDAY = const(2)
_TS_HOUR = const(3)
_TS_MIN = const(4)
_TS_SEC = const(5)
_TS_WDAY = const(6)
_TS_YDAY = const(7)
_TS_ISDST = const(8)
# struct tm *gmtime(const time_t *timep); _WDAY = const(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))
# struct tm *localtime(const time_t *timep); _MDAY = const(
# size_t strftime(char *s, size_t max, const char *format, (
# const struct tm *tm); "January",
gmtime_ = libc.func("P", "gmtime", "P") "February",
localtime_ = libc.func("P", "localtime", "P") "March",
strftime_ = libc.func("i", "strftime", "sisP") "April",
mktime_ = libc.func("i", "mktime", "P") "May",
"June",
_struct_time = namedtuple("struct_time", "July",
["tm_year", "tm_mon", "tm_mday", "tm_hour", "tm_min", "tm_sec", "tm_wday", "tm_yday", "tm_isdst"]) "August",
"September",
def _tuple_to_c_tm(t): "October",
return ustruct.pack("@iiiiiiiii", t[5], t[4], t[3], t[2], t[1] - 1, t[0] - 1900, (t[6] + 1) % 7, t[7] - 1, t[8]) "November",
"December",
)
)
def _c_tm_to_tuple(tm): def strftime(datefmt, ts):
t = ustruct.unpack("@iiiiiiiii", tm) from io import StringIO
return _struct_time(t[5] + 1900, t[4] + 1, t[3], t[2], t[1], t[0], (t[6] - 1) % 7, t[7] + 1, t[8])
def struct_time(tm): fmtsp = False
return _struct_time(*tm) ftime = StringIO()
for k in datefmt:
if fmtsp:
def strftime(format, t=None): if k == "a":
if t is None: ftime.write(_WDAY[ts[_TS_WDAY]][0:3])
t = localtime() elif k == "A":
ftime.write(_WDAY[ts[_TS_WDAY]])
buf = bytearray(32) elif k == "b":
l = strftime_(buf, 32, format, _tuple_to_c_tm(t)) ftime.write(_MDAY[ts[_TS_MON] - 1][0:3])
return str(buf[:l], "utf-8") elif k == "B":
ftime.write(_MDAY[ts[_TS_MON] - 1])
elif k == "d":
def localtime(t=None): ftime.write("%02d" % ts[_TS_MDAY])
if t is None: elif k == "H":
t = time() ftime.write("%02d" % ts[_TS_HOUR])
elif k == "I":
t = int(t) ftime.write("%02d" % (ts[_TS_HOUR] % 12))
a = ustruct.pack('l', t) elif k == "j":
tm_p = localtime_(a) ftime.write("%03d" % ts[_TS_YDAY])
return _c_tm_to_tuple(uctypes.bytearray_at(tm_p, 36)) elif k == "m":
ftime.write("%02d" % ts[_TS_MON])
elif k == "M":
def gmtime(t=None): ftime.write("%02d" % ts[_TS_MIN])
if t is None: elif k == "P":
t = time() ftime.write("AM" if ts[_TS_HOUR] < 12 else "PM")
elif k == "S":
t = int(t) ftime.write("%02d" % ts[_TS_SEC])
a = ustruct.pack('l', t) elif k == "w":
tm_p = gmtime_(a) ftime.write(str(ts[_TS_WDAY]))
return _c_tm_to_tuple(uctypes.bytearray_at(tm_p, 36)) elif k == "y":
ftime.write("%02d" % (ts[_TS_YEAR] % 100))
elif k == "Y":
def mktime(tt): ftime.write(str(ts[_TS_YEAR]))
return mktime_(_tuple_to_c_tm(tt)) else:
ftime.write(k)
fmtsp = False
def perf_counter(): elif k == "%":
return time() fmtsp = True
else:
def process_time(): ftime.write(k)
return clock() val = ftime.getvalue()
ftime.close()
return val
daylight = 0
timezone = 0

View File

@@ -300,9 +300,10 @@ class TestResult:
return self.errorsNum == 0 and self.failuresNum == 0 return self.errorsNum == 0 and self.failuresNum == 0
def printErrors(self): def printErrors(self):
print() if self.errors or self.failures:
self.printErrorList(self.errors) print()
self.printErrorList(self.failures) self.printErrorList(self.errors)
self.printErrorList(self.failures)
def printErrorList(self, lst): def printErrorList(self, lst):
sep = "----------------------------------------------------------------------" sep = "----------------------------------------------------------------------"

14
libs/refresh.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# this script downloads updated versions of all the MicroPython libraries
cd micropython
curl https://codeload.github.com/micropython/micropython/tar.gz/master | tar -xz --strip=2 micropython-master/extmod/asyncio
curl https://raw.githubusercontent.com/micropython/micropython-lib/master/python-stdlib/datetime/datetime.py > datetime.py
curl https://raw.githubusercontent.com/micropython/micropython-lib/d8e163bb5f3ef45e71e145c27bc4f207beaad70f/unix-ffi/ffilib/ffilib.py > ffilib.py
curl https://raw.githubusercontent.com/micropython/micropython-lib/master/python-stdlib/hmac/hmac.py > hmac.py
curl https://raw.githubusercontent.com/micropython/micropython-lib/master/python-stdlib/time/time.py > time.py
curl https://raw.githubusercontent.com/micropython/micropython-lib/master/python-stdlib/unittest/unittest/__init__.py > unittest.py
cd ../common
curl https://raw.githubusercontent.com/pfalcon/utemplate/master/README.md > utemplate/README.md
curl https://codeload.github.com/pfalcon/utemplate/tar.gz/master | tar -xz --strip=1 utemplate-master/utemplate
cd ..

View File

@@ -13,6 +13,7 @@ classifiers = [
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
requires-python = ">=3.8"
[project.readme] [project.readme]
file = "README.md" file = "README.md"
@@ -30,26 +31,16 @@ docs = [
[tool.setuptools] [tool.setuptools]
zip-safe = false zip-safe = false
include-package-data = true include-package-data = true
py-modules = [
"microdot",
"microdot_asyncio",
"microdot_utemplate",
"microdot_jinja",
"microdot_session",
"microdot_cors",
"microdot_websocket",
"microdot_websocket_alt",
"microdot_asyncio_websocket",
"microdot_test_client",
"microdot_asyncio_test_client",
"microdot_wsgi",
"microdot_asgi",
"microdot_asgi_websocket",
]
[tool.setuptools.package-dir] [tool.setuptools.package-dir]
"" = "src" "" = "src"
[tool.setuptools.packages.find]
where = [
"src",
]
namespaces = false
[build-system] [build-system]
requires = [ requires = [
"setuptools>=61.2", "setuptools>=61.2",

2
src/microdot/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
send_file # noqa: F401

View File

@@ -1,10 +1,10 @@
import asyncio import asyncio
import os import os
import signal import signal
from microdot_asyncio import * # noqa: F401, F403 from microdot import * # noqa: F401, F403
from microdot_asyncio import Microdot as BaseMicrodot from microdot.microdot import Microdot as BaseMicrodot, Request, Response, \
from microdot_asyncio import Request NoCaseDict, abort
from microdot import NoCaseDict from microdot.websocket import WebSocket as BaseWebSocket, websocket_wrapper
class _BodyStream: # pragma: no cover class _BodyStream: # pragma: no cover
@@ -21,7 +21,7 @@ class _BodyStream: # pragma: no cover
async def read(self, n=-1): async def read(self, n=-1):
while self.more and len(self.data) < n: while self.more and len(self.data) < n:
self.read_more() await self.read_more()
if len(self.data) < n: if len(self.data) < n:
data = self.data data = self.data
self.data = b'' self.data = b''
@@ -32,14 +32,14 @@ class _BodyStream: # pragma: no cover
return data return data
async def readline(self): async def readline(self):
return self.readuntil() return await self.readuntil()
async def readexactly(self, n): async def readexactly(self, n):
return self.read(n) return await self.read(n)
async def readuntil(self, separator=b'\n'): async def readuntil(self, separator=b'\n'):
if self.more and separator not in self.data: if self.more and separator not in self.data:
self.read_more() await self.read_more()
data, self.data = self.data.split(separator, 1) data, self.data = self.data.split(separator, 1)
return data return data
@@ -113,11 +113,12 @@ class Microdot(BaseMicrodot):
while True: while True:
event = await receive() event = await receive()
if event['type'] == 'http.disconnect': # pragma: no branch if event is None or \
event['type'] == 'http.disconnect': # pragma: no cover
cancelled = True cancelled = True
break break
asyncio.ensure_future(cancel_monitor()) monitor_task = asyncio.ensure_future(cancel_monitor())
body_iter = res.body_iter().__aiter__() body_iter = res.body_iter().__aiter__()
res_body = b'' res_body = b''
@@ -133,6 +134,10 @@ class Microdot(BaseMicrodot):
await send({'type': 'http.response.body', await send({'type': 'http.response.body',
'body': res_body, 'body': res_body,
'more_body': False}) 'more_body': False})
if hasattr(body_iter, 'aclose'): # pragma: no branch
await body_iter.aclose()
cancelled = True
await monitor_task
async def __call__(self, scope, receive, send): async def __call__(self, scope, receive, send):
return await self.asgi_app(scope, receive, send) return await self.asgi_app(scope, receive, send)
@@ -152,3 +157,79 @@ class Microdot(BaseMicrodot):
""" """
self.embedded_server = True self.embedded_server = True
super().run(host=host, port=port, debug=debug, **options) super().run(host=host, port=port, debug=debug, **options)
class WebSocket(BaseWebSocket): # pragma: no cover
async def handshake(self):
connect = await self.request.sock[0]()
if connect['type'] != 'websocket.connect':
abort(400)
await self.request.sock[1]({'type': 'websocket.accept'})
async def receive(self):
message = await self.request.sock[0]()
if message['type'] == 'websocket.disconnect':
raise OSError(32, 'Websocket connection closed')
elif message['type'] != 'websocket.receive':
raise OSError(32, 'Websocket message type not supported')
return message.get('bytes', message.get('text'))
async def send(self, data):
if isinstance(data, str):
await self.request.sock[1](
{'type': 'websocket.send', 'text': data})
else:
await self.request.sock[1](
{'type': 'websocket.send', 'bytes': data})
async def close(self):
if not self.closed:
self.closed = True
try:
await self.request.sock[1]({'type': 'websocket.close'})
except: # noqa E722
pass
async def websocket_upgrade(request): # pragma: no cover
"""Upgrade a request handler to a websocket connection.
This function can be called directly inside a route function to process a
WebSocket upgrade handshake, for example after the user's credentials are
verified. The function returns the websocket object::
@app.route('/echo')
async def echo(request):
if not (await authenticate_user(request)):
abort(401)
ws = await websocket_upgrade(request)
while True:
message = await ws.receive()
await ws.send(message)
"""
ws = WebSocket(request) if not request.app.embedded_server else \
BaseWebSocket(request)
await ws.handshake()
@request.after_request
async def after_request(request, response):
return Response.already_handled
return ws
def with_websocket(f): # pragma: no cover
"""Decorator to make a route a WebSocket endpoint.
This decorator is used to define a route that accepts websocket
connections. The route then receives a websocket object as a second
argument that it can use to send and receive messages::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
"""
return websocket_wrapper(f, websocket_upgrade)

58
src/microdot/jinja.py Normal file
View File

@@ -0,0 +1,58 @@
from jinja2 import Environment, FileSystemLoader, select_autoescape
_jinja_env = None
def init_templates(template_dir='templates', enable_async=False, **kwargs):
"""Initialize the templating subsystem.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load templates
from a *templates* subdirectory.
:param enable_async: set to ``True`` to enable the async rendering engine
in Jinja, and the ``render_async()`` and
``generate_async()`` methods.
:param kwargs: any additional options to be passed to Jinja's
``Environment`` class.
"""
global _jinja_env
_jinja_env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
enable_async=enable_async,
**kwargs
)
class Template:
"""A template object.
:param template: The filename of the template to render, relative to the
configured template directory.
"""
def __init__(self, template):
if _jinja_env is None: # pragma: no cover
init_templates()
#: The name of the template
self.name = template
self.template = _jinja_env.get_template(template)
def generate(self, *args, **kwargs):
"""Return a generator that renders the template in chunks, with the
given arguments."""
return self.template.generate(*args, **kwargs)
def render(self, *args, **kwargs):
"""Render the template with the given arguments and return it as a
string."""
return self.template.render(*args, **kwargs)
def generate_async(self, *args, **kwargs):
"""Return an asynchronous generator that renders the template in
chunks, using the given arguments."""
return self.template.generate_async(*args, **kwargs)
async def render_async(self, *args, **kwargs):
"""Render the template with the given arguments asynchronously and
return it as a string."""
return await self.template.render_async(*args, **kwargs)

View File

@@ -3,9 +3,43 @@ microdot
-------- --------
The ``microdot`` module defines a few classes that help implement HTTP-based The ``microdot`` module defines a few classes that help implement HTTP-based
servers for MicroPython and standard Python, with multithreading support for servers for MicroPython and standard Python.
Python interpreters that support it.
""" """
import asyncio
import io
import json
import re
import time
try:
from inspect import iscoroutinefunction, iscoroutine
async def invoke_handler(handler, *args, **kwargs):
"""Invoke a handler and return the result.
This method runs sync handlers in a thread pool executor.
"""
if iscoroutinefunction(handler):
ret = await handler(*args, **kwargs)
else:
ret = await asyncio.get_running_loop().run_in_executor(
None, handler, *args, **kwargs)
return ret
except ImportError: # pragma: no cover
def iscoroutine(coro):
return hasattr(coro, 'send') and hasattr(coro, 'throw')
async def invoke_handler(handler, *args, **kwargs):
"""Invoke a handler and return the result.
This method runs sync handlers in the asyncio thread, which can
potentially cause blocking and performance issues.
"""
ret = handler(*args, **kwargs)
if iscoroutine(ret):
ret = await ret
return ret
try: try:
from sys import print_exception from sys import print_exception
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
@@ -13,45 +47,6 @@ except ImportError: # pragma: no cover
def print_exception(exc): def print_exception(exc):
traceback.print_exc() traceback.print_exc()
try:
import uerrno as errno
except ImportError:
import errno
concurrency_mode = 'threaded'
try: # pragma: no cover
import threading
def create_thread(f, *args, **kwargs):
# use the threading module
threading.Thread(target=f, args=args, kwargs=kwargs).start()
except ImportError: # pragma: no cover
def create_thread(f, *args, **kwargs):
# no threads available, call function synchronously
f(*args, **kwargs)
concurrency_mode = 'sync'
try:
import ujson as json
except ImportError:
import json
try:
import ure as re
except ImportError:
import re
socket_timeout_error = OSError
try:
import usocket as socket
except ImportError:
try:
import socket
socket_timeout_error = socket.timeout
except ImportError: # pragma: no cover
socket = None
MUTED_SOCKET_ERRORS = [ MUTED_SOCKET_ERRORS = [
32, # Broken pipe 32, # Broken pipe
@@ -275,7 +270,31 @@ class MultiDict(dict):
return values return values
class Request(): class AsyncBytesIO:
"""An async wrapper for BytesIO."""
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)
async def awrite(self, data): # pragma: no cover
return self.stream.write(data)
async def aclose(self): # pragma: no cover
pass
class Request:
"""An HTTP request.""" """An HTTP request."""
#: Specify the maximum payload size that is accepted. Requests with larger #: Specify the maximum payload size that is accepted. Requests with larger
#: payloads will be rejected with a 413 status code. Applications can #: payloads will be rejected with a 413 status code. Applications can
@@ -306,12 +325,6 @@ class Request():
#: Request.max_readline = 16 * 1024 # 16KB lines allowed #: Request.max_readline = 16 * 1024 # 16KB lines allowed
max_readline = 2 * 1024 max_readline = 2 * 1024
#: Specify a suggested read timeout to use when reading the request. Set to
#: 0 to disable the use of a timeout. This timeout should be considered a
#: suggestion only, as some platforms may not support it. The default is
#: 1 second.
socket_read_timeout = 1
class G: class G:
pass pass
@@ -361,48 +374,62 @@ class Request():
self._body = body self._body = body
self.body_used = False self.body_used = False
self._stream = stream self._stream = stream
self.stream_used = False
self.sock = sock self.sock = sock
self._json = None self._json = None
self._form = None self._form = None
self.after_request_handlers = [] self.after_request_handlers = []
@staticmethod @staticmethod
def create(app, client_stream, client_addr, client_sock=None): async def create(app, client_reader, client_writer, client_addr):
"""Create a request object. """Create a request object.
:param app: The Microdot application instance. :param app: The Microdot application instance.
:param client_stream: An input stream from where the request data can :param client_reader: An input stream from where the request data can
be read. be read.
:param client_writer: An output stream where the response data can be
written.
:param client_addr: The address of the client, as a tuple. :param client_addr: The address of the client, as a tuple.
:param client_sock: The low-level socket associated with the request.
This method returns a newly created ``Request`` object. This method is a coroutine. It returns a newly created ``Request``
object.
""" """
# request line # request line
line = Request._safe_readline(client_stream).strip().decode() line = (await Request._safe_readline(client_reader)).strip().decode()
if not line: if not line: # pragma: no cover
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 = NoCaseDict() headers = NoCaseDict()
content_length = 0
while True: while True:
line = Request._safe_readline(client_stream).strip().decode() line = (await Request._safe_readline(
client_reader)).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.lower() == 'content-length':
content_length = int(value)
# body
body = b''
if content_length and content_length <= Request.max_body_length:
body = await client_reader.readexactly(content_length)
stream = None
else:
body = b''
stream = client_reader
return Request(app, client_addr, method, url, http_version, headers, return Request(app, client_addr, method, url, http_version, headers,
stream=client_stream, sock=client_sock) body=body, stream=stream,
sock=(client_reader, client_writer))
def _parse_urlencoded(self, urlencoded): def _parse_urlencoded(self, urlencoded):
data = MultiDict() data = MultiDict()
if len(urlencoded) > 0: if len(urlencoded) > 0: # pragma: no branch
if isinstance(urlencoded, str): if isinstance(urlencoded, str):
for kv in [pair.split('=', 1) for kv in [pair.split('=', 1)
for pair in urlencoded.split('&') if pair]: for pair in urlencoded.split('&') if pair]:
@@ -418,27 +445,13 @@ class Request():
@property @property
def body(self): def body(self):
"""The body of the request, as bytes.""" """The body of the request, as bytes."""
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 return self._body
@property @property
def stream(self): def stream(self):
"""The input stream, containing the request body.""" """The body of the request, as a bytes stream."""
if self.body_used: if self._stream is None:
raise RuntimeError('Cannot use both stream and body') self._stream = AsyncBytesIO(self._body)
self.stream_used = True
return self._stream return self._stream
@property @property
@@ -494,21 +507,21 @@ class Request():
return f return f
@staticmethod @staticmethod
def _safe_readline(stream): async def _safe_readline(stream):
line = stream.readline(Request.max_readline + 1) line = (await stream.readline())
if len(line) > Request.max_readline: if len(line) > Request.max_readline:
raise ValueError('line too long') raise ValueError('line too long')
return line 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. If a file-like a JSON formatter is used to generate the body. If a file-like
object or a generator is given, a streaming response is used. object or an async generator is given, a streaming response is
If a string is given, it is encoded from UTF-8. Else, the used. If a string is given, it is encoded from UTF-8. Else,
body should be a byte sequence. 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.
@@ -526,6 +539,7 @@ class Response():
'png': 'image/png', 'png': 'image/png',
'txt': 'text/plain', 'txt': 'text/plain',
} }
send_file_buffer_size = 1024 send_file_buffer_size = 1024
#: The content type to use for responses that do not explicitly define a #: The content type to use for responses that do not explicitly define a
@@ -558,7 +572,8 @@ class Response():
self.is_head = False self.is_head = False
def set_cookie(self, cookie, value, path=None, domain=None, expires=None, def set_cookie(self, cookie, value, path=None, domain=None, expires=None,
max_age=None, secure=False, http_only=False): max_age=None, secure=False, http_only=False,
partitioned=False):
"""Add a cookie to the response. """Add a cookie to the response.
:param cookie: The cookie's name. :param cookie: The cookie's name.
@@ -570,6 +585,7 @@ class Response():
:param max_age: The cookie's ``Max-Age`` value. :param max_age: The cookie's ``Max-Age`` value.
:param secure: The cookie's ``secure`` flag. :param secure: The cookie's ``secure`` flag.
:param http_only: The cookie's ``HttpOnly`` flag. :param http_only: The cookie's ``HttpOnly`` flag.
:param partitioned: Whether the cookie is partitioned.
""" """
http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value) http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value)
if path: if path:
@@ -580,19 +596,31 @@ class Response():
if isinstance(expires, str): if isinstance(expires, str):
http_cookie += '; Expires=' + expires http_cookie += '; Expires=' + expires
else: else:
http_cookie += '; Expires=' + expires.strftime( http_cookie += '; Expires=' + time.strftime(
'%a, %d %b %Y %H:%M:%S GMT') '%a, %d %b %Y %H:%M:%S GMT', expires.timetuple())
if max_age: if max_age:
http_cookie += '; Max-Age=' + str(max_age) http_cookie += '; Max-Age=' + str(max_age)
if secure: if secure:
http_cookie += '; Secure' http_cookie += '; Secure'
if http_only: if http_only:
http_cookie += '; HttpOnly' http_cookie += '; HttpOnly'
if partitioned:
http_cookie += '; Partitioned'
if 'Set-Cookie' in self.headers: if 'Set-Cookie' in self.headers:
self.headers['Set-Cookie'].append(http_cookie) self.headers['Set-Cookie'].append(http_cookie)
else: else:
self.headers['Set-Cookie'] = [http_cookie] self.headers['Set-Cookie'] = [http_cookie]
def delete_cookie(self, cookie, **kwargs):
"""Delete a cookie.
:param cookie: The cookie's name.
:param kwargs: Any cookie opens and flags supported by
``set_cookie()`` except ``expires``.
"""
self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT',
**kwargs)
def complete(self): def complete(self):
if isinstance(self.body, bytes) and \ if isinstance(self.body, bytes) and \
'Content-Length' not in self.headers: 'Content-Length' not in self.headers:
@@ -602,54 +630,101 @@ class Response():
if 'charset=' not in self.headers['Content-Type']: if 'charset=' not in self.headers['Content-Type']:
self.headers['Content-Type'] += '; charset=UTF-8' self.headers['Content-Type'] += '; charset=UTF-8'
def write(self, stream): async def write(self, stream):
self.complete() self.complete()
# status code try:
reason = self.reason if self.reason is not None else \ # status code
('OK' if self.status_code == 200 else 'N/A') reason = self.reason if self.reason is not None else \
stream.write('HTTP/1.0 {status_code} {reason}\r\n'.format( ('OK' if self.status_code == 200 else 'N/A')
status_code=self.status_code, reason=reason).encode()) await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format(
status_code=self.status_code, reason=reason).encode())
# headers # headers
for header, value in self.headers.items(): for header, value in self.headers.items():
values = value if isinstance(value, list) else [value] values = value if isinstance(value, list) else [value]
for value in values: for value in values:
stream.write('{header}: {value}\r\n'.format( await stream.awrite('{header}: {value}\r\n'.format(
header=header, value=value).encode()) header=header, value=value).encode())
stream.write(b'\r\n') await stream.awrite(b'\r\n')
# body # body
if not self.is_head: if not self.is_head:
can_flush = hasattr(stream, 'flush') iter = self.body_iter()
try: async for body in iter:
for body in self.body_iter():
if isinstance(body, str): # pragma: no cover if isinstance(body, str): # pragma: no cover
body = body.encode() body = body.encode()
stream.write(body) try:
if can_flush: # pragma: no cover await stream.awrite(body)
stream.flush() except OSError as exc: # pragma: no cover
except OSError as exc: # pragma: no cover if exc.errno in MUTED_SOCKET_ERRORS or \
if exc.errno in MUTED_SOCKET_ERRORS: exc.args[0] == 'Connection lost':
pass if hasattr(iter, 'aclose'):
else: await iter.aclose()
raise raise
if hasattr(iter, 'aclose'): # pragma: no branch
await iter.aclose()
except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS or \
exc.args[0] == 'Connection lost':
pass
else:
raise
def body_iter(self): def body_iter(self):
if self.body: if hasattr(self.body, '__anext__'):
if hasattr(self.body, 'read'): # response body is an async generator
while True: return self.body
buf = self.body.read(self.send_file_buffer_size)
if len(buf): response = self
yield buf
if len(buf) < self.send_file_buffer_size: class iter:
break ITER_UNKNOWN = 0
if hasattr(self.body, 'close'): # pragma: no cover ITER_SYNC_GEN = 1
self.body.close() ITER_FILE_OBJ = 2
elif hasattr(self.body, '__next__'): ITER_NO_BODY = -1
yield from self.body
else: def __aiter__(self):
yield self.body if response.body:
self.i = self.ITER_UNKNOWN # need to determine type
else:
self.i = self.ITER_NO_BODY
return self
async def __anext__(self):
if self.i == self.ITER_NO_BODY:
await self.aclose()
raise StopAsyncIteration
if self.i == self.ITER_UNKNOWN:
if hasattr(response.body, 'read'):
self.i = self.ITER_FILE_OBJ
elif hasattr(response.body, '__next__'):
self.i = self.ITER_SYNC_GEN
return next(response.body)
else:
self.i = self.ITER_NO_BODY
return response.body
elif self.i == self.ITER_SYNC_GEN:
try:
return next(response.body)
except StopIteration:
await self.aclose()
raise StopAsyncIteration
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 = self.ITER_NO_BODY
return buf
async def aclose(self):
if hasattr(response.body, 'close'):
result = response.body.close()
if iscoroutine(result): # pragma: no cover
await result
return iter()
@classmethod @classmethod
def redirect(cls, location, status_code=302): def redirect(cls, location, status_code=302):
@@ -781,7 +856,7 @@ class HTTPException(Exception):
return 'HTTPException: {}'.format(self.status_code) return 'HTTPException: {}'.format(self.status_code)
class Microdot(): class Microdot:
"""An HTTP application class. """An HTTP application class.
This class implements an HTTP application instance and is heavily This class implements an HTTP application instance and is heavily
@@ -1048,6 +1123,88 @@ class Microdot():
""" """
raise HTTPException(status_code, reason) raise HTTPException(status_code, reason)
async def start_server(self, host='0.0.0.0', port=5000, debug=False,
ssl=None):
"""Start the Microdot web server as a coroutine. This coroutine does
not normally return, as the server enters an endless listening loop.
The :func:`shutdown` function provides a method for terminating the
server gracefully.
:param host: The hostname or IP address of the network interface that
will be listening for requests. A value of ``'0.0.0.0'``
(the default) indicates that the server should listen for
requests on all the available interfaces, and a value of
``127.0.0.1`` indicates that the server should listen
for requests only on the internal networking interface of
the host.
:param port: The port number to listen for requests. The default is
port 5000.
:param debug: If ``True``, the server logs debugging information. The
default is ``False``.
:param ssl: An ``SSLContext`` instance or ``None`` if the server should
not use TLS. The default is ``None``.
This method is a coroutine.
Example::
import asyncio
from microdot_asyncio import Microdot
app = Microdot()
@app.route('/')
async def index(request):
return 'Hello, world!'
async def main():
await app.start_server(debug=True)
asyncio.run(main())
"""
self.debug = debug
async def serve(reader, writer):
if not hasattr(writer, 'awrite'): # pragma: no cover
# CPython provides the awrite and aclose methods in 3.8+
async def awrite(self, data):
self.write(data)
await self.drain()
async def aclose(self):
self.close()
await self.wait_closed()
from types import MethodType
writer.awrite = MethodType(awrite, writer)
writer.aclose = MethodType(aclose, writer)
await self.handle_request(reader, writer)
if self.debug: # pragma: no cover
print('Starting async server on {host}:{port}...'.format(
host=host, port=port))
try:
self.server = await asyncio.start_server(serve, host, port,
ssl=ssl)
except TypeError: # pragma: no cover
self.server = await asyncio.start_server(serve, host, port)
while True:
try:
if hasattr(self.server, 'serve_forever'): # pragma: no cover
try:
await self.server.serve_forever()
except asyncio.CancelledError:
pass
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, ssl=None): def run(self, host='0.0.0.0', port=5000, debug=False, ssl=None):
"""Start the web server. This function does not normally return, as """Start the web server. This function does not normally return, as
the server enters an endless listening loop. The :func:`shutdown` the server enters an endless listening loop. The :func:`shutdown`
@@ -1069,45 +1226,18 @@ class Microdot():
Example:: Example::
from microdot import Microdot from microdot_asyncio import Microdot
app = Microdot() app = Microdot()
@app.route('/') @app.route('/')
def index(request): async def index(request):
return 'Hello, world!' return 'Hello, world!'
app.run(debug=True) app.run(debug=True)
""" """
self.debug = debug asyncio.run(self.start_server(host=host, port=port, debug=debug,
self.shutdown_requested = False ssl=ssl)) # pragma: no cover
self.server = socket.socket()
ai = socket.getaddrinfo(host, port)
addr = ai[0][-1]
if self.debug: # pragma: no cover
print('Starting {mode} server on {host}:{port}...'.format(
mode=concurrency_mode, host=host, port=port))
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.server.bind(addr)
self.server.listen(5)
if ssl:
self.server = ssl.wrap_socket(self.server, server_side=True)
while not self.shutdown_requested:
try:
sock, addr = self.server.accept()
except OSError as exc: # pragma: no cover
if exc.errno == errno.ECONNABORTED:
break
else:
print_exception(exc)
except Exception as exc: # pragma: no cover
print_exception(exc)
else:
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
@@ -1122,7 +1252,7 @@ class Microdot():
request.app.shutdown() request.app.shutdown()
return 'The server is shutting down...' return 'The server is shutting down...'
""" """
self.shutdown_requested = True self.server.close()
def find_route(self, req): def find_route(self, req):
method = req.method.upper() method = req.method.upper()
@@ -1151,51 +1281,35 @@ class Microdot():
allow.append('OPTIONS') allow.append('OPTIONS')
return {'Allow': ', '.join(allow)} return {'Allow': ', '.join(allow)}
def handle_request(self, sock, addr): async def handle_request(self, reader, writer):
if Request.socket_read_timeout and \
hasattr(sock, 'settimeout'): # pragma: no cover
sock.settimeout(Request.socket_read_timeout)
if not hasattr(sock, 'readline'): # pragma: no cover
stream = sock.makefile("rwb")
else:
stream = sock
req = None req = None
res = None
try: try:
req = Request.create(self, stream, addr, sock) req = await Request.create(self, reader, writer,
res = self.dispatch_request(req) writer.get_extra_info('peername'))
except socket_timeout_error as exc: # pragma: no cover
if exc.errno and exc.errno != errno.ETIMEDOUT:
print_exception(exc) # not a timeout
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)
if res != Response.already_handled: # pragma: no branch
await res.write(writer)
try: try:
if res and res != Response.already_handled: # pragma: no branch await writer.aclose()
res.write(stream)
stream.close()
except OSError as exc: # pragma: no cover except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS: if exc.errno in MUTED_SOCKET_ERRORS:
pass pass
else: else:
print_exception(exc) raise
except Exception as exc: # pragma: no cover
print_exception(exc)
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 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))
def dispatch_request(self, req): async def dispatch_request(self, req):
after_request_handled = False after_request_handled = False
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:
res = self.error_handlers[413](req) res = await invoke_handler(self.error_handlers[413], req)
else: else:
res = 'Payload too large', 413 res = 'Payload too large', 413
else: else:
@@ -1204,11 +1318,12 @@ class Microdot():
res = None res = None
if callable(f): if callable(f):
for handler in self.before_request_handlers: for handler in self.before_request_handlers:
res = handler(req) res = await invoke_handler(handler, req)
if res: if res:
break break
if res is None: if res is None:
res = f(req, **req.url_args) res = await invoke_handler(
f, req, **req.url_args)
if isinstance(res, tuple): if isinstance(res, tuple):
body = res[0] body = res[0]
if isinstance(res[1], int): if isinstance(res[1], int):
@@ -1221,14 +1336,16 @@ class Microdot():
elif not isinstance(res, Response): elif not isinstance(res, Response):
res = Response(res) res = Response(res)
for handler in self.after_request_handlers: for handler in self.after_request_handlers:
res = handler(req, res) or res res = await invoke_handler(
handler, req, res) or res
for handler in req.after_request_handlers: for handler in req.after_request_handlers:
res = handler(req, res) or res res = await invoke_handler(
handler, req, res) or res
after_request_handled = True after_request_handled = True
elif isinstance(f, dict): elif isinstance(f, dict):
res = Response(headers=f) res = Response(headers=f)
elif f in self.error_handlers: elif f in self.error_handlers:
res = self.error_handlers[f](req) res = await invoke_handler(self.error_handlers[f], req)
else: else:
res = 'Not found', f res = 'Not found', f
except HTTPException as exc: except HTTPException as exc:
@@ -1249,32 +1366,35 @@ class Microdot():
break break
if exc_class: if exc_class:
try: try:
res = self.error_handlers[exc_class](req, exc) res = await invoke_handler(
self.error_handlers[exc_class], req, exc)
except Exception as exc2: # pragma: no cover except Exception as exc2: # pragma: no cover
print_exception(exc2) print_exception(exc2)
if res is None: if res is None:
if 500 in self.error_handlers: if 500 in self.error_handlers:
res = self.error_handlers[500](req) res = await invoke_handler(
self.error_handlers[500], req)
else: else:
res = 'Internal server error', 500 res = 'Internal server error', 500
else: else:
if 400 in self.error_handlers: if 400 in self.error_handlers:
res = self.error_handlers[400](req) res = await invoke_handler(self.error_handlers[400], req)
else: else:
res = 'Bad request', 400 res = 'Bad request', 400
if isinstance(res, tuple): if isinstance(res, tuple):
res = Response(*res) res = Response(*res)
elif not isinstance(res, Response): elif not isinstance(res, Response):
res = Response(res) res = Response(res)
if not after_request_handled: if not after_request_handled:
for handler in self.after_error_request_handlers: for handler in self.after_error_request_handlers:
res = handler(req, res) or res res = await invoke_handler(
handler, req, res) or res
res.is_head = (req and req.method == 'HEAD') res.is_head = (req and req.method == 'HEAD')
return res return res
abort = Microdot.abort
Response.already_handled = Response() Response.already_handled = Response()
abort = Microdot.abort
redirect = Response.redirect redirect = Response.redirect
send_file = Response.send_file send_file = Response.send_file

148
src/microdot/session.py Normal file
View File

@@ -0,0 +1,148 @@
import jwt
from microdot.microdot import invoke_handler
secret_key = None
class SessionDict(dict):
"""A session dictionary.
The session dictionary is a standard Python dictionary that has been
extended with convenience ``save()`` and ``delete()`` methods.
"""
def __init__(self, request, session_dict):
super().__init__(session_dict)
self.request = request
def save(self):
"""Update the session cookie."""
self.request.app._session.update(self.request, self)
def delete(self):
"""Delete the session cookie."""
self.request.app._session.delete(self.request)
class Session:
"""
:param app: The application instance.
:param key: The secret key, as a string or bytes object.
"""
secret_key = None
def __init__(self, app=None, secret_key=None):
self.secret_key = secret_key
if app is not None:
self.initialize(app)
def initialize(self, app, secret_key=None):
if secret_key is not None:
self.secret_key = secret_key
app._session = self
def get(self, request):
"""Retrieve the user session.
:param request: The client request.
The return value is a session dictionary with the data stored in the
user's session, or ``{}`` if the session data is not available or
invalid.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
if hasattr(request.g, '_session'):
return request.g._session
session = request.cookies.get('session')
if session is None:
request.g._session = SessionDict(request, {})
return request.g._session
try:
session = jwt.decode(session, self.secret_key,
algorithms=['HS256'])
except jwt.exceptions.PyJWTError: # pragma: no cover
request.g._session = SessionDict(request, {})
else:
request.g._session = SessionDict(request, session)
return request.g._session
def update(self, request, session):
"""Update the user session.
:param request: The client request.
:param session: A dictionary with the update session data for the user.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.save` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session['foo'] = 'bar'
session.save()
return 'Hello, World!'
Calling this method adds a cookie with the updated session to the
request currently being processed.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
encoded_session = jwt.encode(session, self.secret_key,
algorithm='HS256')
@request.after_request
def _update_session(request, response):
response.set_cookie('session', encoded_session, http_only=True)
return response
def delete(self, request):
"""Remove the user session.
:param request: The client request.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.delete` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session.delete()
return 'Hello, World!'
Calling this method adds a cookie removal header to the request
currently being processed.
"""
@request.after_request
def _delete_session(request, response):
response.set_cookie('session', '', http_only=True,
expires='Thu, 01 Jan 1970 00:00:01 GMT')
return response
def with_session(f):
"""Decorator that passes the user session to the route handler.
The session dictionary is passed to the decorated function as an argument
after the request object. Example::
@app.route('/')
@with_session
def index(request, session):
return 'Hello, World!'
Note that the decorator does not save the session. To update the session,
call the :func:`update_session <microdot.session.update_session>` function.
"""
async def wrapper(request, *args, **kwargs):
return await invoke_handler(
f, request, request.app._session.get(request), *args, **kwargs)
for attr in ['__name__', '__doc__', '__module__', '__qualname__']:
try:
setattr(wrapper, attr, getattr(f, attr))
except AttributeError: # pragma: no cover
pass
return wrapper

95
src/microdot/sse.py Normal file
View File

@@ -0,0 +1,95 @@
import asyncio
import json
class SSE:
def __init__(self):
self.event = asyncio.Event()
self.queue = []
async def send(self, data, event=None):
if isinstance(data, (dict, list)):
data = json.dumps(data)
elif not isinstance(data, str):
data = str(data)
data = f'data: {data}\n\n'
if event:
data = f'event: {event}\n{data}'
self.queue.append(data)
self.event.set()
def sse_response(request, event_function, *args, **kwargs):
"""Return a response object that initiates an event stream.
:param request: the request object.
:param event_function: an asynchronous function that will send events to
the client. The function is invoked with ``request``
and an ``sse`` object. The function should use
``sse.send()`` to send events to the client.
:param args: additional positional arguments to be passed to the response.
:param kwargs: additional keyword arguments to be passed to the response.
Example::
@app.route('/events')
async def events_route(request):
async def events(request, sse):
# send an unnamed event with string data
await sse.send('hello')
# send an unnamed event with JSON data
await sse.send({'foo': 'bar'})
# send a named event
await sse.send('hello', event='greeting')
return sse_response(request, events)
"""
sse = SSE()
async def sse_task_wrapper():
await event_function(request, sse, *args, **kwargs)
sse.event.set()
task = asyncio.create_task(sse_task_wrapper())
class sse_loop:
def __aiter__(self):
return self
async def __anext__(self):
event = None
while sse.queue or not task.done():
try:
event = sse.queue.pop(0)
break
except IndexError:
await sse.event.wait()
sse.event.clear()
if event is None:
raise StopAsyncIteration
return event
async def aclose(self):
task.cancel()
return sse_loop(), 200, {'Content-Type': 'text/event-stream'}
def with_sse(f):
"""Decorator to make a route a Server-Sent Events endpoint.
This decorator is used to define a route that accepts SSE connections. The
route then receives a sse object as a second argument that it can use to
send events to the client::
@app.route('/events')
@with_sse
async def events(request, sse):
for i in range(10):
await asyncio.sleep(1)
await sse.send(f'{i}')
"""
async def sse_handler(request, *args, **kwargs):
return sse_response(request, f, *args, **kwargs)
return sse_handler

View File

@@ -1,11 +1,13 @@
from io import BytesIO
import json import json
from microdot import Request, Response, NoCaseDict from microdot.microdot import Request, Response, AsyncBytesIO
try: try:
from microdot_websocket import WebSocket from microdot.websocket import WebSocket
except: # pragma: no cover # noqa: E722 except: # pragma: no cover # noqa: E722
WebSocket = None WebSocket = None
__all__ = ['TestClient', 'TestResponse']
class TestResponse: class TestResponse:
"""A response object issued by the Microdot test client.""" """A response object issued by the Microdot test client."""
@@ -32,12 +34,15 @@ class TestResponse:
self.reason = res.reason self.reason = res.reason
self.headers = res.headers self.headers = res.headers
def _initialize_body(self, res): async def _initialize_body(self, res):
self.body = b'' self.body = b''
for body in res.body_iter(): iter = res.body_iter()
async for body in iter: # pragma: no branch
if isinstance(body, str): if isinstance(body, str):
body = body.encode() body = body.encode()
self.body += body self.body += body
if hasattr(iter, 'aclose'): # pragma: no branch
await iter.aclose()
def _process_text_body(self): def _process_text_body(self):
try: try:
@@ -52,13 +57,13 @@ class TestResponse:
self.json = json.loads(self.text) self.json = json.loads(self.text)
@classmethod @classmethod
def create(cls, res): async def create(cls, res):
test_res = cls() test_res = cls()
test_res._initialize_response(res) test_res._initialize_response(res)
test_res._initialize_body(res) if not res.is_head:
test_res._process_text_body() await test_res._initialize_body(res)
test_res._process_json_body() test_res._process_text_body()
test_res.is_head = res.is_head test_res._process_json_body()
return test_res return test_res
@@ -72,17 +77,17 @@ class TestClient:
The following example shows how to create a test client for an application The following example shows how to create a test client for an application
and send a test request:: and send a test request::
from microdot import Microdot from microdot_asyncio import Microdot
app = Microdot() app = Microdot()
@app.get('/') @app.get('/')
def index(): async def index():
return 'Hello, World!' return 'Hello, World!'
def test_hello_world(self): async def test_hello_world(self):
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = await client.get('/')
assert res.status_code == 200 assert res.status_code == 200
assert res.text == 'Hello, World!' assert res.text == 'Hello, World!'
""" """
@@ -150,23 +155,30 @@ class TestClient:
else: else:
self.cookies[cookie_name] = cookie_options[0] self.cookies[cookie_name] = cookie_options[0]
def request(self, method, path, headers=None, body=None, sock=None): async def request(self, method, path, headers=None, body=None, sock=None):
headers = NoCaseDict(headers or {}) headers = headers or {}
body, headers = self._process_body(body, headers) body, headers = self._process_body(body, headers)
cookies, headers = self._process_cookies(headers) cookies, headers = self._process_cookies(headers)
request_bytes = self._render_request(method, path, headers, body) request_bytes = self._render_request(method, path, headers, body)
if sock:
reader = sock[0]
reader.buffer = request_bytes
writer = sock[1]
else:
reader = AsyncBytesIO(request_bytes)
writer = AsyncBytesIO(b'')
req = Request.create(self.app, BytesIO(request_bytes), req = await Request.create(self.app, reader, writer,
('127.0.0.1', 1234), client_sock=sock) ('127.0.0.1', 1234))
res = self.app.dispatch_request(req) res = await self.app.dispatch_request(req)
if res == Response.already_handled: if res == Response.already_handled:
return None return None
res.complete() res.complete()
self._update_cookies(res) self._update_cookies(res)
return TestResponse.create(res) return await TestResponse.create(res)
def get(self, path, headers=None): async def get(self, path, headers=None):
"""Send a GET request to the application. """Send a GET request to the application.
:param path: The request URL. :param path: The request URL.
@@ -175,9 +187,9 @@ class TestClient:
This method returns a This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object. :class:`TestResponse <microdot_test_client.TestResponse>` object.
""" """
return self.request('GET', path, headers=headers) return await self.request('GET', path, headers=headers)
def post(self, path, headers=None, body=None): async def post(self, path, headers=None, body=None):
"""Send a POST request to the application. """Send a POST request to the application.
:param path: The request URL. :param path: The request URL.
@@ -189,9 +201,9 @@ class TestClient:
This method returns a This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object. :class:`TestResponse <microdot_test_client.TestResponse>` object.
""" """
return self.request('POST', path, headers=headers, body=body) return await self.request('POST', path, headers=headers, body=body)
def put(self, path, headers=None, body=None): async def put(self, path, headers=None, body=None):
"""Send a PUT request to the application. """Send a PUT request to the application.
:param path: The request URL. :param path: The request URL.
@@ -203,9 +215,9 @@ class TestClient:
This method returns a This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object. :class:`TestResponse <microdot_test_client.TestResponse>` object.
""" """
return self.request('PUT', path, headers=headers, body=body) return await self.request('PUT', path, headers=headers, body=body)
def patch(self, path, headers=None, body=None): async def patch(self, path, headers=None, body=None):
"""Send a PATCH request to the application. """Send a PATCH request to the application.
:param path: The request URL. :param path: The request URL.
@@ -217,9 +229,9 @@ class TestClient:
This method returns a This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object. :class:`TestResponse <microdot_test_client.TestResponse>` object.
""" """
return self.request('PATCH', path, headers=headers, body=body) return await self.request('PATCH', path, headers=headers, body=body)
def delete(self, path, headers=None): async def delete(self, path, headers=None):
"""Send a DELETE request to the application. """Send a DELETE request to the application.
:param path: The request URL. :param path: The request URL.
@@ -228,9 +240,9 @@ class TestClient:
This method returns a This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object. :class:`TestResponse <microdot_test_client.TestResponse>` object.
""" """
return self.request('DELETE', path, headers=headers) return await self.request('DELETE', path, headers=headers)
def websocket(self, path, client, headers=None): async def websocket(self, path, client, headers=None):
"""Send a websocket connection request to the application. """Send a websocket connection request to the application.
:param path: The request URL. :param path: The request URL.
@@ -245,27 +257,39 @@ class TestClient:
self.closed = False self.closed = False
self.buffer = b'' self.buffer = b''
def _next(self, data=None): async def _next(self, data=None):
try: try:
data = gen.send(data) data = (await gen.asend(data)) if hasattr(gen, 'asend') \
except StopIteration: else gen.send(data)
if self.closed: # pragma: no cover except (StopIteration, StopAsyncIteration):
return if not self.closed:
self.closed = True self.closed = True
raise OSError(32, 'Websocket connection closed') raise OSError(32, 'Websocket connection closed')
return # pragma: no cover
opcode = WebSocket.TEXT if isinstance(data, str) \ opcode = WebSocket.TEXT if isinstance(data, str) \
else WebSocket.BINARY else WebSocket.BINARY
return WebSocket._encode_websocket_frame(opcode, data) return WebSocket._encode_websocket_frame(opcode, data)
def recv(self, n): async def read(self, n):
self.started = True
if not self.buffer: if not self.buffer:
self.buffer = self._next() self.started = True
self.buffer = await self._next()
data = self.buffer[:n] data = self.buffer[:n]
self.buffer = self.buffer[n:] self.buffer = self.buffer[n:]
return data return data
def send(self, data): async def readexactly(self, n): # pragma: no cover
return await self.read(n)
async def readline(self):
line = b''
while True:
line += await self.read(1)
if line[-1] in [b'\n', 10]:
break
return line
async def awrite(self, data):
if self.started: if self.started:
h = WebSocket._parse_frame_header(data[0:2]) h = WebSocket._parse_frame_header(data[0:2])
if h[3] < 0: if h[3] < 0:
@@ -274,7 +298,7 @@ class TestClient:
data = data[2:] data = data[2:]
if h[1] == WebSocket.TEXT: if h[1] == WebSocket.TEXT:
data = data.decode() data = data.decode()
self.buffer = self._next(data) self.buffer = await self._next(data)
ws_headers = { ws_headers = {
'Upgrade': 'websocket', 'Upgrade': 'websocket',
@@ -283,5 +307,6 @@ class TestClient:
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
} }
ws_headers.update(headers or {}) ws_headers.update(headers or {})
return self.request('GET', path, headers=ws_headers, sock = FakeWebSocket()
sock=FakeWebSocket()) return await self.request('GET', path, headers=ws_headers,
sock=(sock, sock))

68
src/microdot/utemplate.py Normal file
View File

@@ -0,0 +1,68 @@
from utemplate import recompile
_loader = None
def init_templates(template_dir='templates', loader_class=recompile.Loader):
"""Initialize the templating subsystem.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load templates
from a *templates* subdirectory.
:param loader_class: the ``utemplate.Loader`` class to use when loading
templates. This argument is optional. The default is
the ``recompile.Loader`` class, which automatically
recompiles templates when they change.
"""
global _loader
_loader = loader_class(None, template_dir)
class Template:
"""A template object.
:param template: The filename of the template to render, relative to the
configured template directory.
"""
def __init__(self, template):
if _loader is None: # pragma: no cover
init_templates()
#: The name of the template
self.name = template
self.template = _loader.load(template)
def generate(self, *args, **kwargs):
"""Return a generator that renders the template in chunks, with the
given arguments."""
return self.template(*args, **kwargs)
def render(self, *args, **kwargs):
"""Render the template with the given arguments and return it as a
string."""
return ''.join(self.generate(*args, **kwargs))
def generate_async(self, *args, **kwargs):
"""Return an asynchronous generator that renders the template in
chunks, using the given arguments."""
class sync_to_async_iter():
def __init__(self, iter):
self.iter = iter
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.iter)
except StopIteration:
raise StopAsyncIteration
return sync_to_async_iter(self.generate(*args, **kwargs))
async def render_async(self, *args, **kwargs):
"""Render the template with the given arguments asynchronously and
return it as a string."""
response = ''
async for chunk in self.generate_async(*args, **kwargs):
response += chunk
return response

View File

@@ -1,6 +1,7 @@
import binascii import binascii
import hashlib import hashlib
from microdot import Response from microdot import Response
from microdot.microdot import MUTED_SOCKET_ERRORS
class WebSocket: class WebSocket:
@@ -15,33 +16,34 @@ class WebSocket:
self.request = request self.request = request
self.closed = False self.closed = False
def handshake(self): async def handshake(self):
response = self._handshake_response() response = self._handshake_response()
self.request.sock.send(b'HTTP/1.1 101 Switching Protocols\r\n') await self.request.sock[1].awrite(
self.request.sock.send(b'Upgrade: websocket\r\n') b'HTTP/1.1 101 Switching Protocols\r\n')
self.request.sock.send(b'Connection: Upgrade\r\n') await self.request.sock[1].awrite(b'Upgrade: websocket\r\n')
self.request.sock.send( await self.request.sock[1].awrite(b'Connection: Upgrade\r\n')
await self.request.sock[1].awrite(
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
def receive(self): async def receive(self):
while True: while True:
opcode, payload = self._read_frame() opcode, payload = await self._read_frame()
send_opcode, data = self._process_websocket_frame(opcode, payload) send_opcode, data = self._process_websocket_frame(opcode, payload)
if send_opcode: # pragma: no cover if send_opcode: # pragma: no cover
self.send(data, send_opcode) await self.send(data, send_opcode)
elif data: # pragma: no branch elif data: # pragma: no branch
return data return data
def send(self, data, opcode=None): async def send(self, data, opcode=None):
frame = self._encode_websocket_frame( frame = self._encode_websocket_frame(
opcode or (self.TEXT if isinstance(data, str) else self.BINARY), opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
data) data)
self.request.sock.send(frame) await self.request.sock[1].awrite(frame)
def close(self): async def close(self):
if not self.closed: # pragma: no cover if not self.closed: # pragma: no cover
self.closed = True self.closed = True
self.send(b'', self.CLOSE) await self.send(b'', self.CLOSE)
def _handshake_response(self): def _handshake_response(self):
connection = False connection = False
@@ -109,23 +111,26 @@ class WebSocket:
frame.extend(payload) frame.extend(payload)
return frame return frame
def _read_frame(self): async def _read_frame(self):
header = self.request.sock.recv(2) header = await self.request.sock[0].read(2)
if len(header) != 2: # pragma: no cover if len(header) != 2: # pragma: no cover
raise OSError(32, 'Websocket connection closed') raise OSError(32, 'Websocket connection closed')
fin, opcode, has_mask, length = self._parse_frame_header(header) fin, opcode, has_mask, length = self._parse_frame_header(header)
if length < 0: if length == -2:
length = self.request.sock.recv(-length) length = await self.request.sock[0].read(2)
length = int.from_bytes(length, 'big')
elif length == -8:
length = await self.request.sock[0].read(8)
length = int.from_bytes(length, 'big') length = int.from_bytes(length, 'big')
if has_mask: # pragma: no cover if has_mask: # pragma: no cover
mask = self.request.sock.recv(4) mask = await self.request.sock[0].read(4)
payload = self.request.sock.recv(length) payload = await self.request.sock[0].read(length)
if has_mask: # pragma: no cover if has_mask: # pragma: no cover
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
return opcode, payload return opcode, payload
def websocket_upgrade(request): async def websocket_upgrade(request):
"""Upgrade a request handler to a websocket connection. """Upgrade a request handler to a websocket connection.
This function can be called directly inside a route function to process a This function can be called directly inside a route function to process a
@@ -133,24 +138,37 @@ def websocket_upgrade(request):
verified. The function returns the websocket object:: verified. The function returns the websocket object::
@app.route('/echo') @app.route('/echo')
def echo(request): async def echo(request):
if not authenticate_user(request): if not authenticate_user(request):
abort(401) abort(401)
ws = websocket_upgrade(request) ws = await websocket_upgrade(request)
while True: while True:
message = ws.receive() message = await ws.receive()
ws.send(message) await ws.send(message)
""" """
ws = WebSocket(request) ws = WebSocket(request)
ws.handshake() await ws.handshake()
@request.after_request @request.after_request
def after_request(request, response): async def after_request(request, response):
return Response.already_handled return Response.already_handled
return ws return ws
def websocket_wrapper(f, upgrade_function):
async def wrapper(request, *args, **kwargs):
ws = await upgrade_function(request)
try:
await f(request, ws, *args, **kwargs)
await ws.close() # pragma: no cover
except OSError as exc:
if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover
raise
return ''
return wrapper
def with_websocket(f): def with_websocket(f):
"""Decorator to make a route a WebSocket endpoint. """Decorator to make a route a WebSocket endpoint.
@@ -160,18 +178,9 @@ def with_websocket(f):
@app.route('/echo') @app.route('/echo')
@with_websocket @with_websocket
def echo(request, ws): async def echo(request, ws):
while True: while True:
message = ws.receive() message = await ws.receive()
ws.send(message) await ws.send(message)
""" """
def wrapper(request, *args, **kwargs): return websocket_wrapper(f, websocket_upgrade)
ws = websocket_upgrade(request)
try:
f(request, ws, *args, **kwargs)
ws.close() # pragma: no cover
except OSError as exc:
if exc.errno not in [32, 54, 104]: # pragma: no cover
raise
return ''
return wrapper

154
src/microdot/wsgi.py Normal file
View File

@@ -0,0 +1,154 @@
import asyncio
import os
import signal
from microdot import * # noqa: F401, F403
from microdot.microdot import Microdot as BaseMicrodot, Request, NoCaseDict, \
MUTED_SOCKET_ERRORS
from microdot.websocket import WebSocket, websocket_upgrade, \
with_websocket # noqa: F401
class Microdot(BaseMicrodot):
def __init__(self):
super().__init__()
self.loop = asyncio.new_event_loop()
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 = NoCaseDict()
content_length = 0
for k, value in environ.items():
if k.startswith('HTTP_'):
key = '-'.join([p.title() for p in k[5:].split('_')])
headers[key] = value
elif k == 'CONTENT_TYPE':
headers['Content-Type'] = value
elif k == 'CONTENT_LENGTH':
headers['Content-Length'] = value
content_length = int(value)
class sync_to_async_body_stream(): # pragma: no cover
def __init__(self, wsgi_input=None):
self.wsgi_input = wsgi_input
async def read(self, n=-1):
return self.wsgi_input.read(n)
async def readline(self):
return self.wsgi_input.readline()
async def readexactly(self, n):
return self.wsgi_input.read(n)
wsgi_input = environ.get('wsgi.input')
if content_length and content_length <= Request.max_body_length:
# the request came with a body that is within the allowed size
body = wsgi_input.read(content_length)
stream = None
sock = (None, None)
else:
body = b''
if content_length:
# the request came with a body that is too large to fit in
# memory, so we stream it
stream = sync_to_async_body_stream(wsgi_input)
sock = (None, None)
else:
# the request did not declare a body size, so we connect the
# raw socket if available
stream = None
if 'gunicorn.socket' in environ: # pragma: no cover
reader, writer = self.loop.run_until_complete(
asyncio.open_connection(
sock=environ['gunicorn.socket'].dup()))
if not hasattr(writer, 'awrite'): # pragma: no cover
async def awrite(self, data):
self.write(data)
await self.drain()
async def aclose(self):
self.close()
await self.wait_closed()
from types import MethodType
writer.awrite = MethodType(awrite, writer)
writer.aclose = MethodType(aclose, writer)
sock = (reader, writer)
else:
sock = (None, None)
req = Request(
self,
(environ['REMOTE_ADDR'], int(environ.get('REMOTE_PORT', '0'))),
environ['REQUEST_METHOD'],
path,
environ['SERVER_PROTOCOL'],
headers,
body=body,
stream=stream,
sock=sock)
req.environ = environ
res = self.loop.run_until_complete(self.dispatch_request(req))
res.complete()
if sock[1]: # pragma: no cover
try:
self.loop.run_until_complete(sock[1].aclose())
except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS:
pass
else:
raise
reason = res.reason or ('OK' if res.status_code == 200 else 'N/A')
header_list = []
for name, value in res.headers.items():
if not isinstance(value, list):
header_list.append((name, value))
else:
for v in value:
header_list.append((name, v))
start_response(str(res.status_code) + ' ' + reason, header_list)
class async_to_sync_iter():
def __init__(self, iter, loop):
self.iter = iter.__aiter__()
self.loop = loop
def __iter__(self):
return self
def __next__(self):
try:
return self.loop.run_until_complete(self.iter.__anext__())
except StopAsyncIteration:
raise StopIteration
def close(self): # pragma: no cover
if hasattr(self.iter, 'aclose'):
self.loop.run_until_complete(self.iter.aclose())
return async_to_sync_iter(res.body_iter(), self.loop)
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

@@ -1,86 +0,0 @@
from microdot_asyncio import Response, abort
from microdot_websocket import WebSocket as BaseWebSocket
class WebSocket(BaseWebSocket):
async def handshake(self):
connect = await self.request.sock[0]()
if connect['type'] != 'websocket.connect':
abort(400)
await self.request.sock[1]({'type': 'websocket.accept'})
async def receive(self):
message = await self.request.sock[0]()
if message['type'] == 'websocket.disconnect':
raise OSError(32, 'Websocket connection closed')
elif message['type'] != 'websocket.receive':
raise OSError(32, 'Websocket message type not supported')
return message.get('bytes', message.get('text'))
async def send(self, data):
if isinstance(data, str):
await self.request.sock[1](
{'type': 'websocket.send', 'text': data})
else:
await self.request.sock[1](
{'type': 'websocket.send', 'bytes': data})
async def close(self):
if not self.closed:
self.closed = True
try:
await self.request.sock[1]({'type': 'websocket.close'})
except: # noqa E722
pass
async def websocket_upgrade(request):
"""Upgrade a request handler to a websocket connection.
This function can be called directly inside a route function to process a
WebSocket upgrade handshake, for example after the user's credentials are
verified. The function returns the websocket object::
@app.route('/echo')
async def echo(request):
if not (await authenticate_user(request)):
abort(401)
ws = await websocket_upgrade(request)
while True:
message = await ws.receive()
await ws.send(message)
"""
ws = WebSocket(request)
await ws.handshake()
@request.after_request
async def after_request(request, response):
return Response.already_handled
return ws
def with_websocket(f):
"""Decorator to make a route a WebSocket endpoint.
This decorator is used to define a route that accepts websocket
connections. The route then receives a websocket object as a second
argument that it can use to send and receive messages::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
"""
async def wrapper(request, *args, **kwargs):
ws = await websocket_upgrade(request)
try:
await f(request, ws, *args, **kwargs)
except OSError as exc:
if exc.errno != 32 and exc.errno != 54:
raise
await ws.close()
return ''
return wrapper

View File

@@ -1,455 +0,0 @@
"""
microdot_asyncio
----------------
The ``microdot_asyncio`` module defines a few classes that help implement
HTTP-based servers for MicroPython and standard Python that use ``asyncio``
and coroutines.
"""
try:
import uasyncio as asyncio
except ImportError:
import asyncio
try:
import uio as io
except ImportError:
import io
from microdot import Microdot as BaseMicrodot
from microdot import mro
from microdot import NoCaseDict
from microdot import Request as BaseRequest
from microdot import Response as BaseResponse
from microdot import print_exception
from microdot import HTTPException
from microdot import MUTED_SOCKET_ERRORS
def _iscoroutine(coro):
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)
async def awrite(self, data): # pragma: no cover
return self.stream.write(data)
async def aclose(self): # pragma: no cover
pass
class Request(BaseRequest):
@staticmethod
async def create(app, client_reader, client_writer, client_addr):
"""Create a request object.
:param app: The Microdot application instance.
:param client_reader: An input stream from where the request data can
be read.
:param client_writer: An output stream where the response data can be
written.
:param client_addr: The address of the client, as a tuple.
This method is a coroutine. It returns a newly created ``Request``
object.
"""
# request line
line = (await Request._safe_readline(client_reader)).strip().decode()
if not line:
return None
method, url, http_version = line.split()
http_version = http_version.split('/', 1)[1]
# headers
headers = NoCaseDict()
content_length = 0
while True:
line = (await Request._safe_readline(
client_reader)).strip().decode()
if line == '':
break
header, value = line.split(':', 1)
value = value.strip()
headers[header] = value
if header.lower() == 'content-length':
content_length = int(value)
# body
body = b''
if content_length and content_length <= Request.max_body_length:
body = await client_reader.readexactly(content_length)
stream = None
else:
body = b''
stream = client_reader
return Request(app, client_addr, method, url, http_version, headers,
body=body, stream=stream,
sock=(client_reader, client_writer))
@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):
"""An HTTP response class.
:param body: The body of the response. If a dictionary or list is given,
a JSON formatter is used to generate the body. If a file-like
object or an async generator is given, a streaming response is
used. If a string is given, it is encoded from UTF-8. Else,
the body should be a byte sequence.
:param status_code: The numeric HTTP status code of the response. The
default is 200.
:param headers: A dictionary of headers to include in the response.
: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):
self.complete()
try:
# 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(
status_code=self.status_code, reason=reason).encode())
# headers
for header, value in self.headers.items():
values = value if isinstance(value, list) else [value]
for value in values:
await stream.awrite('{header}: {value}\r\n'.format(
header=header, value=value).encode())
await stream.awrite(b'\r\n')
# body
if not self.is_head:
async for body in self.body_iter():
if isinstance(body, str): # pragma: no cover
body = body.encode()
await stream.awrite(body)
except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS or \
exc.args[0] == 'Connection lost':
pass
else:
raise
def body_iter(self):
if hasattr(self.body, '__anext__'):
# response body is an async generator
return self.body
response = self
class iter:
def __aiter__(self):
if response.body:
self.i = 0 # need to determine type of response.body
else:
self.i = -1 # no response body
return self
async def __anext__(self):
if self.i == -1:
raise StopAsyncIteration
if self.i == 0:
if hasattr(response.body, 'read'):
self.i = 2 # response body is a file-like object
elif hasattr(response.body, '__next__'):
self.i = 1 # response body is a sync generator
return next(response.body)
else:
self.i = -1 # response body is a plain string
return response.body
elif self.i == 1:
try:
return next(response.body)
except StopIteration:
raise StopAsyncIteration
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):
async def start_server(self, host='0.0.0.0', port=5000, debug=False,
ssl=None):
"""Start the Microdot web server as a coroutine. This coroutine does
not normally return, as the server enters an endless listening loop.
The :func:`shutdown` function provides a method for terminating the
server gracefully.
:param host: The hostname or IP address of the network interface that
will be listening for requests. A value of ``'0.0.0.0'``
(the default) indicates that the server should listen for
requests on all the available interfaces, and a value of
``127.0.0.1`` indicates that the server should listen
for requests only on the internal networking interface of
the host.
:param port: The port number to listen for requests. The default is
port 5000.
:param debug: If ``True``, the server logs debugging information. The
default is ``False``.
:param ssl: An ``SSLContext`` instance or ``None`` if the server should
not use TLS. The default is ``None``.
This method is a coroutine.
Example::
import asyncio
from microdot_asyncio import Microdot
app = Microdot()
@app.route('/')
async def index(request):
return 'Hello, world!'
async def main():
await app.start_server(debug=True)
asyncio.run(main())
"""
self.debug = debug
async def serve(reader, writer):
if not hasattr(writer, 'awrite'): # pragma: no cover
# CPython provides the awrite and aclose methods in 3.8+
async def awrite(self, data):
self.write(data)
await self.drain()
async def aclose(self):
self.close()
await self.wait_closed()
from types import MethodType
writer.awrite = MethodType(awrite, writer)
writer.aclose = MethodType(aclose, writer)
await self.handle_request(reader, writer)
if self.debug: # pragma: no cover
print('Starting async server on {host}:{port}...'.format(
host=host, port=port))
try:
self.server = await asyncio.start_server(serve, host, port,
ssl=ssl)
except TypeError:
self.server = await asyncio.start_server(serve, host, port)
while True:
try:
if hasattr(self.server, 'serve_forever'): # pragma: no cover
try:
await self.server.serve_forever()
except asyncio.CancelledError:
pass
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, ssl=None):
"""Start the web server. This function does not normally return, as
the server enters an endless listening loop. The :func:`shutdown`
function provides a method for terminating the server gracefully.
:param host: The hostname or IP address of the network interface that
will be listening for requests. A value of ``'0.0.0.0'``
(the default) indicates that the server should listen for
requests on all the available interfaces, and a value of
``127.0.0.1`` indicates that the server should listen
for requests only on the internal networking interface of
the host.
:param port: The port number to listen for requests. The default is
port 5000.
:param debug: If ``True``, the server logs debugging information. The
default is ``False``.
:param ssl: An ``SSLContext`` instance or ``None`` if the server should
not use TLS. The default is ``None``.
Example::
from microdot_asyncio import Microdot
app = Microdot()
@app.route('/')
async def index(request):
return 'Hello, world!'
app.run(debug=True)
"""
asyncio.run(self.start_server(host=host, port=port, debug=debug,
ssl=ssl))
def shutdown(self):
self.server.close()
async def handle_request(self, reader, writer):
req = None
try:
req = await Request.create(self, reader, writer,
writer.get_extra_info('peername'))
except Exception as exc: # pragma: no cover
print_exception(exc)
res = await self.dispatch_request(req)
if res != Response.already_handled: # pragma: no branch
await res.write(writer)
try:
await writer.aclose()
except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS:
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):
after_request_handled = False
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 callable(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):
body = res[0]
if isinstance(res[1], int):
status_code = res[1]
headers = res[2] if len(res) > 2 else {}
else:
status_code = 200
headers = res[1]
res = Response(body, status_code, headers)
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
for handler in req.after_request_handlers:
res = await self._invoke_handler(
handler, req, res) or res
after_request_handled = True
elif isinstance(f, dict):
res = Response(headers=f)
elif f in self.error_handlers:
res = await self._invoke_handler(
self.error_handlers[f], req)
else:
res = 'Not found', f
except HTTPException as exc:
if exc.status_code in self.error_handlers:
res = self.error_handlers[exc.status_code](req)
else:
res = exc.reason, exc.status_code
except Exception as exc:
print_exception(exc)
exc_class = None
res = None
if exc.__class__ in self.error_handlers:
exc_class = exc.__class__
else:
for c in mro(exc.__class__)[1:]:
if c in self.error_handlers:
exc_class = c
break
if exc_class:
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:
if 400 in self.error_handlers:
res = await self._invoke_handler(self.error_handlers[400], req)
else:
res = 'Bad request', 400
if isinstance(res, tuple):
res = Response(*res)
elif not isinstance(res, Response):
res = Response(res)
if not after_request_handled:
for handler in self.after_error_request_handlers:
res = await self._invoke_handler(
handler, req, res) or res
res.is_head = (req and req.method == 'HEAD')
return res
async def _invoke_handler(self, f_or_coro, *args, **kwargs):
ret = f_or_coro(*args, **kwargs)
if _iscoroutine(ret):
ret = await ret
return ret
abort = Microdot.abort
Response.already_handled = Response()
redirect = Response.redirect
send_file = Response.send_file

View File

@@ -1,208 +0,0 @@
from microdot_asyncio import Request, Response, _AsyncBytesIO
from microdot_test_client import TestClient as BaseTestClient, \
TestResponse as BaseTestResponse
try:
from microdot_asyncio_websocket import WebSocket
except: # pragma: no cover # noqa: E722
WebSocket = None
class TestResponse(BaseTestResponse):
"""A response object issued by the Microdot test client."""
@classmethod
async def create(cls, res):
test_res = cls()
test_res._initialize_response(res)
await test_res._initialize_body(res)
test_res._process_text_body()
test_res._process_json_body()
return test_res
async def _initialize_body(self, res):
self.body = b''
async for body in res.body_iter(): # pragma: no branch
if isinstance(body, str):
body = body.encode()
self.body += body
class TestClient(BaseTestClient):
"""A test client for Microdot's Asynchronous web server.
:param app: The Microdot application instance.
:param cookies: A dictionary of cookies to use when sending requests to the
application.
The following example shows how to create a test client for an application
and send a test request::
from microdot_asyncio import Microdot
app = Microdot()
@app.get('/')
async def index():
return 'Hello, World!'
async def test_hello_world(self):
client = TestClient(app)
res = await client.get('/')
assert res.status_code == 200
assert res.text == 'Hello, World!'
"""
async def request(self, method, path, headers=None, body=None, sock=None):
headers = headers or {}
body, headers = self._process_body(body, headers)
cookies, headers = self._process_cookies(headers)
request_bytes = self._render_request(method, path, headers, body)
if sock:
reader = sock[0]
reader.buffer = request_bytes
writer = sock[1]
else:
reader = _AsyncBytesIO(request_bytes)
writer = _AsyncBytesIO(b'')
req = await Request.create(self.app, reader, writer,
('127.0.0.1', 1234))
res = await self.app.dispatch_request(req)
if res == Response.already_handled:
return None
res.complete()
self._update_cookies(res)
return await TestResponse.create(res)
async def get(self, path, headers=None):
"""Send a GET request to the application.
:param path: The request URL.
:param headers: A dictionary of headers to send with the request.
This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object.
"""
return await self.request('GET', path, headers=headers)
async def post(self, path, headers=None, body=None):
"""Send a POST request to the application.
:param path: The request URL.
:param headers: A dictionary of headers to send with the request.
:param body: The request body. If a dictionary or list is provided,
a JSON-encoded body will be sent. A string body is encoded
to bytes as UTF-8. A bytes body is sent as-is.
This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object.
"""
return await self.request('POST', path, headers=headers, body=body)
async def put(self, path, headers=None, body=None):
"""Send a PUT request to the application.
:param path: The request URL.
:param headers: A dictionary of headers to send with the request.
:param body: The request body. If a dictionary or list is provided,
a JSON-encoded body will be sent. A string body is encoded
to bytes as UTF-8. A bytes body is sent as-is.
This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object.
"""
return await self.request('PUT', path, headers=headers, body=body)
async def patch(self, path, headers=None, body=None):
"""Send a PATCH request to the application.
:param path: The request URL.
:param headers: A dictionary of headers to send with the request.
:param body: The request body. If a dictionary or list is provided,
a JSON-encoded body will be sent. A string body is encoded
to bytes as UTF-8. A bytes body is sent as-is.
This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object.
"""
return await self.request('PATCH', path, headers=headers, body=body)
async def delete(self, path, headers=None):
"""Send a DELETE request to the application.
:param path: The request URL.
:param headers: A dictionary of headers to send with the request.
This method returns a
:class:`TestResponse <microdot_test_client.TestResponse>` object.
"""
return await self.request('DELETE', path, headers=headers)
async def websocket(self, path, client, headers=None):
"""Send a websocket connection request to the application.
:param path: The request URL.
:param client: A generator function that yields client messages.
:param headers: A dictionary of headers to send with the request.
"""
gen = client()
class FakeWebSocket:
def __init__(self):
self.started = False
self.closed = False
self.buffer = b''
async def _next(self, data=None):
try:
data = (await gen.asend(data)) if hasattr(gen, 'asend') \
else gen.send(data)
except (StopIteration, StopAsyncIteration):
if not self.closed:
self.closed = True
raise OSError(32, 'Websocket connection closed')
return # pragma: no cover
opcode = WebSocket.TEXT if isinstance(data, str) \
else WebSocket.BINARY
return WebSocket._encode_websocket_frame(opcode, data)
async def read(self, n):
if not self.buffer:
self.started = True
self.buffer = await self._next()
data = self.buffer[:n]
self.buffer = self.buffer[n:]
return data
async def readexactly(self, n): # pragma: no cover
return await self.read(n)
async def readline(self):
line = b''
while True:
line += await self.read(1)
if line[-1] in [b'\n', 10]:
break
return line
async def awrite(self, data):
if self.started:
h = WebSocket._parse_frame_header(data[0:2])
if h[3] < 0:
data = data[2 - h[3]:]
else:
data = data[2:]
if h[1] == WebSocket.TEXT:
data = data.decode()
self.buffer = await self._next(data)
ws_headers = {
'Upgrade': 'websocket',
'Connection': 'Upgrade',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
}
ws_headers.update(headers or {})
sock = FakeWebSocket()
return await self.request('GET', path, headers=ws_headers,
sock=(sock, sock))

View File

@@ -1,103 +0,0 @@
from microdot_asyncio import Response
from microdot_websocket import WebSocket as BaseWebSocket
class WebSocket(BaseWebSocket):
async def handshake(self):
response = self._handshake_response()
await self.request.sock[1].awrite(
b'HTTP/1.1 101 Switching Protocols\r\n')
await self.request.sock[1].awrite(b'Upgrade: websocket\r\n')
await self.request.sock[1].awrite(b'Connection: Upgrade\r\n')
await self.request.sock[1].awrite(
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
async def receive(self):
while True:
opcode, payload = await self._read_frame()
send_opcode, data = self._process_websocket_frame(opcode, payload)
if send_opcode: # pragma: no cover
await self.send(data, send_opcode)
elif data: # pragma: no branch
return data
async def send(self, data, opcode=None):
frame = self._encode_websocket_frame(
opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
data)
await self.request.sock[1].awrite(frame)
async def close(self):
if not self.closed: # pragma: no cover
self.closed = True
await self.send(b'', self.CLOSE)
async def _read_frame(self):
header = await self.request.sock[0].read(2)
if len(header) != 2: # pragma: no cover
raise OSError(32, 'Websocket connection closed')
fin, opcode, has_mask, length = self._parse_frame_header(header)
if length == -2:
length = await self.request.sock[0].read(2)
length = int.from_bytes(length, 'big')
elif length == -8:
length = await self.request.sock[0].read(8)
length = int.from_bytes(length, 'big')
if has_mask: # pragma: no cover
mask = await self.request.sock[0].read(4)
payload = await self.request.sock[0].read(length)
if has_mask: # pragma: no cover
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
return opcode, payload
async def websocket_upgrade(request):
"""Upgrade a request handler to a websocket connection.
This function can be called directly inside a route function to process a
WebSocket upgrade handshake, for example after the user's credentials are
verified. The function returns the websocket object::
@app.route('/echo')
async def echo(request):
if not authenticate_user(request):
abort(401)
ws = await websocket_upgrade(request)
while True:
message = await ws.receive()
await ws.send(message)
"""
ws = WebSocket(request)
await ws.handshake()
@request.after_request
async def after_request(request, response):
return Response.already_handled
return ws
def with_websocket(f):
"""Decorator to make a route a WebSocket endpoint.
This decorator is used to define a route that accepts websocket
connections. The route then receives a websocket object as a second
argument that it can use to send and receive messages::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
"""
async def wrapper(request, *args, **kwargs):
ws = await websocket_upgrade(request)
try:
await f(request, ws, *args, **kwargs)
await ws.close() # pragma: no cover
except OSError as exc:
if exc.errno not in [32, 54, 104]: # pragma: no cover
raise
return ''
return wrapper

View File

@@ -1,33 +0,0 @@
from jinja2 import Environment, FileSystemLoader, select_autoescape
_jinja_env = None
def init_templates(template_dir='templates'):
"""Initialize the templating subsystem.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load templates
from a *templates* subdirectory.
"""
global _jinja_env
_jinja_env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape()
)
def render_template(template, *args, **kwargs):
"""Render a template.
:param template: The filename of the template to render, relative to the
configured template directory.
:param args: Positional arguments to be passed to the render engine.
:param kwargs: Keyword arguments to be passed to the render engine.
The return value is a string with the rendered template.
"""
if _jinja_env is None: # pragma: no cover
init_templates()
template = _jinja_env.get_template(template)
return template.render(*args, **kwargs)

View File

@@ -1,98 +0,0 @@
import jwt
secret_key = None
def set_session_secret_key(key):
"""Set the secret key for signing user sessions.
:param key: The secret key, as a string or bytes object.
"""
global secret_key
secret_key = key
def get_session(request):
"""Retrieve the user session.
:param request: The client request.
The return value is a dictionary with the data stored in the user's
session, or ``{}`` if the session data is not available or invalid.
"""
global secret_key
if not secret_key:
raise ValueError('The session secret key is not configured')
if hasattr(request.g, '_session'):
return request.g._session
session = request.cookies.get('session')
if session is None:
request.g._session = {}
return request.g._session
try:
session = jwt.decode(session, secret_key, algorithms=['HS256'])
except jwt.exceptions.PyJWTError: # pragma: no cover
request.g._session = {}
else:
request.g._session = session
return request.g._session
def update_session(request, session):
"""Update the user session.
:param request: The client request.
:param session: A dictionary with the update session data for the user.
Calling this function adds a cookie with the updated session to the request
currently being processed.
"""
if not secret_key:
raise ValueError('The session secret key is not configured')
encoded_session = jwt.encode(session, secret_key, algorithm='HS256')
@request.after_request
def _update_session(request, response):
response.set_cookie('session', encoded_session, http_only=True)
return response
def delete_session(request):
"""Remove the user session.
:param request: The client request.
Calling this function adds a cookie removal header to the request currently
being processed.
"""
@request.after_request
def _delete_session(request, response):
response.set_cookie('session', '', http_only=True,
expires='Thu, 01 Jan 1970 00:00:01 GMT')
return response
def with_session(f):
"""Decorator that passes the user session to the route handler.
The session dictionary is passed to the decorated function as an argument
after the request object. Example::
@app.route('/')
@with_session
def index(request, session):
return 'Hello, World!'
Note that the decorator does not save the session. To update the session,
call the :func:`update_session <microdot_session.update_session>` function.
"""
def wrapper(request, *args, **kwargs):
return f(request, get_session(request), *args, **kwargs)
for attr in ['__name__', '__doc__', '__module__', '__qualname__']:
try:
setattr(wrapper, attr, getattr(f, attr))
except AttributeError: # pragma: no cover
pass
return wrapper

View File

@@ -1,61 +0,0 @@
import ssl
def create_ssl_context(cert, key, **kwargs):
"""Create an SSL context to wrap sockets with.
:param cert: The certificate to use. If it is given as a string, it is
assumed to be a filename. If it is given as a bytes object, it
is assumed to be the certificate data. In both cases the data
is expected to be in PEM format for CPython and in DER format
for MicroPython.
:param key: The private key to use. If it is given as a string, it is
assumed to be a filename. If it is given as a bytes object, it
is assumed to be the private key data. in both cases the data
is expected to be in PEM format for CPython and in DER format
for MicroPython.
:param kwargs: Additional arguments to pass to the ``ssl.wrap_socket``
function.
Note: This function creates a fairly limited SSL context object to enable
the use of certificates under MicroPython. It is not intended to be used in
any other context, and in particular, it is not needed when using CPython
or any other Python implementation that has native support for
``SSLContext`` objects. Once MicroPython implements ``SSLContext``
natively, this function will be deprecated.
"""
if hasattr(ssl, 'SSLContext'):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER, **kwargs)
ctx.load_cert_chain(cert, key)
return ctx
if isinstance(cert, str):
with open(cert, 'rb') as f:
cert = f.read()
if isinstance(key, str):
with open(key, 'rb') as f:
key = f.read()
class FakeSSLSocket:
def __init__(self, sock, **kwargs):
self.sock = sock
self.kwargs = kwargs
def accept(self):
client, addr = self.sock.accept()
return (ssl.wrap_socket(client, cert=cert, key=key, **self.kwargs),
addr)
def close(self):
self.sock.close()
class FakeSSLContext:
def __init__(self, **kwargs):
self.kwargs = kwargs
def wrap_socket(self, sock, **kwargs):
all_kwargs = self.kwargs.copy()
all_kwargs.update(kwargs)
return FakeSSLSocket(sock, **all_kwargs)
return FakeSSLContext(**kwargs)

View File

@@ -1,34 +0,0 @@
from utemplate import recompile
_loader = None
def init_templates(template_dir='templates', loader_class=recompile.Loader):
"""Initialize the templating subsystem.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load templates
from a *templates* subdirectory.
:param loader_class: the ``utemplate.Loader`` class to use when loading
templates. This argument is optional. The default is
the ``recompile.Loader`` class, which automatically
recompiles templates when they change.
"""
global _loader
_loader = loader_class(None, template_dir)
def render_template(template, *args, **kwargs):
"""Render a template.
:param template: The filename of the template to render, relative to the
configured template directory.
:param args: Positional arguments to be passed to the render engine.
:param kwargs: Keyword arguments to be passed to the render engine.
The return value is an iterator that returns sections of rendered template.
"""
if _loader is None: # pragma: no cover
init_templates()
render = _loader.load(template)
return render(*args, **kwargs)

View File

@@ -1,114 +0,0 @@
import binascii
import hashlib
import select
import websocket as _websocket
from microdot import Response
class WebSocket:
CONT = 0
TEXT = 1
BINARY = 2
CLOSE = 8
PING = 9
PONG = 10
def __init__(self, request):
self.request = request
self.poll = select.poll()
self.poll.register(self.request.sock, select.POLLIN)
self.ws = _websocket.websocket(self.request.sock, True)
self.request.sock.setblocking(False)
def handshake(self):
response = self._handshake_response()
self.request.sock.write(b'HTTP/1.1 101 Switching Protocols\r\n')
self.request.sock.write(b'Upgrade: websocket\r\n')
self.request.sock.write(b'Connection: Upgrade\r\n')
self.request.sock.write(
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
def receive(self):
while True:
self.poll.poll()
data = self.ws.read()
if data:
try:
data = data.decode()
except ValueError:
pass
return data
def send(self, data):
self.ws.write(data)
def close(self):
self.poll.unregister(self.request.sock)
self.ws.close()
def _handshake_response(self):
for header, value in self.request.headers.items():
h = header.lower()
if h == 'connection' and not value.lower().startswith('upgrade'):
return self.request.app.abort(400)
elif h == 'upgrade' and not value.lower() == 'websocket':
return self.request.app.abort(400)
elif h == 'sec-websocket-key':
websocket_key = value
if not websocket_key:
return self.request.app.abort(400)
d = hashlib.sha1(websocket_key.encode())
d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
return binascii.b2a_base64(d.digest())[:-1]
def websocket_upgrade(request):
"""Upgrade a request handler to a websocket connection.
This function can be called directly inside a route function to process a
WebSocket upgrade handshake, for example after the user's credentials are
verified. The function returns the websocket object::
@app.route('/echo')
def echo(request):
if not authenticate_user(request):
abort(401)
ws = websocket_upgrade(request)
while True:
message = ws.receive()
ws.send(message)
"""
ws = WebSocket(request)
ws.handshake()
@request.after_request
def after_request(request, response):
return Response.already_handled
return ws
def with_websocket(f):
"""Decorator to make a route a WebSocket endpoint.
This decorator is used to define a route that accepts websocket
connections. The route then receives a websocket object as a second
argument that it can use to send and receive messages::
@app.route('/echo')
@with_websocket
def echo(request, ws):
while True:
message = ws.receive()
ws.send(message)
"""
def wrapper(request, *args, **kwargs):
ws = websocket_upgrade(request)
try:
f(request, ws, *args, **kwargs)
except OSError as exc:
if exc.errno != 32 and exc.errno != 54:
raise
ws.close()
return ''
return wrapper

View File

@@ -1,64 +0,0 @@
import os
import signal
from microdot import * # noqa: F401, F403
from microdot import Microdot as BaseMicrodot, Request, NoCaseDict
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 = NoCaseDict()
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'],
sock=environ.get('gunicorn.socket'))
req.environ = environ
res = self.dispatch_request(req)
res.complete()
reason = res.reason or ('OK' if res.status_code == 200 else 'N/A')
header_list = []
for name, value in res.headers.items():
if not isinstance(value, list):
header_list.append((name, value))
else:
for v in value:
header_list.append((name, v))
start_response(str(res.status_code) + ' ' + reason, header_list)
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

@@ -1,14 +1,11 @@
from .test_multidict import TestMultiDict from tests.test_microdot import * # noqa: F401, F403
from .test_request import TestRequest from tests.test_multidict import * # noqa: F401, F403
from .test_response import TestResponse from tests.test_request import * # noqa: F401, F403
from .test_url_pattern import TestURLPattern from tests.test_response import * # noqa: F401, F403
from .test_microdot import TestMicrodot from tests.test_urlencode import * # noqa: F401, F403
from .test_microdot_websocket import TestMicrodotWebSocket from tests.test_url_pattern import * # noqa: F401, F403
from tests.test_websocket import * # noqa: F401, F403
from .test_request_asyncio import TestRequestAsync from tests.test_sse import * # noqa: F401, F403
from .test_response_asyncio import TestResponseAsync from tests.test_cors import * # noqa: F401, F403
from .test_microdot_asyncio import TestMicrodotAsync from tests.test_utemplate import * # noqa: F401, F403
from .test_microdot_asyncio_websocket import TestMicrodotAsyncWebSocket from tests.test_session import * # noqa: F401, F403
from .test_utemplate import TestUTemplate
from .test_session import TestSession

View File

@@ -1,39 +0,0 @@
try:
import uasyncio as asyncio
except ImportError:
import asyncio
from tests import mock_socket
def get_event_loop():
return asyncio.get_event_loop()
async def start_server(cb, host, port):
class MockServer:
def __init__(self):
self.closed = False
async def run(self):
s = mock_socket.socket()
while not self.closed:
fd, addr = s.accept()
fd = mock_socket.FakeStreamAsync(fd)
await cb(fd, fd)
def close(self):
self.closed = True
async def wait_closed(self):
while not self.closed:
await asyncio.sleep(0.01)
server = MockServer()
asyncio.get_event_loop().create_task(server.run())
return server
def run(coro):
loop = asyncio.get_event_loop()
return loop.run_until_complete(coro)

View File

@@ -1,23 +1,23 @@
import unittest import asyncio
import sys import sys
import unittest
from unittest import mock
try: from microdot.asgi import Microdot, Response
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', @unittest.skipIf(sys.implementation.name == 'micropython',
'not supported under MicroPython') 'not supported under MicroPython')
class TestMicrodotASGI(unittest.TestCase): class TestASGI(unittest.TestCase):
@classmethod
def setUpClass(cls):
if hasattr(asyncio, 'set_event_loop'):
asyncio.set_event_loop(asyncio.new_event_loop())
cls.loop = asyncio.get_event_loop()
def _run(self, coro):
return self.loop.run_until_complete(coro)
def test_asgi_request_with_query_string(self): def test_asgi_request_with_query_string(self):
app = Microdot() app = Microdot()
@@ -98,7 +98,7 @@ class TestMicrodotASGI(unittest.TestCase):
original_buffer_size = Response.send_file_buffer_size original_buffer_size = Response.send_file_buffer_size
Response.send_file_buffer_size = 2 Response.send_file_buffer_size = 2
mock_asyncio.run(app(scope, receive, send)) self._run(app(scope, receive, send))
Response.send_file_buffer_size = original_buffer_size Response.send_file_buffer_size = original_buffer_size
@@ -143,7 +143,7 @@ class TestMicrodotASGI(unittest.TestCase):
async def send(packet): async def send(packet):
pass pass
mock_asyncio.run(app(scope, receive, send)) self._run(app(scope, receive, send))
def test_shutdown(self): def test_shutdown(self):
app = Microdot() app = Microdot()
@@ -166,7 +166,7 @@ class TestMicrodotASGI(unittest.TestCase):
async def send(packet): async def send(packet):
pass pass
with mock.patch('microdot_asgi.os.kill') as kill: with mock.patch('microdot.asgi.os.kill') as kill:
mock_asyncio.run(app(scope, receive, send)) self._run(app(scope, receive, send))
kill.assert_called() kill.assert_called()

View File

@@ -1,10 +1,18 @@
import asyncio
import unittest import unittest
from microdot import Microdot from microdot import Microdot
from microdot_test_client import TestClient from microdot.test_client import TestClient
from microdot_cors import CORS from microdot.cors import CORS
class TestCORS(unittest.TestCase): class TestCORS(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.loop = asyncio.new_event_loop()
def _run(self, coro):
return self.loop.run_until_complete(coro)
def test_origin(self): def test_origin(self):
app = Microdot() app = Microdot()
cors = CORS(allowed_origins=['https://example.com'], cors = CORS(allowed_origins=['https://example.com'],
@@ -16,13 +24,14 @@ class TestCORS(unittest.TestCase):
return 'foo' return 'foo'
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertFalse('Access-Control-Allow-Origin' in res.headers) self.assertFalse('Access-Control-Allow-Origin' in res.headers)
self.assertFalse('Access-Control-Allow-Credentials' in res.headers) self.assertFalse('Access-Control-Allow-Credentials' in res.headers)
self.assertFalse('Vary' in res.headers) self.assertFalse('Vary' in res.headers)
res = client.get('/', headers={'Origin': 'https://example.com'}) res = self._run(client.get(
'/', headers={'Origin': 'https://example.com'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Access-Control-Allow-Origin'], self.assertEqual(res.headers['Access-Control-Allow-Origin'],
'https://example.com') 'https://example.com')
@@ -32,14 +41,15 @@ class TestCORS(unittest.TestCase):
cors.allow_credentials = False cors.allow_credentials = False
res = client.get('/foo', headers={'Origin': 'https://example.com'}) res = self._run(client.get(
'/foo', headers={'Origin': 'https://example.com'}))
self.assertEqual(res.status_code, 404) self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Access-Control-Allow-Origin'], self.assertEqual(res.headers['Access-Control-Allow-Origin'],
'https://example.com') 'https://example.com')
self.assertFalse('Access-Control-Allow-Credentials' in res.headers) self.assertFalse('Access-Control-Allow-Credentials' in res.headers)
self.assertEqual(res.headers['Vary'], 'Origin') self.assertEqual(res.headers['Vary'], 'Origin')
res = client.get('/', headers={'Origin': 'https://bad.com'}) res = self._run(client.get('/', headers={'Origin': 'https://bad.com'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertFalse('Access-Control-Allow-Origin' in res.headers) self.assertFalse('Access-Control-Allow-Origin' in res.headers)
self.assertFalse('Access-Control-Allow-Credentials' in res.headers) self.assertFalse('Access-Control-Allow-Credentials' in res.headers)
@@ -58,14 +68,15 @@ class TestCORS(unittest.TestCase):
return 'foo', {'Vary': 'X-Foo, X-Bar'} return 'foo', {'Vary': 'X-Foo, X-Bar'}
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Access-Control-Allow-Origin'], '*') self.assertEqual(res.headers['Access-Control-Allow-Origin'], '*')
self.assertFalse('Vary' in res.headers) self.assertFalse('Vary' in res.headers)
self.assertEqual(res.headers['Access-Control-Expose-Headers'], self.assertEqual(res.headers['Access-Control-Expose-Headers'],
'X-Test, X-Test2') 'X-Test, X-Test2')
res = client.get('/', headers={'Origin': 'https://example.com'}) res = self._run(client.get(
'/', headers={'Origin': 'https://example.com'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Access-Control-Allow-Origin'], self.assertEqual(res.headers['Access-Control-Allow-Origin'],
'https://example.com') 'https://example.com')
@@ -73,7 +84,8 @@ class TestCORS(unittest.TestCase):
self.assertEqual(res.headers['Access-Control-Expose-Headers'], self.assertEqual(res.headers['Access-Control-Expose-Headers'],
'X-Test, X-Test2') 'X-Test, X-Test2')
res = client.get('/bad', headers={'Origin': 'https://example.com'}) res = self._run(client.get(
'/bad', headers={'Origin': 'https://example.com'}))
self.assertEqual(res.status_code, 404) self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Access-Control-Allow-Origin'], self.assertEqual(res.headers['Access-Control-Allow-Origin'],
'https://example.com') 'https://example.com')
@@ -81,7 +93,8 @@ class TestCORS(unittest.TestCase):
self.assertEqual(res.headers['Access-Control-Expose-Headers'], self.assertEqual(res.headers['Access-Control-Expose-Headers'],
'X-Test, X-Test2') 'X-Test, X-Test2')
res = client.get('/foo', headers={'Origin': 'https://example.com'}) res = self._run(client.get(
'/foo', headers={'Origin': 'https://example.com'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Vary'], 'X-Foo, X-Bar, Origin') self.assertEqual(res.headers['Vary'], 'X-Foo, X-Bar, Origin')
@@ -94,10 +107,10 @@ class TestCORS(unittest.TestCase):
return 'foo' return 'foo'
client = TestClient(app) client = TestClient(app)
res = client.request('OPTIONS', '/', headers={ res = self._run(client.request('OPTIONS', '/', headers={
'Origin': 'https://example.com', 'Origin': 'https://example.com',
'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'X-Test, X-Test2'}) 'Access-Control-Request-Headers': 'X-Test, X-Test2'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Access-Control-Allow-Origin'], self.assertEqual(res.headers['Access-Control-Allow-Origin'],
'https://example.com') 'https://example.com')
@@ -106,8 +119,8 @@ class TestCORS(unittest.TestCase):
self.assertEqual(res.headers['Access-Control-Allow-Headers'], self.assertEqual(res.headers['Access-Control-Allow-Headers'],
'X-Test, X-Test2') 'X-Test, X-Test2')
res = client.request('OPTIONS', '/', headers={ res = self._run(client.request('OPTIONS', '/', headers={
'Origin': 'https://example.com'}) 'Origin': 'https://example.com'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Access-Control-Allow-Origin'], self.assertEqual(res.headers['Access-Control-Allow-Origin'],
'https://example.com') 'https://example.com')
@@ -125,10 +138,10 @@ class TestCORS(unittest.TestCase):
return 'foo' return 'foo'
client = TestClient(app) client = TestClient(app)
res = client.request('OPTIONS', '/', headers={ res = self._run(client.request('OPTIONS', '/', headers={
'Origin': 'https://example.com', 'Origin': 'https://example.com',
'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'X-Test, X-Test2'}) 'Access-Control-Request-Headers': 'X-Test, X-Test2'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Access-Control-Allow-Origin'], self.assertEqual(res.headers['Access-Control-Allow-Origin'],
'https://example.com') 'https://example.com')
@@ -136,9 +149,9 @@ class TestCORS(unittest.TestCase):
self.assertEqual(res.headers['Access-Control-Allow-Methods'], 'POST') self.assertEqual(res.headers['Access-Control-Allow-Methods'], 'POST')
self.assertEqual(res.headers['Access-Control-Allow-Headers'], 'X-Test') self.assertEqual(res.headers['Access-Control-Allow-Headers'], 'X-Test')
res = client.request('OPTIONS', '/', headers={ res = self._run(client.request('OPTIONS', '/', headers={
'Origin': 'https://example.com', 'Origin': 'https://example.com',
'Access-Control-Request-Method': 'GET'}) 'Access-Control-Request-Method': 'GET'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertFalse('Access-Control-Allow-Methods' in res.headers) self.assertFalse('Access-Control-Allow-Methods' in res.headers)
self.assertFalse('Access-Control-Allow-Headers' in res.headers) self.assertFalse('Access-Control-Allow-Headers' in res.headers)
@@ -152,7 +165,7 @@ class TestCORS(unittest.TestCase):
return 'foo' return 'foo'
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertFalse('Access-Control-Allow-Origin' in res.headers) self.assertFalse('Access-Control-Allow-Origin' in res.headers)
self.assertFalse('Vary' in res.headers) self.assertFalse('Vary' in res.headers)

104
tests/test_end2end.py Normal file
View File

@@ -0,0 +1,104 @@
import asyncio
import sys
import time
import unittest
from microdot import Microdot
class TestEnd2End(unittest.TestCase):
async def request(self, url, method='GET'):
while True:
reader, writer = await asyncio.open_connection('localhost', 5678)
try:
writer.write(f'{method} {url} HTTP/1.0\r\n\r\n'.encode())
break
except OSError:
# micropython's server sometimes needs a moment
writer.close()
await writer.wait_closed()
await asyncio.sleep(0.1)
await writer.drain()
response = await reader.read()
writer.close()
await writer.wait_closed()
return response.decode().splitlines()
def test_get(self):
app = Microdot()
@app.route('/')
def index(request):
return 'Hello, World!'
@app.route('/shutdown')
def shutdown(request):
app.shutdown()
return ''
async def run():
server = asyncio.create_task(app.start_server(host='127.0.0.1',
port=5678))
await asyncio.sleep(0.1)
response = await self.request('/')
self.assertEqual(response[0], 'HTTP/1.0 200 OK')
self.assertEqual(response[-1], 'Hello, World!')
await self.request('/shutdown')
await server
asyncio.run(run())
@unittest.skipIf(sys.implementation.name == 'micropython',
'not supported under MicroPython')
def test_concurrent_requests(self):
app = Microdot()
counter = 0
@app.route('/async1')
async def async1(request):
nonlocal counter
counter += 1
while counter < 4:
await asyncio.sleep(0.01)
return 'OK'
@app.route('/async2')
async def async2(request):
nonlocal counter
counter += 1
while counter < 4:
await asyncio.sleep(0.01)
return 'OK'
@app.route('/sync1')
def sync1(request):
nonlocal counter
counter += 1
while counter < 4:
time.sleep(0.01)
return 'OK'
@app.route('/sync2')
def sync2(request):
nonlocal counter
counter += 1
while counter < 4:
time.sleep(0.01)
return 'OK'
@app.route('/shutdown')
def shutdown(request):
app.shutdown()
return ''
async def run():
server = asyncio.create_task(app.start_server(port=5678))
await asyncio.sleep(0.1)
await asyncio.gather(self.request('/async1'),
self.request('/async2'),
self.request('/sync1'),
self.request('/sync2'))
await self.request('/shutdown')
await server
asyncio.run(run())

View File

@@ -1,58 +1,81 @@
try: import asyncio
import uasyncio as asyncio
except ImportError:
import asyncio
import sys import sys
import unittest import unittest
from microdot import Microdot, Request from microdot import Microdot
from microdot_asyncio import Microdot as MicrodotAsync, Request as RequestAsync from microdot.jinja import Template, init_templates
from microdot_jinja import render_template, init_templates from microdot.test_client import TestClient
from tests.mock_socket import get_request_fd, get_async_request_fd
init_templates('tests/templates') init_templates('tests/templates')
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
@unittest.skipIf(sys.implementation.name == 'micropython', @unittest.skipIf(sys.implementation.name == 'micropython',
'not supported under MicroPython') 'not supported under MicroPython')
class TestJinja(unittest.TestCase): class TestJinja(unittest.TestCase):
@classmethod
def setUpClass(cls):
if hasattr(asyncio, 'set_event_loop'):
asyncio.set_event_loop(asyncio.new_event_loop())
cls.loop = asyncio.get_event_loop()
def _run(self, coro):
return self.loop.run_until_complete(coro)
def test_render_template(self): def test_render_template(self):
s = render_template('hello.jinja.txt', name='foo') s = Template('hello.jinja.txt').render(name='foo')
self.assertEqual(s, 'Hello, foo!') self.assertEqual(s, 'Hello, foo!')
def test_render_template_in_app(self): def test_render_template_in_app(self):
app = Microdot() app = Microdot()
@app.route('/') @app.route('/')
def index(req): async def index(req):
return render_template('hello.jinja.txt', name='foo') return Template('hello.jinja.txt').render(name='foo')
req = Request.create(app, get_request_fd('GET', '/'), 'addr') client = TestClient(app)
res = app.dispatch_request(req) res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(list(res.body_iter()), [b'Hello, foo!']) self.assertEqual(res.body, b'Hello, foo!')
def test_render_template_in_app_async(self): def test_generate_template_in_app(self):
app = MicrodotAsync() app = Microdot()
@app.route('/') @app.route('/')
async def index(req): async def index(req):
return render_template('hello.jinja.txt', name='foo') return Template('hello.jinja.txt').generate(name='foo')
req = _run(RequestAsync.create( client = TestClient(app)
app, get_async_request_fd('GET', '/'), 'writer', 'addr')) res = self._run(client.get('/'))
res = _run(app.dispatch_request(req))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.body, b'Hello, foo!')
async def get_result(): def test_render_async_template_in_app(self):
result = [] init_templates('tests/templates', enable_async=True)
async for chunk in res.body_iter():
result.append(chunk)
return result
result = _run(get_result()) app = Microdot()
self.assertEqual(result, [b'Hello, foo!'])
@app.route('/')
async def index(req):
return await Template('hello.jinja.txt').render_async(name='foo')
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.body, b'Hello, foo!')
init_templates('tests/templates')
def test_generate_async_template_in_app(self):
init_templates('tests/templates', enable_async=True)
app = Microdot()
@app.route('/')
async def index(req):
return Template('hello.jinja.txt').generate_async(name='foo')
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.body, b'Hello, foo!')
init_templates('tests/templates')

View File

@@ -1,31 +1,18 @@
import sys import asyncio
import unittest import unittest
from microdot import Microdot, Response, abort from microdot import Microdot, Response, abort
from microdot_test_client import TestClient from microdot.test_client import TestClient
from tests import mock_socket
class TestMicrodot(unittest.TestCase): class TestMicrodot(unittest.TestCase):
def _mock(self): @classmethod
def mock_create_thread(f, *args, **kwargs): def setUpClass(cls):
f(*args, **kwargs) if hasattr(asyncio, 'set_event_loop'):
asyncio.set_event_loop(asyncio.new_event_loop())
cls.loop = asyncio.get_event_loop()
self.original_socket = sys.modules['microdot'].socket def _run(self, coro):
self.original_create_thread = sys.modules['microdot'].create_thread return self.loop.run_until_complete(coro)
sys.modules['microdot'].socket = mock_socket
sys.modules['microdot'].create_thread = mock_create_thread
def _unmock(self):
sys.modules['microdot'].socket = self.original_socket
sys.modules['microdot'].create_thread = self.original_create_thread
def _add_shutdown(self, app):
@app.route('/shutdown')
def shutdown(req):
app.shutdown()
return ''
mock_socket.add_request('GET', '/shutdown')
def test_get_request(self): def test_get_request(self):
app = Microdot() app = Microdot()
@@ -34,8 +21,13 @@ class TestMicrodot(unittest.TestCase):
def index(req): def index(req):
return 'foo' return 'foo'
@app.route('/async')
async def index2(req):
return 'foo-async'
client = TestClient(app) client = TestClient(app)
res = client.get('/')
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -44,6 +36,15 @@ class TestMicrodot(unittest.TestCase):
self.assertEqual(res.body, b'foo') self.assertEqual(res.body, b'foo')
self.assertEqual(res.json, None) self.assertEqual(res.json, None)
res = self._run(client.get('/async'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Content-Length'], '9')
self.assertEqual(res.text, 'foo-async')
self.assertEqual(res.body, b'foo-async')
self.assertEqual(res.json, None)
def test_post_request(self): def test_post_request(self):
app = Microdot() app = Microdot()
@@ -55,78 +56,71 @@ class TestMicrodot(unittest.TestCase):
def index_post(req): def index_post(req):
return Response('bar') return Response('bar')
@app.route('/async', methods=['POST'])
async def index_post2(req):
return Response('bar-async')
client = TestClient(app) client = TestClient(app)
res = client.post('/')
res = self._run(client.post('/'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Content-Length'], '3') self.assertEqual(res.headers['Content-Length'], '3')
self.assertEqual(res.text, 'bar') self.assertEqual(res.text, 'bar')
self.assertEqual(res.body, b'bar')
self.assertEqual(res.json, None)
res = self._run(client.post('/async'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Content-Length'], '9')
self.assertEqual(res.text, 'bar-async')
self.assertEqual(res.body, b'bar-async')
self.assertEqual(res.json, None)
def test_head_request(self): def test_head_request(self):
self._mock()
app = Microdot() app = Microdot()
@app.route('/foo') @app.route('/foo')
def index(req): def index(req):
return 'foo' return 'foo'
mock_socket.clear_requests() client = TestClient(app)
fd = mock_socket.add_request('HEAD', '/foo') res = self._run(client.request('HEAD', '/foo'))
self._add_shutdown(app) self.assertEqual(res.status_code, 200)
app.run() self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n')) self.assertEqual(res.headers['Content-Length'], '3')
self.assertIn(b'Content-Length: 3\r\n', fd.response) self.assertIsNone(res.body)
self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n', self.assertIsNone(res.text)
fd.response) self.assertIsNone(res.json)
self.assertTrue(fd.response.endswith(b'\r\n\r\n'))
self._unmock()
def test_options_request(self): def test_options_request(self):
app = Microdot() app = Microdot()
@app.route('/', methods=['GET', 'DELETE']) @app.route('/', methods=['GET', 'DELETE'])
def index(req): async def index(req):
return 'foo' return 'foo'
@app.post('/') @app.post('/')
def index_post(req): async def index_post(req):
return 'bar' return 'bar'
@app.route('/foo', methods=['POST', 'PUT']) @app.route('/foo', methods=['POST', 'PUT'])
def foo(req): async def foo(req):
return 'baz' return 'baz'
client = TestClient(app) client = TestClient(app)
res = client.request('OPTIONS', '/') res = self._run(client.request('OPTIONS', '/'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Allow'], self.assertEqual(res.headers['Allow'],
'GET, DELETE, POST, HEAD, OPTIONS') 'GET, DELETE, POST, HEAD, OPTIONS')
res = client.request('OPTIONS', '/foo') res = self._run(client.request('OPTIONS', '/foo'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Allow'], 'POST, PUT, OPTIONS') self.assertEqual(res.headers['Allow'], 'POST, PUT, OPTIONS')
def test_empty_request(self):
self._mock()
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; charset=UTF-8\r\n',
fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request'))
self._unmock()
def test_method_decorators(self): def test_method_decorators(self):
app = Microdot() app = Microdot()
@@ -135,7 +129,7 @@ class TestMicrodot(unittest.TestCase):
return 'GET' return 'GET'
@app.post('/post') @app.post('/post')
def post(req): async def post(req):
return 'POST' return 'POST'
@app.put('/put') @app.put('/put')
@@ -143,7 +137,7 @@ class TestMicrodot(unittest.TestCase):
return 'PUT' return 'PUT'
@app.patch('/patch') @app.patch('/patch')
def patch(req): async def patch(req):
return 'PATCH' return 'PATCH'
@app.delete('/delete') @app.delete('/delete')
@@ -153,7 +147,8 @@ class TestMicrodot(unittest.TestCase):
client = TestClient(app) client = TestClient(app)
methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
for method in methods: for method in methods:
res = getattr(client, method.lower())('/' + method.lower()) res = self._run(getattr(
client, method.lower())('/' + method.lower()))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -167,7 +162,7 @@ class TestMicrodot(unittest.TestCase):
return req.headers.get('X-Foo') return req.headers.get('X-Foo')
client = TestClient(app) client = TestClient(app)
res = client.get('/', headers={'X-Foo': 'bar'}) res = self._run(client.get('/', headers={'X-Foo': 'bar'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -178,15 +173,19 @@ class TestMicrodot(unittest.TestCase):
@app.route('/') @app.route('/')
def index(req): def index(req):
return req.cookies['one'] + req.cookies['two'] + \ res = Response(
req.cookies['three'] req.cookies['one'] + req.cookies['two'] + req.cookies['three'])
res.set_cookie('four', '4')
res.delete_cookie('two', path='/')
return res
client = TestClient(app, cookies={'one': '1', 'two': '2'}) client = TestClient(app, cookies={'one': '1', 'two': '2'})
res = client.get('/', headers={'Cookie': 'three=3'}) res = self._run(client.get('/', headers={'Cookie': 'three=3'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertEqual(res.text, '123') self.assertEqual(res.text, '123')
self.assertEqual(client.cookies, {'one': '1', 'four': '4'})
def test_binary_payload(self): def test_binary_payload(self):
app = Microdot() app = Microdot()
@@ -196,7 +195,7 @@ class TestMicrodot(unittest.TestCase):
return req.body return req.body
client = TestClient(app) client = TestClient(app)
res = client.post('/', body=b'foo') res = self._run(client.post('/', body=b'foo'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -216,13 +215,13 @@ class TestMicrodot(unittest.TestCase):
client = TestClient(app) client = TestClient(app)
res = client.post('/dict', body={'foo': 'bar'}) res = self._run(client.post('/dict', body={'foo': 'bar'}))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'bar') self.assertEqual(res.text, 'bar')
res = client.post('/list', body=['foo', 'bar']) res = self._run(client.post('/list', body=['foo', 'bar']))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -250,24 +249,24 @@ class TestMicrodot(unittest.TestCase):
client = TestClient(app) client = TestClient(app)
res = client.get('/body') res = self._run(client.get('/body'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'one') self.assertEqual(res.text, 'one')
res = client.get('/body-status') res = self._run(client.get('/body-status'))
self.assertEqual(res.status_code, 202) self.assertEqual(res.status_code, 202)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'two') self.assertEqual(res.text, 'two')
res = client.get('/body-headers') res = self._run(client.get('/body-headers'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], 'text/html') self.assertEqual(res.headers['Content-Type'], 'text/html')
self.assertEqual(res.text, '<p>three</p>') self.assertEqual(res.text, '<p>three</p>')
res = client.get('/body-status-headers') res = self._run(client.get('/body-status-headers'))
self.assertEqual(res.status_code, 202) self.assertEqual(res.status_code, 202)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/html; charset=UTF-8') 'text/html; charset=UTF-8')
@@ -280,7 +279,7 @@ class TestMicrodot(unittest.TestCase):
def before_request(req): def before_request(req):
if req.path == '/bar': if req.path == '/bar':
@req.after_request @req.after_request
def after_request(req, res): async def after_request(req, res):
res.headers['X-Two'] = '2' res.headers['X-Two'] = '2'
return res return res
return 'bar', 202 return 'bar', 202
@@ -291,7 +290,7 @@ class TestMicrodot(unittest.TestCase):
res.headers['X-One'] = '1' res.headers['X-One'] = '1'
@app.after_request @app.after_request
def after_request_two(req, res): async def after_request_two(req, res):
res.set_cookie('foo', 'bar') res.set_cookie('foo', 'bar')
return res return res
@@ -305,7 +304,7 @@ class TestMicrodot(unittest.TestCase):
client = TestClient(app) client = TestClient(app)
res = client.get('/bar') res = self._run(client.get('/bar'))
self.assertEqual(res.status_code, 202) self.assertEqual(res.status_code, 202)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -315,7 +314,7 @@ class TestMicrodot(unittest.TestCase):
self.assertEqual(res.text, 'bar') self.assertEqual(res.text, 'bar')
self.assertEqual(client.cookies['foo'], 'bar') self.assertEqual(client.cookies['foo'], 'bar')
res = client.get('/baz') res = self._run(client.get('/baz'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -343,14 +342,14 @@ class TestMicrodot(unittest.TestCase):
client = TestClient(app) client = TestClient(app)
res = client.get('/foo') res = self._run(client.get('/foo'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertFalse('X-One' in res.headers) self.assertFalse('X-One' in res.headers)
self.assertFalse('Set-Cookie' in res.headers) self.assertFalse('Set-Cookie' in res.headers)
res = client.get('/bar') res = self._run(client.get('/bar'))
self.assertEqual(res.status_code, 404) self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -359,44 +358,22 @@ class TestMicrodot(unittest.TestCase):
self.assertEqual(client.cookies['foo'], 'bar') self.assertEqual(client.cookies['foo'], 'bar')
def test_400(self): def test_400(self):
self._mock()
app = Microdot() app = Microdot()
mock_socket.clear_requests() res = self._run(app.dispatch_request(None))
fd = mock_socket.FakeStream(b'\n') self.assertEqual(res.status_code, 400)
mock_socket._requests.append(fd) self.assertEqual(res.body, b'Bad request')
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; charset=UTF-8\r\n',
fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request'))
self._unmock()
def test_400_handler(self): def test_400_handler(self):
self._mock()
app = Microdot() app = Microdot()
@app.errorhandler(400) @app.errorhandler(400)
def handle_400(req): async def handle_400(req):
return '400' return '400'
mock_socket.clear_requests() res = self._run(app.dispatch_request(None))
fd = mock_socket.FakeStream(b'\n') self.assertEqual(res.status_code, 200)
mock_socket._requests.append(fd) self.assertEqual(res.body, b'400')
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n'))
self.assertIn(b'Content-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n',
fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n400'))
self._unmock()
def test_404(self): def test_404(self):
app = Microdot() app = Microdot()
@@ -406,7 +383,7 @@ class TestMicrodot(unittest.TestCase):
return 'foo' return 'foo'
client = TestClient(app) client = TestClient(app)
res = client.post('/foo') res = self._run(client.post('/foo'))
self.assertEqual(res.status_code, 404) self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -420,11 +397,11 @@ class TestMicrodot(unittest.TestCase):
return 'foo' return 'foo'
@app.errorhandler(404) @app.errorhandler(404)
def handle_404(req): async def handle_404(req):
return '404' return '404'
client = TestClient(app) client = TestClient(app)
res = client.post('/foo') res = self._run(client.post('/foo'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -438,7 +415,7 @@ class TestMicrodot(unittest.TestCase):
return 'foo' return 'foo'
client = TestClient(app) client = TestClient(app)
res = client.post('/foo') res = self._run(client.post('/foo'))
self.assertEqual(res.status_code, 405) self.assertEqual(res.status_code, 405)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -452,11 +429,11 @@ class TestMicrodot(unittest.TestCase):
return 'foo' return 'foo'
@app.errorhandler(405) @app.errorhandler(405)
def handle_405(req): async def handle_405(req):
return '405', 405 return '405', 405
client = TestClient(app) client = TestClient(app)
res = client.patch('/foo') res = self._run(client.patch('/foo'))
self.assertEqual(res.status_code, 405) self.assertEqual(res.status_code, 405)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -465,12 +442,12 @@ class TestMicrodot(unittest.TestCase):
def test_413(self): def test_413(self):
app = Microdot() app = Microdot()
@app.post('/') @app.route('/')
def index(req): def index(req):
return 'foo' return 'foo'
client = TestClient(app) client = TestClient(app)
res = client.post('/foo', body='x' * 17000) res = self._run(client.post('/foo', body='x' * 17000))
self.assertEqual(res.status_code, 413) self.assertEqual(res.status_code, 413)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -484,11 +461,11 @@ class TestMicrodot(unittest.TestCase):
return 'foo' return 'foo'
@app.errorhandler(413) @app.errorhandler(413)
def handle_413(req): async def handle_413(req):
return '413', 400 return '413', 400
client = TestClient(app) client = TestClient(app)
res = client.post('/foo', body='x' * 17000) res = self._run(client.post('/foo', body='x' * 17000))
self.assertEqual(res.status_code, 400) self.assertEqual(res.status_code, 400)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -502,7 +479,7 @@ class TestMicrodot(unittest.TestCase):
return 1 / 0 return 1 / 0
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 500) self.assertEqual(res.status_code, 500)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -520,7 +497,7 @@ class TestMicrodot(unittest.TestCase):
return '501', 501 return '501', 501
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501) self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -534,11 +511,11 @@ class TestMicrodot(unittest.TestCase):
return 1 / 0 return 1 / 0
@app.errorhandler(ZeroDivisionError) @app.errorhandler(ZeroDivisionError)
def handle_div_zero(req, exc): async def handle_div_zero(req, exc):
return '501', 501 return '501', 501
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501) self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -553,11 +530,11 @@ class TestMicrodot(unittest.TestCase):
return foo[1] return foo[1]
@app.errorhandler(LookupError) @app.errorhandler(LookupError)
def handle_lookup_error(req, exc): async def handle_lookup_error(req, exc):
return exc.__class__.__name__, 501 return exc.__class__.__name__, 501
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501) self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -572,15 +549,15 @@ class TestMicrodot(unittest.TestCase):
return foo[1] return foo[1]
@app.errorhandler(LookupError) @app.errorhandler(LookupError)
def handle_lookup_error(req, exc): async def handle_lookup_error(req, exc):
return 'LookupError', 501 return 'LookupError', 501
@app.errorhandler(IndexError) @app.errorhandler(IndexError)
def handle_index_error(req, exc): async def handle_index_error(req, exc):
return 'IndexError', 501 return 'IndexError', 501
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501) self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -595,15 +572,15 @@ class TestMicrodot(unittest.TestCase):
return foo[1] return foo[1]
@app.errorhandler(Exception) @app.errorhandler(Exception)
def handle_generic_exception(req, exc): async def handle_generic_exception(req, exc):
return 'Exception', 501 return 'Exception', 501
@app.errorhandler(LookupError) @app.errorhandler(LookupError)
def handle_lookup_error(req, exc): async def handle_lookup_error(req, exc):
return 'LookupError', 501 return 'LookupError', 501
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501) self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -618,11 +595,11 @@ class TestMicrodot(unittest.TestCase):
return foo[1] return foo[1]
@app.errorhandler(RuntimeError) @app.errorhandler(RuntimeError)
def handle_runtime_error(req, exc): async def handle_runtime_error(req, exc):
return 'RuntimeError', 501 return 'RuntimeError', 501
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 500) self.assertEqual(res.status_code, 500)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -637,7 +614,7 @@ class TestMicrodot(unittest.TestCase):
return 'foo' return 'foo'
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 406) self.assertEqual(res.status_code, 406)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -652,11 +629,11 @@ class TestMicrodot(unittest.TestCase):
return 'foo' return 'foo'
@app.errorhandler(406) @app.errorhandler(406)
def handle_406(req): def handle_500(req):
return '406', 406 return '406', 406
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 406) self.assertEqual(res.status_code, 406)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
@@ -666,7 +643,7 @@ class TestMicrodot(unittest.TestCase):
app = Microdot() app = Microdot()
@app.route('/dict') @app.route('/dict')
def json_dict(req): async def json_dict(req):
return {'foo': 'bar'} return {'foo': 'bar'}
@app.route('/list') @app.route('/list')
@@ -675,13 +652,13 @@ class TestMicrodot(unittest.TestCase):
client = TestClient(app) client = TestClient(app)
res = client.get('/dict') res = self._run(client.get('/dict'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'application/json; charset=UTF-8') 'application/json; charset=UTF-8')
self.assertEqual(res.json, {'foo': 'bar'}) self.assertEqual(res.json, {'foo': 'bar'})
res = client.get('/list') res = self._run(client.get('/list'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'application/json; charset=UTF-8') 'application/json; charset=UTF-8')
@@ -695,7 +672,7 @@ class TestMicrodot(unittest.TestCase):
return b'\xff\xfe', {'Content-Type': 'application/octet-stream'} return b'\xff\xfe', {'Content-Type': 'application/octet-stream'}
client = TestClient(app) client = TestClient(app)
res = client.get('/bin') res = self._run(client.get('/bin'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'application/octet-stream') 'application/octet-stream')
@@ -705,20 +682,38 @@ class TestMicrodot(unittest.TestCase):
def test_streaming(self): def test_streaming(self):
app = Microdot() app = Microdot()
done = False
@app.route('/') @app.route('/')
def index(req): def index(req):
def stream(): class stream():
yield 'foo' def __init__(self):
yield b'bar' 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
async def aclose(self):
nonlocal done
done = True
return stream() return stream()
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'foobar') self.assertEqual(res.text, 'foobar')
self.assertEqual(done, True)
def test_already_handled_response(self): def test_already_handled_response(self):
app = Microdot() app = Microdot()
@@ -728,7 +723,7 @@ class TestMicrodot(unittest.TestCase):
return Response.already_handled return Response.already_handled
client = TestClient(app) client = TestClient(app)
res = client.get('/') res = self._run(client.get('/'))
self.assertEqual(res, None) self.assertEqual(res, None)
def test_mount(self): def test_mount(self):
@@ -759,39 +754,14 @@ class TestMicrodot(unittest.TestCase):
client = TestClient(app) client = TestClient(app)
res = client.get('/app') res = self._run(client.get('/app'))
self.assertEqual(res.status_code, 404) self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertEqual(res.text, '404:errorafter') self.assertEqual(res.text, '404:errorafter')
res = client.get('/sub/app') res = self._run(client.get('/sub/app'))
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8') 'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'before:foo:after') self.assertEqual(res.text, 'before:foo:after')
def test_ssl(self):
self._mock()
app = Microdot()
@app.route('/foo')
def foo(req):
return 'bar'
class FakeSSL:
def wrap_socket(self, sock, **kwargs):
return sock
mock_socket.clear_requests()
fd = mock_socket.add_request('GET', '/foo')
self._add_shutdown(app)
app.run(ssl=FakeSSL())
self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n'))
self.assertIn(b'Content-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n',
fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nbar'))
self._unmock()

View File

@@ -1,775 +0,0 @@
try:
import uasyncio as asyncio
except ImportError:
import asyncio
import sys
import unittest
from microdot_asyncio import Microdot, Response, abort
from microdot_asyncio_test_client import TestClient
from tests import mock_asyncio, mock_socket
class TestMicrodotAsync(unittest.TestCase):
def setUp(self):
self._mock()
def tearDown(self):
self._unmock()
def _run(self, coro):
loop = asyncio.get_event_loop()
return loop.run_until_complete(coro)
def _mock(self):
self.original_asyncio = sys.modules['microdot_asyncio'].asyncio
sys.modules['microdot_asyncio'].asyncio = mock_asyncio
def _unmock(self):
sys.modules['microdot_asyncio'].asyncio = self.original_asyncio
def _add_shutdown(self, app):
@app.route('/shutdown')
def shutdown(req):
app.shutdown()
return ''
mock_socket.add_request('GET', '/shutdown')
def test_get_request(self):
app = Microdot()
@app.route('/')
def index(req):
return 'foo'
@app.route('/async')
async def index2(req):
return 'foo-async'
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Content-Length'], '3')
self.assertEqual(res.text, 'foo')
self.assertEqual(res.body, b'foo')
self.assertEqual(res.json, None)
res = self._run(client.get('/async'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Content-Length'], '9')
self.assertEqual(res.text, 'foo-async')
self.assertEqual(res.body, b'foo-async')
self.assertEqual(res.json, None)
def test_post_request(self):
app = Microdot()
@app.route('/')
def index(req):
return 'foo'
@app.route('/', methods=['POST'])
def index_post(req):
return Response('bar')
@app.route('/async', methods=['POST'])
async def index_post2(req):
return Response('bar-async')
client = TestClient(app)
res = self._run(client.post('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Content-Length'], '3')
self.assertEqual(res.text, 'bar')
self.assertEqual(res.body, b'bar')
self.assertEqual(res.json, None)
res = self._run(client.post('/async'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Content-Length'], '9')
self.assertEqual(res.text, 'bar-async')
self.assertEqual(res.body, b'bar-async')
self.assertEqual(res.json, None)
def test_head_request(self):
app = Microdot()
@app.route('/foo')
def index(req):
return 'foo'
mock_socket.clear_requests()
fd = mock_socket.add_request('HEAD', '/foo')
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 200 OK\r\n'))
self.assertIn(b'Content-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n',
fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n'))
def test_options_request(self):
app = Microdot()
@app.route('/', methods=['GET', 'DELETE'])
async def index(req):
return 'foo'
@app.post('/')
async def index_post(req):
return 'bar'
@app.route('/foo', methods=['POST', 'PUT'])
async def foo(req):
return 'baz'
client = TestClient(app)
res = self._run(client.request('OPTIONS', '/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Allow'],
'GET, DELETE, POST, HEAD, OPTIONS')
res = self._run(client.request('OPTIONS', '/foo'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Allow'], 'POST, PUT, OPTIONS')
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; charset=UTF-8\r\n',
fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request'))
def test_method_decorators(self):
app = Microdot()
@app.get('/get')
def get(req):
return 'GET'
@app.post('/post')
async def post(req):
return 'POST'
@app.put('/put')
def put(req):
return 'PUT'
@app.patch('/patch')
async def patch(req):
return 'PATCH'
@app.delete('/delete')
def delete(req):
return 'DELETE'
client = TestClient(app)
methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
for method in methods:
res = self._run(getattr(
client, method.lower())('/' + method.lower()))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, method)
def test_headers(self):
app = Microdot()
@app.route('/')
def index(req):
return req.headers.get('X-Foo')
client = TestClient(app)
res = self._run(client.get('/', headers={'X-Foo': 'bar'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'bar')
def test_cookies(self):
app = Microdot()
@app.route('/')
def index(req):
return req.cookies['one'] + req.cookies['two'] + \
req.cookies['three']
client = TestClient(app, cookies={'one': '1', 'two': '2'})
res = self._run(client.get('/', headers={'Cookie': 'three=3'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, '123')
def test_binary_payload(self):
app = Microdot()
@app.post('/')
def index(req):
return req.body
client = TestClient(app)
res = self._run(client.post('/', body=b'foo'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'foo')
def test_json_payload(self):
app = Microdot()
@app.post('/dict')
def json_dict(req):
print(req.headers)
return req.json.get('foo')
@app.post('/list')
def json_list(req):
return req.json[0]
client = TestClient(app)
res = self._run(client.post('/dict', body={'foo': 'bar'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'bar')
res = self._run(client.post('/list', body=['foo', 'bar']))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'foo')
def test_tuple_responses(self):
app = Microdot()
@app.route('/body')
def one(req):
return 'one'
@app.route('/body-status')
def two(req):
return 'two', 202
@app.route('/body-headers')
def three(req):
return '<p>three</p>', {'Content-Type': 'text/html'}
@app.route('/body-status-headers')
def four(req):
return '<p>four</p>', 202, \
{'Content-Type': 'text/html; charset=UTF-8'}
client = TestClient(app)
res = self._run(client.get('/body'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'one')
res = self._run(client.get('/body-status'))
self.assertEqual(res.status_code, 202)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'two')
res = self._run(client.get('/body-headers'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'], 'text/html')
self.assertEqual(res.text, '<p>three</p>')
res = self._run(client.get('/body-status-headers'))
self.assertEqual(res.status_code, 202)
self.assertEqual(res.headers['Content-Type'],
'text/html; charset=UTF-8')
self.assertEqual(res.text, '<p>four</p>')
def test_before_after_request(self):
app = Microdot()
@app.before_request
def before_request(req):
if req.path == '/bar':
@req.after_request
async def after_request(req, res):
res.headers['X-Two'] = '2'
return res
return 'bar', 202
req.g.message = 'baz'
@app.after_request
def after_request_one(req, res):
res.headers['X-One'] = '1'
@app.after_request
async def after_request_two(req, res):
res.set_cookie('foo', 'bar')
return res
@app.route('/bar')
def bar(req):
return 'foo'
@app.route('/baz')
def baz(req):
return req.g.message
client = TestClient(app)
res = self._run(client.get('/bar'))
self.assertEqual(res.status_code, 202)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Set-Cookie'], ['foo=bar'])
self.assertEqual(res.headers['X-One'], '1')
self.assertEqual(res.headers['X-Two'], '2')
self.assertEqual(res.text, 'bar')
self.assertEqual(client.cookies['foo'], 'bar')
res = self._run(client.get('/baz'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Set-Cookie'], ['foo=bar'])
self.assertEqual(res.headers['X-One'], '1')
self.assertFalse('X-Two' in res.headers)
self.assertEqual(res.headers['Content-Length'], '3')
self.assertEqual(res.text, 'baz')
def test_after_error_request(self):
app = Microdot()
@app.after_error_request
def after_error_request_one(req, res):
res.headers['X-One'] = '1'
@app.after_error_request
def after_error_request_two(req, res):
res.set_cookie('foo', 'bar')
return res
@app.route('/foo')
def foo(req):
return 'foo'
client = TestClient(app)
res = self._run(client.get('/foo'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertFalse('X-One' in res.headers)
self.assertFalse('Set-Cookie' in res.headers)
res = self._run(client.get('/bar'))
self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Set-Cookie'], ['foo=bar'])
self.assertEqual(res.headers['X-One'], '1')
self.assertEqual(client.cookies['foo'], 'bar')
def test_400(self):
self._mock()
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; charset=UTF-8\r\n',
fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request'))
self._unmock()
def test_400_handler(self):
self._mock()
app = Microdot()
@app.errorhandler(400)
async def handle_404(req):
return '400'
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 200 OK\r\n'))
self.assertIn(b'Content-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain; charset=UTF-8\r\n',
fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n400'))
self._unmock()
def test_404(self):
app = Microdot()
@app.route('/')
def index(req):
return 'foo'
client = TestClient(app)
res = self._run(client.post('/foo'))
self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'Not found')
def test_404_handler(self):
app = Microdot()
@app.route('/')
def index(req):
return 'foo'
@app.errorhandler(404)
async def handle_404(req):
return '404'
client = TestClient(app)
res = self._run(client.post('/foo'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, '404')
def test_405(self):
app = Microdot()
@app.route('/foo')
def index(req):
return 'foo'
client = TestClient(app)
res = self._run(client.post('/foo'))
self.assertEqual(res.status_code, 405)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'Not found')
def test_405_handler(self):
app = Microdot()
@app.route('/foo')
def index(req):
return 'foo'
@app.errorhandler(405)
async def handle_405(req):
return '405', 405
client = TestClient(app)
res = self._run(client.patch('/foo'))
self.assertEqual(res.status_code, 405)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, '405')
def test_413(self):
app = Microdot()
@app.route('/')
def index(req):
return 'foo'
client = TestClient(app)
res = self._run(client.post('/foo', body='x' * 17000))
self.assertEqual(res.status_code, 413)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'Payload 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
client = TestClient(app)
res = self._run(client.post('/foo', body='x' * 17000))
self.assertEqual(res.status_code, 400)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, '413')
def test_500(self):
app = Microdot()
@app.route('/')
def index(req):
return 1 / 0
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 500)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'Internal server error')
def test_500_handler(self):
app = Microdot()
@app.route('/')
def index(req):
return 1 / 0
@app.errorhandler(500)
def handle_500(req):
return '501', 501
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, '501')
def test_exception_handler(self):
app = Microdot()
@app.route('/')
def index(req):
return 1 / 0
@app.errorhandler(ZeroDivisionError)
async def handle_div_zero(req, exc):
return '501', 501
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, '501')
def test_exception_handler_parent(self):
app = Microdot()
@app.route('/')
def index(req):
foo = []
return foo[1]
@app.errorhandler(LookupError)
async def handle_lookup_error(req, exc):
return exc.__class__.__name__, 501
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'IndexError')
def test_exception_handler_redundant_parent(self):
app = Microdot()
@app.route('/')
def index(req):
foo = []
return foo[1]
@app.errorhandler(LookupError)
async def handle_lookup_error(req, exc):
return 'LookupError', 501
@app.errorhandler(IndexError)
async def handle_index_error(req, exc):
return 'IndexError', 501
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'IndexError')
def test_exception_handler_multiple_parents(self):
app = Microdot()
@app.route('/')
def index(req):
foo = []
return foo[1]
@app.errorhandler(Exception)
async def handle_generic_exception(req, exc):
return 'Exception', 501
@app.errorhandler(LookupError)
async def handle_lookup_error(req, exc):
return 'LookupError', 501
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 501)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'LookupError')
def test_exception_handler_no_viable_parents(self):
app = Microdot()
@app.route('/')
def index(req):
foo = []
return foo[1]
@app.errorhandler(RuntimeError)
async def handle_runtime_error(req, exc):
return 'RuntimeError', 501
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 500)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'Internal server error')
def test_abort(self):
app = Microdot()
@app.route('/')
def index(req):
abort(406, 'Not acceptable')
return 'foo'
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 406)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'Not acceptable')
def test_abort_handler(self):
app = Microdot()
@app.route('/')
def index(req):
abort(406)
return 'foo'
@app.errorhandler(406)
def handle_500(req):
return '406', 406
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 406)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, '406')
def test_json_response(self):
app = Microdot()
@app.route('/dict')
async def json_dict(req):
return {'foo': 'bar'}
@app.route('/list')
def json_list(req):
return ['foo', 'bar']
client = TestClient(app)
res = self._run(client.get('/dict'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'application/json; charset=UTF-8')
self.assertEqual(res.json, {'foo': 'bar'})
res = self._run(client.get('/list'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'application/json; charset=UTF-8')
self.assertEqual(res.json, ['foo', 'bar'])
def test_binary_response(self):
app = Microdot()
@app.route('/bin')
def index(req):
return b'\xff\xfe', {'Content-Type': 'application/octet-stream'}
client = TestClient(app)
res = self._run(client.get('/bin'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'application/octet-stream')
self.assertEqual(res.text, None)
self.assertEqual(res.json, None)
self.assertEqual(res.body, b'\xff\xfe')
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()
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.text, 'foobar')
def test_already_handled_response(self):
app = Microdot()
@app.route('/')
def index(req):
return Response.already_handled
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res, None)

View File

@@ -1,71 +0,0 @@
import sys
try:
import uasyncio as asyncio
except ImportError:
import asyncio
import unittest
from microdot_asyncio import Microdot
from microdot_asyncio_websocket import with_websocket
from microdot_asyncio_test_client import TestClient
class TestMicrodotAsyncWebSocket(unittest.TestCase):
def _run(self, coro):
loop = asyncio.get_event_loop()
return loop.run_until_complete(coro)
def test_websocket_echo(self):
app = Microdot()
@app.route('/echo')
@with_websocket
async def index(req, ws):
while True:
data = await ws.receive()
await ws.send(data)
results = []
def ws():
data = yield 'hello'
results.append(data)
data = yield b'bye'
results.append(data)
data = yield b'*' * 300
results.append(data)
data = yield b'+' * 65537
results.append(data)
client = TestClient(app)
res = self._run(client.websocket('/echo', ws))
self.assertIsNone(res)
self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537])
@unittest.skipIf(sys.implementation.name == 'micropython',
'no support for async generators in MicroPython')
def test_websocket_echo_async_client(self):
app = Microdot()
@app.route('/echo')
@with_websocket
async def index(req, ws):
while True:
data = await ws.receive()
await ws.send(data)
results = []
async def ws():
data = yield 'hello'
results.append(data)
data = yield b'bye'
results.append(data)
data = yield b'*' * 300
results.append(data)
data = yield b'+' * 65537
results.append(data)
client = TestClient(app)
res = self._run(client.websocket('/echo', ws))
self.assertIsNone(res)
self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537])

Some files were not shown because too many files have changed in this diff Show More