Compare commits

...

53 Commits

Author SHA1 Message Date
Miguel Grinberg
84361045a3 Release 2.3.4 2025-10-16 00:21:49 +01:00
Miguel Grinberg
e9c9937b41 Add Python 3.13 and 3.14 to the CI builds 2025-10-16 00:17:30 +01:00
Miguel Grinberg
7addcf4bb5 Faster HTTP streaming when using ASGI (#318) 2025-10-16 00:17:17 +01:00
Miguel Grinberg
6045390cef Prevent reading past EOF in multipart parser (Fixes #307) (#309) 2025-09-03 15:24:04 +01:00
Miguel Grinberg
c12d465809 Parse empty cookies (Fixes #308) 2025-08-13 23:30:09 +01:00
Miguel Grinberg
cca0b0f693 Generate a valid CORS response when the request badly formatted (Fixes #305) 2025-07-15 22:53:03 +01:00
Miguel Grinberg
7071358b1f Add weather dashboard example (#303) 2025-07-13 23:46:31 +01:00
Miguel Grinberg
d7fcd1a247 Version 2.3.4.dev0 2025-07-01 23:48:57 +01:00
Miguel Grinberg
eb5e249e34 Release 2.3.3 2025-07-01 23:46:00 +01:00
Miguel Grinberg
9bc3dced6c Handle partial reads in WebSocket class (Fixes #294) 2025-06-30 18:32:21 +01:00
Miguel Grinberg
786e5e5337 Additional documentation for the URLPattern class 2025-06-30 18:23:46 +01:00
Ozuba
1d419ce59b Add svg to supported mimetypes (#302) 2025-06-30 12:24:24 +01:00
Miguel Grinberg
7c98c4589d Additional documentation on WebSocket and SSE disconnections 2025-06-28 11:01:22 +01:00
Miguel Grinberg
0f219fd494 fix linter errors #nolog 2025-06-28 10:48:20 +01:00
Miguel Grinberg
e146e2d08d More detailed documentation for current_user 2025-06-28 10:40:59 +01:00
Miguel Grinberg
dc61470fa9 More detailed documentation for route responses 2025-06-28 10:40:30 +01:00
Miguel Grinberg
d7a9c53563 Add a sub-application example 2025-06-20 23:59:04 +01:00
dependabot[bot]
4ddb09ceb3 Bump urllib3 from 2.2.2 to 2.5.0 in /examples/benchmark (#301) #nolog
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.2.2 to 2.5.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.2.2...2.5.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-19 09:20:41 +01:00
Miguel Grinberg
3dffa05ffb Documentation improvements for the Request class 2025-06-18 20:09:59 +01:00
dependabot[bot]
b93a55c9f2 Bump requests from 2.32.0 to 2.32.4 in /examples/benchmark (#300) #nolog
Bumps [requests](https://github.com/psf/requests) from 2.32.0 to 2.32.4.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.0...v2.32.4)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.32.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-10 10:16:45 +01:00
Miguel Grinberg
f5d3d931ed Support for SSE responses in the test client 2025-05-18 18:26:38 +01:00
Miguel Grinberg
654a85f46b Do not silence exceptions that occur in the SSE task 2025-05-18 12:21:17 +01:00
Miguel Grinberg
3c936a82e0 Version 2.3.3.dev0 2025-05-08 23:11:35 +01:00
Miguel Grinberg
4c0ace1b01 Release 2.3.2 2025-05-08 23:02:29 +01:00
Miguel Grinberg
d9d7ff0825 use async error handlers in auth module (Fixes #298) 2025-05-08 20:07:35 +01:00
dependabot[bot]
7c42a18436 Bump h11 from 0.14.0 to 0.16.0 in /examples/benchmark (#293) #nolog
Bumps [h11](https://github.com/python-hyper/h11) from 0.14.0 to 0.16.0.
- [Commits](https://github.com/python-hyper/h11/compare/v0.14.0...v0.16.0)

---
updated-dependencies:
- dependency-name: h11
  dependency-version: 0.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-24 19:04:29 +01:00
Miguel Grinberg
ea84fcb435 Version 2.3.2.dev0 2025-04-13 00:01:21 +01:00
Miguel Grinberg
f30c4733f0 Release 2.3.1 2025-04-13 00:01:12 +01:00
Miguel Grinberg
cd0b3234dd Additional support needed when using orjson 2025-04-12 23:58:48 +01:00
Miguel Grinberg
1f64478957 Version 2.3.1.dev0 2025-04-12 23:33:26 +01:00
Miguel Grinberg
815594fc8b Release 2.3.0 2025-04-12 23:31:54 +01:00
Miguel Grinberg
086f2af3de Use orjson instead of json if available 2025-04-12 23:24:31 +01:00
Miguel Grinberg
f317b15bdb Support optional authentication methods 2025-04-06 23:52:36 +01:00
Miguel Grinberg
b6f232db11 Addressed typing warnings from pyright 2025-04-06 23:52:36 +01:00
Miguel Grinberg
e7ee74d6bb Catch SSL crashes while writing the response (Fixes #206) 2025-03-22 19:02:06 +00:00
dependabot[bot]
847dfd1321 Bump gunicorn from 22.0.0 to 23.0.0 in /examples/benchmark (#291) #nolog
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 22.0.0 to 23.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/22.0.0...23.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-22 12:41:50 +00:00
Miguel Grinberg
1aa035378e Updates to change log #nolog 2025-03-22 12:40:27 +00:00
Miguel Grinberg
1edfb8daa7 Version 2.2.1.dev0 2025-03-22 12:37:02 +00:00
Miguel Grinberg
9337a2ec9b Release 2.2.0 2025-03-22 12:35:02 +00:00
Miguel Grinberg
11a91a6035 Support for multipart/form-data requests (#287) 2025-03-22 12:24:12 +00:00
Miguel Grinberg
99f65c0198 Additional urldecode tests 2025-03-16 20:39:50 +00:00
Miguel Grinberg
4cc2e95338 Update micropython version used in tests to 1.24.1 2025-03-16 20:34:38 +00:00
Miguel Grinberg
d203df75fe urldecoding should always be done in bytes 2025-03-16 20:32:34 +00:00
dependabot[bot]
00bf535821 Bump jinja2 from 3.1.5 to 3.1.6 in /examples/benchmark (#286) #nolog
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.5 to 3.1.6.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.5...3.1.6)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-06 10:19:46 +00:00
Miguel Grinberg
3bc31f10b2 Simplified urldecode logic 2025-03-03 19:16:18 +00:00
Miguel Grinberg
aa76e6378b Delay route compilation to allow late register_type calls 2025-03-03 19:10:33 +00:00
Miguel Grinberg
c6b99b6d81 Documentation improvements 2025-03-02 19:47:21 +00:00
Miguel Grinberg
953dd94321 Expose the Jinja environment as Template.jinja_env 2025-03-02 11:53:54 +00:00
Miguel Grinberg
68a53a7ae7 Update README #nolog 2025-03-02 00:51:23 +00:00
Miguel Grinberg
c92b5ae282 Redesigned the URL parser to allow for custom path components 2025-03-02 00:48:07 +00:00
dependabot[bot]
48ce31e699 Bump quart from 0.19.7 to 0.20.0 in /examples/benchmark (#283) #nolog
Bumps [quart](https://github.com/pallets/quart) from 0.19.7 to 0.20.0.
- [Release notes](https://github.com/pallets/quart/releases)
- [Changelog](https://github.com/pallets/quart/blob/main/CHANGES.md)
- [Commits](https://github.com/pallets/quart/compare/0.19.7...0.20.0)

---
updated-dependencies:
- dependency-name: quart
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-04 11:19:19 +00:00
dependabot[bot]
6a33e817a2 Bump jinja2 from 3.1.4 to 3.1.5 in /examples/benchmark (#284) #nolog
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.4...3.1.5)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-04 11:18:55 +00:00
Miguel Grinberg
265009ecd6 Version 2.1.1.dev0 2025-02-04 00:35:10 +00:00
47 changed files with 1805 additions and 199 deletions

View File

@@ -22,7 +22,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ['3.8', '3.9', '3.10', '3.11', '3.12']
python: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
fail-fast: false
runs-on: ${{ matrix.os }}
steps:

View File

@@ -1,5 +1,52 @@
# Microdot change log
**Release 2.3.4** - 2025-10-16
- Prevent reading past EOF in multipart parser [#309](https://github.com/miguelgrinberg/microdot/issues/309) ([commit](https://github.com/miguelgrinberg/microdot/commit/6045390cef8735cbbc9f5f7eee7a3912f00e284d))
- Generate a valid CORS response when the request is badly formatted [#305](https://github.com/miguelgrinberg/microdot/issues/305) ([commit](https://github.com/miguelgrinberg/microdot/commit/cca0b0f693c909134bc19eb41dfb5a86226e032b))
- Faster HTTP streaming when using ASGI [#318](https://github.com/miguelgrinberg/microdot/issues/318) ([commit](https://github.com/miguelgrinberg/microdot/commit/7addcf4bb51f1caf57663c5bb4d8cc16ee6391e1))
- Parse empty cookies [#308](https://github.com/miguelgrinberg/microdot/issues/308) ([commit](https://github.com/miguelgrinberg/microdot/commit/c12d4658091ff7eec1ac67c83bcd51eb38af9db7))
- Add weather dashboard example [#303](https://github.com/miguelgrinberg/microdot/issues/303) ([commit](https://github.com/miguelgrinberg/microdot/commit/7071358b1f95892b1342226b43411e036be67d3a))
- Add Python 3.13 and 3.14 to the CI builds ([commit](https://github.com/miguelgrinberg/microdot/commit/e9c9937b41e652876241307307f3e855f4f07379))
**Release 2.3.3** - 2025-07-01
- Handle partial reads in WebSocket class [#294](https://github.com/miguelgrinberg/microdot/issues/294) ([commit](https://github.com/miguelgrinberg/microdot/commit/9bc3dced6c1f582dde0496961d25170b448ad8d7))
- Add SVG to supported mimetypes [#302](https://github.com/miguelgrinberg/microdot/issues/302) ([commit](https://github.com/miguelgrinberg/microdot/commit/1d419ce59bf7006617109c05dc2d6fc6d1dc8235)) (thanks **Ozuba**!)
- Do not silence exceptions that occur in the SSE task ([commit](https://github.com/miguelgrinberg/microdot/commit/654a85f46b7dd7a1e94f81193c4a78a8a1e99936))
- Add Support for SSE responses in the test client ([commit](https://github.com/miguelgrinberg/microdot/commit/f5d3d931edfbacedebf5fdf938ef77c5ee910380))
- Documentation improvements for the `Request` class ([commit](https://github.com/miguelgrinberg/microdot/commit/3dffa05ffb229813156b71e10a85283bdaa26d5e))
- Additional documentation for the `URLPattern` class ([commit](https://github.com/miguelgrinberg/microdot/commit/786e5e533748e1343612c97123773aec9a1a99fc))
- More detailed documentation for route responses ([commit](https://github.com/miguelgrinberg/microdot/commit/dc61470fa959549bb43313906ba6ed9f686babc2))
- Additional documentation on WebSocket and SSE disconnections ([commit](https://github.com/miguelgrinberg/microdot/commit/7c98c4589de4774a88381b393444c75094532550))
- More detailed documentation for `current_user` ([commit](https://github.com/miguelgrinberg/microdot/commit/e146e2d08deddf9b924c7657f04db28d71f34221))
- Add a sub-application example ([commit](https://github.com/miguelgrinberg/microdot/commit/d7a9c535639268e415714b12ac898ae38e516308))
**Release 2.3.2** - 2025-05-08
- Use async error handlers in auth module [#298](https://github.com/miguelgrinberg/microdot/issues/298) ([commit](https://github.com/miguelgrinberg/microdot/commit/d9d7ff0825e4c5fbed6564d3684374bf3937df11))
**Release 2.3.1** - 2025-04-13
- Additional support needed when using `orjson` ([commit](https://github.com/miguelgrinberg/microdot/commit/cd0b3234ddb0c8ff4861d369836ec2aed77494db))
**Release 2.3.0** - 2025-04-12
- Support optional authentication methods ([commit](https://github.com/miguelgrinberg/microdot/commit/f317b15bdbf924007e5e3414e0c626baccc3ede6))
- Catch SSL exceptions while writing the response [#206](https://github.com/miguelgrinberg/microdot/issues/206) ([commit](https://github.com/miguelgrinberg/microdot/commit/e7ee74d6bba74cfd89b9ddc38f28e02514eb1791))
- Use `orjson` instead of `json` if available ([commit](https://github.com/miguelgrinberg/microdot/commit/086f2af3deab86d4340f3f1feb9e019de59f351d))
- Addressed typing warnings from pyright ([commit](https://github.com/miguelgrinberg/microdot/commit/b6f232db1125045d79c444c736a2ae59c5501fdd))
**Release 2.2.0** - 2025-03-22
- Support for `multipart/form-data` requests [#287](https://github.com/miguelgrinberg/microdot/issues/287) ([commit](https://github.com/miguelgrinberg/microdot/commit/11a91a60350518e426b557fae8dffe75912f8823))
- Support custom path components in URLs ([commit #1](https://github.com/miguelgrinberg/microdot/commit/c92b5ae28222af5a1094f5d2f70a45d4d17653d5) [commit #2](https://github.com/miguelgrinberg/microdot/commit/aa76e6378b37faab52008a8aab8db75f81b29323))
- Expose the Jinja environment as `Template.jinja_env` ([commit](https://github.com/miguelgrinberg/microdot/commit/953dd9432122defe943f0637bbe7e01f2fc7743f))
- Simplified urldecode logic ([commit #1](https://github.com/miguelgrinberg/microdot/commit/3bc31f10b2b2d4460c62366013278d87665f0f97) [commit #2](https://github.com/miguelgrinberg/microdot/commit/d203df75fef32c7cc0fe7cc6525e77522b37a289))
- Additional urldecode tests ([commit](https://github.com/miguelgrinberg/microdot/commit/99f65c0198590c0dfb402c24685b6f8dfba1935d))
- Documentation improvements ([commit](https://github.com/miguelgrinberg/microdot/commit/c6b99b6d8117d4e40e16d5b953dbf4deb023d24d))
- Update micropython version used in tests to 1.24.1 ([commit](https://github.com/miguelgrinberg/microdot/commit/4cc2e95338a7de3b03742389004147ee21285621))
**Release 2.1.0** - 2025-02-04
- User login support ([commit](https://github.com/miguelgrinberg/microdot/commit/d807011ad006e53e70c4594d7eac04d03bb08681))

View File

@@ -43,8 +43,8 @@ describes the backwards incompatible changes that were made.
The following features are planned for future releases of Microdot, both for
MicroPython and CPython:
- Support for forms encoded in `multipart/form-data` format
- Authentication support, similar to [Flask-Login](https://github.com/maxcountryman/flask-login) for Flask
- Authentication support, similar to [Flask-Login](https://github.com/maxcountryman/flask-login) for Flask (**Added in version 2.1**)
- Support for forms encoded in `multipart/form-data` format (**Added in version 2.2**)
- OpenAPI integration, similar to [APIFairy](https://github.com/miguelgrinberg/apifairy) for Flask
In addition to the above, the following extensions are also under consideration,
@@ -53,4 +53,4 @@ but only for CPython:
- Database integration through [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy)
- Socket.IO support through [python-socketio](https://github.com/miguelgrinberg/python-socketio)
Do you have other ideas to propose? Let's [discuss them](https://github.com/miguelgrinberg/microdot/discussions/new?category=ideas)!
Do you have other ideas to propose? Let's [discuss them](https://github.com/:miguelgrinberg/microdot/discussions/new?category=ideas)!

Binary file not shown.

View File

@@ -13,6 +13,14 @@ Core API
.. autoclass:: microdot.Response
:members:
.. autoclass:: microdot.URLPattern
:members:
Multipart Forms
---------------
.. automodule:: microdot.multipart
:members:
WebSocket
---------

View File

@@ -5,8 +5,82 @@ Microdot is a highly extensible web application framework. The extensions
described in this section are maintained as part of the Microdot project in
the same source code repository.
Multipart Forms
~~~~~~~~~~~~~~~
.. list-table::
:align: left
* - Compatibility
- | CPython & MicroPython
* - Required Microdot source files
- | `multipart.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/multipart.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | None
* - Examples
- | `formdata.py <https://github.com/miguelgrinberg/microdot/blob/main/examples/uploads/formdata.py>`_
The multipart extension handles multipart forms, including those that have file
uploads.
The :func:`with_form_data <microdot.multipart.with_form_data>` decorator
provides the simplest way to work with these forms. With this decorator added
to the route, whenever the client sends a multipart request the
:attr:`request.form <microdot.Request.form>` and
:attr:`request.files <microdot.Request.files>` properties are populated with
the submitted data. For form fields the field values are always strings. For
files, they are instances of the
:class:`FileUpload <microdot.multipart.FileUpload>` class.
Example::
from microdot.multipart import with_form_data
@app.post('/upload')
@with_form_data
async def upload(request):
print('form fields:', request.form)
print('files:', request.files)
One disadvantage of the ``@with_form_data`` decorator is that it has to copy
any uploaded files to memory or temporary disk files, depending on their size.
The :attr:`FileUpload.max_memory_size <microdot.multipart.FileUpload.max_memory_size>`
attribute can be used to control the cutoff size above which a file upload
is transferred to a temporary file.
A more performant alternative to the ``@with_form_data`` decorator is the
:class:`FormDataIter <microdot.multipart.FormDataIter>` class, which iterates
over the form fields sequentially, giving the application the option to parse
the form fields on the fly and decide what to copy and what to discard. When
using ``FormDataIter`` the ``request.form`` and ``request.files`` attributes
are not used.
Example::
from microdot.multipart import FormDataIter
@app.post('/upload')
async def upload(request):
async for name, value in FormDataIter(request):
print(name, value)
For fields that contain an uploaded file, the ``value`` returned by the
iterator is the same ``FileUpload`` instance. The application can choose to
save the file with the :meth:`save() <microdot.multipart.FileUpload.save>`
method, or read it with the :meth:`read() <microdot.multipart.FileUpload.read>`
method, optionally passing a size to read it in chunks. The
:meth:`copy() <microdot.multipart.FileUpload.copy>` method is also available to
apply the copying logic used by the ``@with_form_data`` decorator, which is
inefficient but allows the file to be set aside to be processed later, after
the remaining form fields.
WebSocket
~~~~~~~~-
~~~~~~~~~
.. list-table::
:align: left
@@ -16,6 +90,7 @@ WebSocket
* - Required Microdot source files
- | `websocket.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/websocket.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | None
@@ -32,12 +107,41 @@ messages respectively.
Example::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
from microdot.websocket import with_websocket
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
To end the WebSocket connection, the route handler can exit, without returning
anything::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
if message == 'exit':
break
await ws.send(message)
await ws.send('goodbye')
If the client ends the WebSocket connection from their side, the route function
is cancelled. The route function can catch the ``CancelledError`` exception
from asyncio to perform cleanup tasks::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
try:
while True:
message = await ws.receive()
await ws.send(message)
except asyncio.CancelledError:
print('Client disconnected!')
Server-Sent Events
~~~~~~~~~~~~~~~~~~
@@ -50,6 +154,7 @@ Server-Sent Events
* - Required Microdot source files
- | `sse.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/sse.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | None
@@ -65,6 +170,8 @@ asynchronous method to send an event to the client.
Example::
from microdot.sse import with_sse
@app.route('/events')
@with_sse
async def events(request, sse):
@@ -73,6 +180,25 @@ Example::
await sse.send({'counter': i}) # unnamed event
await sse.send('end', event='comment') # named event
To end the SSE connection, the route handler can exit, without returning
anything, as shown in the above examples.
If the client ends the SSE connection from their side, the route function is
cancelled. The route function can catch the ``CancelledError`` exception from
asyncio to perform cleanup tasks::
@app.route('/events')
@with_sse
async def events(request, sse):
try:
i = 0
while True:
await asyncio.sleep(1)
await sse.send({'counter': i})
i += 1
except asyncio.CancelledError:
print('Client disconnected!')
.. note::
The SSE protocol is unidirectional, so there is no ``receive()`` method in
the SSE object. For bidirectional communication with the client, use the
@@ -213,6 +339,7 @@ Secure User Sessions
* - Required Microdot source files
- | `session.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/session.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | CPython: `PyJWT <https://pyjwt.readthedocs.io/>`_
@@ -333,10 +460,25 @@ decorator::
While running an authenticated request, the user object returned by the
authenticaction function is accessible as ``request.g.current_user``.
If an endpoint is intended to work with or without authentication, then it can
be protected with the ``auth.optional`` decorator::
@app.route('/')
@auth.optional
async def index(request):
if request.g.current_user:
return f'Hello, {request.g.current_user}!'
else:
return 'Hello, anonymous user!'
As shown in the example, a route can check ``request.g.current_user`` to
determine if the user is authenticated or not.
Token Authentication
^^^^^^^^^^^^^^^^^^^^
To set up token authentication, create an instance of :class:`TokenAuth <microdot.auth.TokenAuth>`::
To set up token authentication, create an instance of
:class:`TokenAuth <microdot.auth.TokenAuth>`::
from microdot.auth import TokenAuth
@@ -350,13 +492,24 @@ or ``None`` if the token is invalid or expired::
return load_user_from_token(token)
As with Basic authentication, the ``auth`` instance is used as a decorator to
protect your routes::
protect your routes, and the authenticated user is accessible from the request
object as ``request.g.current_user``::
@app.route('/')
@auth
async def index(request):
return f'Hello, {request.g.current_user}!'
Optional authentication can also be used with tokens::
@app.route('/')
@auth.optional
async def index(request):
if request.g.current_user:
return f'Hello, {request.g.current_user}!'
else:
return 'Hello, anonymous user!'
User Logins
~~~~~~~~~~~
@@ -369,6 +522,7 @@ User Logins
* - Required Microdot source files
- | `login.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/auth.py>`_
| `session.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/session.py>`_
| `helpers.py <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot/helpers.py>`_
* - Required external dependencies
- | CPython: `PyJWT <https://pyjwt.readthedocs.io/>`_
| MicroPython: `jwt.py <https://github.com/micropython/micropython-lib/blob/master/python-ecosys/pyjwt/jwt.py>`_,

View File

@@ -1,5 +1,8 @@
Cross-Compiling and Freezing Microdot (MicroPython Only)
--------------------------------------------------------
Cross-Compiling and Freezing Microdot
-------------------------------------
.. note::
This section only applies when using Microdot on MicroPython.
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
@@ -36,7 +39,7 @@ 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.
once the code is compiled they are able to run it just fine.
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
@@ -82,8 +85,8 @@ 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.
different for each microcontroller platform, 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>`_

View File

@@ -329,15 +329,52 @@ URL::
async def get_test(request, path):
return 'Test: ' + path
For the most control, the ``re`` type allows the application to provide a
custom regular expression for the dynamic component. The next example defines
a route that only matches usernames that begin with an upper or lower case
letter, followed by a sequence of letters or numbers::
The ``re`` type allows the application to provide a custom regular expression
for the dynamic component. The next example defines a route that only matches
usernames that begin with an upper or lower case letter, followed by a sequence
of letters or numbers::
@app.get('/users/<re:[a-zA-Z][a-zA-Z0-9]*:username>')
async def get_user(request, username):
return 'User: ' + username
The ``re`` type returns the URL component as a string, which sometimes may not
be the most convenient. To convert a path component to something more
meaningful than a string, the application can register a custom URL component
type and provide a parser function that performs the conversion. In the
following example, a ``hex`` custom type is registered to automatically
convert hex numbers given in the path to numbers::
from microdot import URLPattern
URLPattern.register_type('hex', parser=lambda value: int(value, 16))
@app.get('/users/<hex:user_id>')
async def get_user(request, user_id):
user = get_user_by_id(user_id)
# ...
In addition to the parser, the custom URL component can include a pattern,
given as a regular expression. When a pattern is provided, the URL component
will only match if the regular expression matches the value passed in the URL.
The ``hex`` example above can be expanded with a pattern as follows::
URLPattern.register_type('hex', pattern='[0-9a-fA-F]+',
parser=lambda value: int(value, 16))
In cases where a pattern isn't provided, or when the pattern is unable to
filter out all invalid values, the parser function can return ``None`` to
indicate a failed match. The next example shows how the parser for the ``hex``
type can be expanded to do that::
def hex_parser(value):
try:
return int(value, 16)
except ValueError:
return None
URLPattern.register_type('hex', parser=hex_parser)
.. note::
Dynamic path components are passed to route functions as keyword arguments,
so the names of the function arguments must match the names declared in the
@@ -564,6 +601,13 @@ The request object provides access to the request attributes, including:
specified by the client, or ``None`` if no content type was specified.
- :attr:`content_length <microdot.Request.content_length>`: The content
length of the request, or 0 if no content length was specified.
- :attr:`json <microdot.Request.json>`: The parsed JSON data in the request
body. See :ref:`below <JSON Payloads>` for additional details.
- :attr:`form <microdot.Request.form>`: The parsed form data in the request
body, as a dictionary. See :ref:`below <Form Data>` for additional details.
- :attr:`files <microdot.Request.files>`: A dictionary with the file uploads
included in the request body. Note that file uploads are only supported when
the :ref:`Multipart Forms` extension is used.
- :attr:`client_addr <microdot.Request.client_addr>`: The network address of
the client, as a tuple (host, port).
- :attr:`app <microdot.Request.app>`: The application instance that created the
@@ -590,8 +634,8 @@ to use this attribute::
The client must set the ``Content-Type`` header to ``application/json`` for
the ``json`` attribute of the request object to be populated.
URLEncoded Form Data
^^^^^^^^^^^^^^^^^^^^
Form Data
^^^^^^^^^
The request object also supports standard HTML form submissions through the
:attr:`form <microdot.Request.form>` attribute, which presents the form data
@@ -605,9 +649,10 @@ as a :class:`MultiDict <microdot.MultiDict>` object. Example::
return f'Hello {name}'
.. note::
Form submissions are only parsed when the ``Content-Type`` header is set by
the client to ``application/x-www-form-urlencoded``. Form submissions using
the ``multipart/form-data`` content type are currently not supported.
Form submissions automatically parsed when the ``Content-Type`` header is
set by the client to ``application/x-www-form-urlencoded``. For form
submissions that use the ``multipart/form-data`` content type the
:ref:`Multipart Forms` extension must be used.
Accessing the Raw Request Body
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -712,15 +757,18 @@ sections describe the different types of responses that are supported.
The Three Parts of a Response
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Route functions can return one, two or three values. The first or only value is
always returned to the client in the response body::
Route functions can return one, two or three values. The first and most
important value is the response body::
@app.get('/')
async def index(request):
return 'Hello, World!'
In the above example, Microdot issues a standard 200 status code response, and
inserts default headers.
In the above example, Microdot issues a standard 200 status code response
indicating a successful request. The body of the response is the
``'Hello, World!'`` string returned by the function. Microdot includes default
headers with this response, including the ``Content-Type`` header set to
``text/plain`` to indicate a response in plain text.
The application can provide its own status code as a second value returned from
the route to override the 200 default. The example below returns a 202 status
@@ -732,22 +780,30 @@ code::
The application can also return a third value, a dictionary with additional
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 the default plain text
response::
@app.get('/')
async def index(request):
return '<h1>Hello, World!</h1>', 202, {'Content-Type': 'text/html'}
If the application needs to return custom headers, but does not need to change
the default status code, then it can return two values, omitting the status
code::
If the application does not need to return a body, then it can omit it and
have the status code as the first or only returned value::
@app.get('/')
async def index(request):
return 204
Likewise, if the application needs to return a body and custom headers, but
does not need to change the default status code, then it can return two values,
omitting the status code::
@app.get('/')
async def index(request):
return '<h1>Hello, World!</h1>', {'Content-Type': 'text/html'}
The application can also return a :class:`Response <microdot.Response>` object
containing all the details of the response as a single value.
Lastly, the application can also return a :class:`Response <microdot.Response>`
object containing all the details of the response as a single value.
JSON Responses
^^^^^^^^^^^^^^
@@ -895,18 +951,36 @@ Another option is to create a response object directly in the route function::
Concurrency
~~~~~~~~~~~
Microdot implements concurrency through the ``asyncio`` package. Applications
must ensure their handlers do not block, as this will prevent other concurrent
requests from being handled.
Microdot implements concurrency through the ``asyncio`` package, which means
that applications must be careful to prevent blocking in their handlers.
When running under CPython, ``async def`` handler functions run as native
asyncio tasks, while ``def`` handler functions are executed in a
`thread executor <https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor>`_
to prevent them from blocking the asynchronous loop.
"async def" handlers
^^^^^^^^^^^^^^^^^^^^
The recommendation for route handlers in Microdot is to use asynchronous
functions, declared as ``async def``. Microdot executes these handler
functions as native asynchronous tasks. The standard considerations for writing
asynchronous code apply, and in particular blocking calls should be avoided to
ensure the application runs smoothly and is always responsive.
"def" handlers
^^^^^^^^^^^^^^
Microdot also supports the use of synchronous route handlers, declared as
standard ``def`` functions. These handlers are handled differently under
CPython and MicroPython.
When running on CPython, Microdot executes synchronous handlers in a
`thread executor <https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_in_executor>`_,
which uses a thread pool. The use of blocking or CPU intensive code in these
handlers does not have such a negative effect on the application, because
handlers do not run on the same thread as the asynchronous loop. On the other
hand, the application will be affected by threading issues such as those caused
by the Global Interpreter Lock.
Under MicroPython the situation is different. Most microcontroller boards
implementing MicroPython do not have threading support or executors, so ``def``
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.
do not have or have very limited threading support, so Microdot executes
synchronous handlers in the main and often only thread available. This means
that these functions will block the asynchronous loop when they take too long
to complete. The use of properly written asynchronous handlers should be
preferred.

View File

@@ -32,9 +32,9 @@ flask==3.0.0
# via
# -r requirements.in
# quart
gunicorn==22.0.0
gunicorn==23.0.0
# via -r requirements.in
h11==0.14.0
h11==0.16.0
# via
# hypercorn
# uvicorn
@@ -57,7 +57,7 @@ itsdangerous==2.1.2
# via
# flask
# quart
jinja2==3.1.4
jinja2==3.1.6
# via
# flask
# quart
@@ -82,9 +82,9 @@ pydantic-core==2.14.5
# via pydantic
pyproject-hooks==1.0.0
# via build
quart==0.19.7
quart==0.20.0
# via -r requirements.in
requests==2.32.0
requests==2.32.4
# via -r requirements.in
sniffio==1.3.0
# via anyio
@@ -95,7 +95,7 @@ typing-extensions==4.9.0
# fastapi
# pydantic
# pydantic-core
urllib3==2.2.2
urllib3==2.5.0
# via requests
uvicorn==0.24.0.post1
# via -r requirements.in

View File

@@ -0,0 +1,104 @@
# Microdot Weather Dashboard
This example reports the temperature and humidity, both as a web application
and as a JSON API.
![Weather Dashboard Screenshot](screenshot.png)
## Requirements
- A microcontroller that supports MicroPython (e.g. ESP8266, ESP32, Raspberry
Pi Pico W, etc.)
- A DHT22 temperature and humidity sensor
- A breadboard and some jumper wires to create the circuit
## Circuit
Install the microconller and the DHT22 sensor on different parts of the
breadboard. Make the following connections with jumper wires:
- from a microcontroller power pin (3.3V or 5V) to the left pin of the DHT22
sensor.
- from a microcontroller `GND` pin to the right pin of the DHT22 sensor.
- from any available microcontroller GPIO pin to the middle pin of the DHT22
sensor. If the DHT22 sensor has 4 pins instead of 3, use the one on the left,
next to the pin receiving power.
The following diagram shows a possible wiring for this circuit using an ESP8266
microcontroller and the 4-pin variant of the DHT22. In this diagram the data
pin of the DHT22 sensor is connected to pin `D2` of the ESP8266, which is
assigned to GPIO #4. Note that the location of the pins in the microcontroller
board will vary depending on which microcontroller you use.
![Circuit diagram](circuit.png)
## Installation
Edit *config.py* as follows:
- Set the `DHT22_PIN` variable to the GPIO pin number connected to the sensor's
data pin. Make sure you consult the documentation for your microcontroller to
learn what number you should use for your chosen GPIO pin. In the example
diagram above, the value should be 4.
- Enter your Wi-Fi SSID name and password in this file.
Install MicroPython on your microcontroller board following instructions on the
MicroPython website. Then use a tool such as
[rshell](https://github.com/dhylands/rshell) to upload the following files to
the board:
- *main.py*
- *config.py*
- *index.html*
- *microdot.py*
You can find *microdot.py* in the *src/microdot* directory of this repository.
If you are using a low end microcontroller such as the ESP8266, it is quite
possible that the *microdot.py* file will fail to compile due to the
MicroPython compiler needing more RAM than available in the device. In that
case, you can install the `mpy-cross` Python package in your computer (same
version as your MicroPython firmware) and precompile this file. The precompiled
file will have the name *microdot.mpy*. Upload this file and remove
*microdot.py* from the device.
When the device is restarted after the files were uploaded, it will connect to
Wi-Fi and then start a web server on port 8000. One way to find out which IP
address was assigned to your device is to check your Wi-Fi's router
administration panel. Another option is to connect to the MicroPython REPL with
`rshell` or any other tool that you like, and then press Ctrl-D at the
MicroPython prompt to soft boot the device. The IP address is printed to the
terminal on startup.
You should not upload other *.py* files that exist in this directory to your
device. These files are used when running with emulated hardware.
## Trying out the application
Once the device is running the server, you can connect to it using a web
browser. For example, if your device's Wi-Fi connection was assigned the IP
address 192.168.0.145, type *http://192.168.0.45:8000/* in your browser's
address bar. Note it is *http://* and not *https://*. This example does not use
the TLS/SSL protocol.
To test the JSON API, you can use `curl` or your favorite HTTP client. The API
endpoint uses the */api* path, with the same URL as the main website. Here is
an example using `curl`:
```bash
$ curl http://192.168.0.145:8000/api
{"temperature": 21.6, "humidity": 58.9, "time": 1752444652}
```
The `temperature` value is given in degrees Celsius. The `humidity` value is
given as a percentage. The `time` value is a UNIX timestamp.
## Running in Emulation mode
You can run this application on your computer, directly from this directory.
When used in this way, the DHT22 hardware is emulated, and the temperature and
humidity values are randomly generated.
The only dependency that is needed for this application to run in emulation
mode is `microdot`, so make sure that is installed, or else add a copy of the
*microdot.py* from the *src/microdot* directory in this folder.

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -0,0 +1,3 @@
DHT22_PIN = 4 # GPIO pin for DHT22 sensor
WIFI_ESSID = 'your_wifi_ssid'
WIFI_PASSWORD = 'your_wifi_password'

View File

@@ -0,0 +1,26 @@
"""
DO NOT UPLOAD THIS FILE TO YOUR MICROPYTHON DEVICE
This module emulates MicroPython's DHT22 driver. It can be used when running
on a system without the DHT22 hardware.
The temperature and humidity values that are returned are random values.
"""
from random import random
class DHT22:
def __init__(self, pin):
self.pin = pin
def measure(self):
pass
def temperature(self):
"""Return a random temperature between 10 and 30 degrees Celsius."""
return random() * 20 + 10
def humidity(self):
"""Return a random humidity between 30 and 70 percent."""
return random() * 40 + 30

View File

@@ -0,0 +1,169 @@
<!doctype html>
<html>
<head>
<title>Microdot Weather Dashboard</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gauge.js/1.3.9/gauge.min.js" integrity="sha512-/gkYCBz4KVyJb3Shz6Z1kKu9Za5EdInNezzsm2O/DPvAYhCeIOounTzi7yuIF526z3rNZfIDxcx+rJAD07p8aA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<style>
html, body {
height: 95%;
font-family: Arial, sans-serif;
}
#container {
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
h1 {
font-size: 1.5em;
}
h1, p {
text-align: center;
}
table {
margin-left: auto;
margin-right: auto;
}
table h1 {
margin-top: 0;
}
table p {
margin: 0;
}
#temperature, #humidity {
width: 100%;
max-width: 400px;
aspect-ratio: 2;
}
</style>
</head>
<body>
<div id="container">
<h1>Microdot Weather Dashboard</h1>
<table cellspacing="0" cellpadding="0">
<tr>
<td>
<canvas id="temperature" width="400" height="200"></canvas>
</td>
<td>
<canvas id="humidity" width="400" height="200"></canvas>
</td>
</tr>
<tr>
<td>
<p>Temperature</p>
<h1><span id="temperature-text">??</span>°C</h1>
</td>
<td>
<p>Humidity</p>
<h1><span id="humidity-text">??</span>%</h1>
</td>
</tr>
<tr>
<td colspan="2">
<p><i>Last updated: <span id="time-text">...</span></i></p>
</td>
</tr>
</table>
</div>
<script>
// create the temperature gauge
let temperatureGauge = new Gauge(document.getElementById('temperature')).setOptions({
angle: 0,
lineWidth: 0.3,
radiusScale: 1,
pointer: {
length: 0.6,
strokeWidth: 0.035,
color: '#000000',
},
limitMax: false,
limitMin: false,
highDpiSupport: true,
staticLabels: {
font: "14px sans-serif",
labels: [-30, -20, -10, 0, 10, 20, 30, 40, 50],
color: "#000000",
fractionDigits: 0,
},
staticZones: [
{strokeStyle: "#85a6e8", min: -30, max: 0},
{strokeStyle: "#a5dde8", min: 0, max: 10},
{strokeStyle: "#a5e8a6", min: 10, max: 20},
{strokeStyle: "#e8d8a5", min: 20, max: 30},
{strokeStyle: "#e8a8a5", min: 30, max: 50},
],
renderTicks: {
divisions: 8,
divWidth: 1.1,
divLength: 0.7,
divColor: '#333333',
subDivisions: 4,
subLength: 0.3,
subWidth: 0.6,
subColor: '#666666'
}
});
temperatureGauge.maxValue = 50;
temperatureGauge.setMinValue(-30);
temperatureGauge.animationSpeed = 36;
temperatureGauge.set(0);
let humidityGauge = new Gauge(document.getElementById('humidity')).setOptions({
angle: 0,
lineWidth: 0.3,
radiusScale: 1,
pointer: {
length: 0.6,
strokeWidth: 0.035,
color: '#000000',
},
limitMax: false,
limitMin: false,
highDpiSupport: true,
staticLabels: {
font: "14px sans-serif",
labels: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
color: "#000000",
fractionDigits: 0,
},
staticZones: [
{strokeStyle: "#85a6e8", min: 0, max: 40},
{strokeStyle: "#a5e8a6", min: 40, max: 70},
{strokeStyle: "#e8a8a5", min: 70, max: 100},
],
renderTicks: {
divisions: 10,
divWidth: 1.1,
divLength: 0.7,
divColor: '#333333',
subDivisions: 4,
subLength: 0.3,
subWidth: 0.6,
subColor: '#666666'
}
});
humidityGauge.maxValue = 100;
humidityGauge.setMinValue(0);
humidityGauge.animationSpeed = 36;
humidityGauge.set(0);
async function update() {
const response = await fetch('/api');
if (response.ok) {
const data = await response.json();
temperatureGauge.set(data.temperature);
humidityGauge.set(data.humidity);
document.getElementById('temperature-text').textContent = data.temperature;
document.getElementById('humidity-text').textContent = data.humidity;
document.getElementById('time-text').textContent = new Date(data.time * 1000).toLocaleString();
}
setTimeout(update, 60000); // refresh every minute
}
update();
</script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
"""
DO NOT UPLOAD THIS FILE TO YOUR MICROPYTHON DEVICE
This module emulates parts of MicroPython's `machine` module, to enable to run
MicroPython applications on UNIX, Mac or Windows systems without dedicated
hardware.
"""
class Pin:
def __init__(self, pin):
self.pin = pin

View File

@@ -0,0 +1,112 @@
import asyncio
import dht
import gc
import machine
import network
import socket
import time
import config
from microdot import Microdot, send_file
app = Microdot()
current_temperature = None
current_humidity = None
current_time = None
def wifi_connect():
"""Connect to the configured Wi-Fi network.
Returns the IP address of the connected interface.
"""
ap_if = network.WLAN(network.AP_IF)
ap_if.active(False)
sta_if = network.WLAN(network.STA_IF)
if not sta_if.isconnected():
print('connecting to network...')
sta_if.active(True)
sta_if.connect(config.WIFI_ESSID, config.WIFI_PASSWORD)
for i in range(20):
if sta_if.isconnected():
break
time.sleep(1)
if not sta_if.isconnected():
raise RuntimeError('Could not connect to network')
return sta_if.ifconfig()[0]
def get_current_time():
"""Return the current Unix time.
Note that because many microcontrollers do not have a clock, this function
makes a call to an NTP server to obtain the current time. A Wi-Fi
connection needs to be in place before calling this function.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(5)
s.sendto(b'\x1b' + 47 * b'\0',
socket.getaddrinfo('pool.ntp.org', 123)[0][4])
msg, _ = s.recvfrom(1024)
return ((msg[40] << 24) | (msg[41] << 16) | (msg[42] << 8) | msg[43]) - \
2208988800
def get_current_weather():
"""Read the temperature and humidity from the DHT22 sensor.
Returns them as a tuple. The returned temperature is in degrees Celcius.
The humidity is a 0-100 percentage.
"""
d = dht.DHT22(machine.Pin(config.DHT22_PIN))
d.measure()
return d.temperature(), d.humidity()
async def refresh_weather():
"""Background task that updates the temperature and humidity.
This task is designed to run in the background. It connects to the DHT22
temperature and humidity sensor once per minute and stores the updated
readings in global variables.
"""
global current_temperature
global current_humidity
global current_time
while True:
try:
t = get_current_time()
temp, hum = get_current_weather()
except asyncio.CancelledError:
raise
except Exception as error:
print(f'Could not obtain weather, error: {error}')
else:
current_time = t
current_temperature = int(temp * 10) / 10
current_humidity = int(hum * 10) / 10
gc.collect()
await asyncio.sleep(60)
@app.route('/')
async def index(request):
return send_file('index.html')
@app.route('/api')
async def api(request):
return {
'temperature': current_temperature,
'humidity': current_humidity,
'time': current_time,
}
async def start():
ip = wifi_connect()
print(f'Starting server at http://{ip}:8000...')
bgtask = asyncio.create_task(refresh_weather())
server = asyncio.create_task(app.start_server(port=8000))
await asyncio.gather(server, bgtask)
asyncio.run(start())

View File

@@ -0,0 +1,31 @@
"""
DO NOT UPLOAD THIS FILE TO YOUR MICROPYTHON DEVICE
This module emulates parts of MicroPython's `network` module, in particular
those related to establishing a Wi-Fi connection. This enables to run
MicroPython applications on UNIX, Mac or Windows systems without dedicated
hardware.
Note that no connections are attempted. The assumption is that the system is
already connected. The "127.0.0.1" address is always returned.
"""
AP_IF = 1
STA_IF = 2
class WLAN:
def __init__(self, network):
self.network = network
def isconnected(self):
return True
def ifconfig(self):
return ('127.0.0.1', 'n/a', 'n/a', 'n/a')
def connect(self):
pass
def active(self, active=None):
pass

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1 @@
This directory contains examples that demonstrate sub-applications.

27
examples/subapps/app.py Normal file
View File

@@ -0,0 +1,27 @@
from microdot import Microdot
from subapp import subapp
app = Microdot()
app.mount(subapp, url_prefix='/subapp')
@app.route('/')
async def hello(request):
return '''
<!DOCTYPE html>
<html>
<head>
<title>Microdot Sub-App Example</title>
<meta charset="UTF-8">
</head>
<body>
<div>
<h1>Microdot Main Page</h1>
<p>Visit the <a href="/subapp">sub-app</a>.</p>
</div>
</body>
</html>
''', 200, {'Content-Type': 'text/html'}
app.run(debug=True)

View File

@@ -0,0 +1,44 @@
from microdot import Microdot
subapp = Microdot()
@subapp.route('')
async def hello(request):
# request.url_prefix can be used in links that are relative to this subapp
return f'''
<!DOCTYPE html>
<html>
<head>
<title>Microdot Sub-App Example</title>
<meta charset="UTF-8">
</head>
<body>
<div>
<h1>Microdot Sub-App Main Page</h1>
<p>Visit the sub-app's <a href="{request.url_prefix}/second">secondary page</a>.</p>
<p>Go back to the app's <a href="/">main page</a>.</p>
</div>
</body>
</html>
''', 200, {'Content-Type': 'text/html'} # noqa: E501
@subapp.route('/second')
async def second(request):
return f'''
<!DOCTYPE html>
<html>
<head>
<title>Microdot Sub-App Example</title>
<meta charset="UTF-8">
</head>
<body>
<div>
<h1>Microdot Sub-App Secondary Page</h1>
<p>Visit the sub-app's <a href="{request.url_prefix}">main page</a>.</p>
<p>Go back to the app's <a href="/">main page</a>.</p>
</div>
</body>
</html>
''', 200, {'Content-Type': 'text/html'} # noqa: E501

View File

@@ -1 +1,4 @@
This directory contains file upload examples.
- `simple_uploads.py` demonstrates how to upload a single file.
- `formdata.py` demonstrates how to process a form that includes file uploads.

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<title>Microdot Multipart Form-Data Example</title>
<meta charset="UTF-8">
</head>
<body>
<h1>Microdot Multipart Form-Data Example</h1>
<form method="POST" action="" enctype="multipart/form-data">
<p>Name: <input type="text" name="name" /></p>
<p>Age: <input type="text" name="age" /></p>
<p>Comments: <textarea name="comments" rows="4"></textarea></p>
<p>File: <input type="file" id="file" name="file" /></p>
<input type="submit" value="Submit" />
</form>
</body>
</html>

View File

@@ -0,0 +1,26 @@
from microdot import Microdot, send_file, Request
from microdot.multipart import with_form_data
app = Microdot()
Request.max_content_length = 1024 * 1024 # 1MB (change as needed)
@app.get('/')
async def index(request):
return send_file('formdata.html')
@app.post('/')
@with_form_data
async def upload(request):
print('Form fields:')
for field, value in request.form.items():
print(f'- {field}: {value}')
print('\nFile uploads:')
for field, value in request.files.items():
print(f'- {field}: {value.filename}, {await value.read()}')
return 'We have received your data!'
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -6,7 +6,7 @@ Request.max_content_length = 1024 * 1024 # 1MB (change as needed)
@app.get('/')
async def index(request):
return send_file('index.html')
return send_file('simple_uploads.html')
@app.post('/upload')

View File

@@ -1,6 +1,6 @@
[project]
name = "microdot"
version = "2.1.0"
version = "2.3.4"
authors = [
{ name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
]

View File

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

View File

@@ -127,19 +127,17 @@ class Microdot(BaseMicrodot):
monitor_task = asyncio.ensure_future(cancel_monitor())
body_iter = res.body_iter().__aiter__()
res_body = b''
try:
res_body = await body_iter.__anext__()
while not cancelled: # pragma: no branch
next_body = await body_iter.__anext__()
res_body = await body_iter.__anext__()
await send({'type': 'http.response.body',
'body': res_body,
'more_body': True})
res_body = next_body
except StopAsyncIteration:
await send({'type': 'http.response.body',
'body': res_body,
'more_body': False})
pass
await send({'type': 'http.response.body',
'body': b'',
'more_body': False})
if hasattr(body_iter, 'aclose'): # pragma: no branch
await body_iter.aclose()
cancelled = True

View File

@@ -36,6 +36,24 @@ class BaseAuth:
return wrapper
def optional(self, f):
"""Decorator to protect a route with optional authentication.
This decorator makes authentication for the decorated route optional,
meaning that the route is allowed to run with or with
authentication given in the request.
"""
async def wrapper(request, *args, **kwargs):
auth = self._get_auth(request)
if not auth:
request.g.current_user = None
else:
request.g.current_user = await invoke_handler(
self.auth_callback, request, *auth)
return await invoke_handler(f, request, *args, **kwargs)
return wrapper
class BasicAuth(BaseAuth):
"""Basic Authentication.
@@ -67,7 +85,7 @@ class BasicAuth(BaseAuth):
return None
return username, password
def authentication_error(self, request):
async def authentication_error(self, request):
return '', self.error_status, {
'WWW-Authenticate': '{} realm="{}", charset="{}"'.format(
self.scheme, self.realm, self.charset)}
@@ -140,5 +158,5 @@ class TokenAuth(BaseAuth):
"""
self.error_callback = f
def authentication_error(self, request):
async def authentication_error(self, request):
abort(self.error_status)

View File

@@ -104,7 +104,8 @@ class CORS:
def after_request(self, request, response):
saved_vary = response.headers.get('Vary')
response.headers.update(self.get_cors_headers(request))
if request: # pragma: no branch
response.headers.update(self.get_cors_headers(request))
if saved_vary and saved_vary != response.headers.get('Vary'):
response.headers['Vary'] = (
saved_vary + ', ' + response.headers['Vary'])

View File

@@ -1,19 +1,27 @@
from jinja2 import Environment, FileSystemLoader, select_autoescape
_jinja_env = None
class Template:
"""A template object.
:param template: The filename of the template to render, relative to the
configured template directory.
:param kwargs: any additional options to be passed to the Jinja
environment's ``get_template()`` method.
"""
#: The Jinja environment. The ``initialize()`` method must be called before
#: this attribute is accessed.
jinja_env = None
@classmethod
def initialize(cls, template_dir='templates', enable_async=False,
**kwargs):
"""Initialize the templating subsystem.
This method is automatically invoked when the first template is
created. The application can call it explicitly if custom options need
to be provided.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load
templates from a *templates* subdirectory.
@@ -23,20 +31,19 @@ class Template:
:param kwargs: any additional options to be passed to Jinja's
``Environment`` class.
"""
global _jinja_env
_jinja_env = Environment(
cls.jinja_env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
enable_async=enable_async,
**kwargs
)
def __init__(self, template):
if _jinja_env is None: # pragma: no cover
def __init__(self, template, **kwargs):
if self.jinja_env is None: # pragma: no cover
self.initialize()
#: The name of the template
#: The name of the template.
self.name = template
self.template = _jinja_env.get_template(template)
self.template = self.jinja_env.get_template(template, **kwargs)
def generate(self, *args, **kwargs):
"""Return a generator that renders the template in chunks, with the

View File

@@ -7,9 +7,14 @@ servers for MicroPython and standard Python.
"""
import asyncio
import io
import json
import re
import time
try:
import orjson as json
except ImportError:
import json
try:
from inspect import iscoroutinefunction, iscoroutine
from functools import partial
@@ -56,23 +61,9 @@ MUTED_SOCKET_ERRORS = [
]
def urldecode_str(s):
s = s.replace('+', ' ')
parts = s.split('%')
if len(parts) == 1:
return s
result = [parts[0]]
for item in parts[1:]:
if item == '':
result.append('%')
else:
code = item[:2]
result.append(chr(int(code, 16)))
result.append(item[2:])
return ''.join(result)
def urldecode_bytes(s):
def urldecode(s):
if isinstance(s, str):
s = s.encode()
s = s.replace(b'+', b' ')
parts = s.split(b'%')
if len(parts) == 1:
@@ -375,8 +366,8 @@ class Request:
self.content_type = self.headers['Content-Type']
if 'Cookie' in self.headers:
for cookie in self.headers['Cookie'].split(';'):
name, value = cookie.strip().split('=', 1)
self.cookies[name] = value
c = cookie.strip().split('=', 1)
self.cookies[c[0]] = c[1] if len(c) > 1 else ''
self._body = body
self.body_used = False
@@ -384,6 +375,7 @@ class Request:
self.sock = sock
self._json = None
self._form = None
self._files = None
self.after_request_handlers = []
@staticmethod
@@ -440,12 +432,12 @@ class Request:
if isinstance(urlencoded, str):
for kv in [pair.split('=', 1)
for pair in urlencoded.split('&') if pair]:
data[urldecode_str(kv[0])] = urldecode_str(kv[1]) \
data[urldecode(kv[0])] = urldecode(kv[1]) \
if len(kv) > 1 else ''
elif isinstance(urlencoded, bytes): # pragma: no branch
for kv in [pair.split(b'=', 1)
for pair in urlencoded.split(b'&') if pair]:
data[urldecode_bytes(kv[0])] = urldecode_bytes(kv[1]) \
data[urldecode(kv[0])] = urldecode(kv[1]) \
if len(kv) > 1 else b''
return data
@@ -478,7 +470,13 @@ class Request:
def form(self):
"""The parsed form submission body, as a
:class:`MultiDict <microdot.MultiDict>` object, or ``None`` if the
request does not have a form submission."""
request does not have a form submission.
Forms that are URL encoded are processed by default. For multipart
forms to be processed, the
:func:`with_form_data <microdot.multipart.with_form_data>`
decorator must be added to the route.
"""
if self._form is None:
if self.content_type is None:
return None
@@ -488,6 +486,17 @@ class Request:
self._form = self._parse_urlencoded(self.body)
return self._form
@property
def files(self):
"""The files uploaded in the request as a dictionary, or ``None`` if
the request does not have any files.
The :func:`with_form_data <microdot.multipart.with_form_data>`
decorator must be added to the route that receives file uploads for
this property to be set.
"""
return self._files
def after_request(self, f):
"""Register a request-specific function to run after the request is
handled. Request-specific after request handlers run at the very end,
@@ -545,6 +554,7 @@ class Response:
'json': 'application/json',
'png': 'image/png',
'txt': 'text/plain',
'svg': 'image/svg+xml',
}
send_file_buffer_size = 1024
@@ -569,9 +579,9 @@ class Response:
self.headers = NoCaseDict(headers or {})
self.reason = reason
if isinstance(body, (dict, list)):
self.body = json.dumps(body).encode()
body = json.dumps(body)
self.headers['Content-Type'] = 'application/json; charset=UTF-8'
elif isinstance(body, str):
if isinstance(body, str):
self.body = body.encode()
else:
# this applies to bytes, file-like objects or generators
@@ -805,13 +815,54 @@ class Response:
class URLPattern():
"""A class that represents the URL pattern for a route.
:param url_pattern: The route URL pattern, which can include static and
dynamic path segments. Dynamic segments are enclosed in
``<`` and ``>``. The type of the segment can be given
as a prefix, separated from the name with a colon.
Supported types are ``string`` (the default),
``int`` and ``path``. Custom types can be registered
using the :meth:`URLPattern.register_type` method.
"""
segment_patterns = {
'string': '/([^/]+)',
'int': '/(-?\\d+)',
'path': '/(.+)',
}
segment_parsers = {
'int': lambda value: int(value),
}
@classmethod
def register_type(cls, type_name, pattern='[^/]+', parser=None):
"""Register a new URL segment type.
:param type_name: The name of the segment type to register.
:param pattern: The regular expression pattern to use when matching
this segment type. If not given, a default matcher for
a single path segment is used.
:param parser: A callable that will be used to parse and transform the
value of the segment. If omitted, the value is returned
as a string.
"""
cls.segment_patterns[type_name] = '/({})'.format(pattern)
cls.segment_parsers[type_name] = parser
def __init__(self, url_pattern):
self.url_pattern = url_pattern
self.segments = []
self.regex = None
def compile(self):
"""Generate a regular expression for the URL pattern.
This method is automatically invoked the first time the URL pattern is
matched against a path.
"""
pattern = ''
use_regex = False
for segment in url_pattern.lstrip('/').split('/'):
for segment in self.url_pattern.lstrip('/').split('/'):
if segment and segment[0] == '<':
if segment[-1] != '>':
raise ValueError('invalid URL pattern')
@@ -822,82 +873,44 @@ class URLPattern():
type_ = 'string'
name = segment
parser = None
if type_ == 'string':
parser = self._string_segment
pattern += '/([^/]+)'
elif type_ == 'int':
parser = self._int_segment
pattern += '/(-?\\d+)'
elif type_ == 'path':
use_regex = True
pattern += '/(.+)'
elif type_.startswith('re:'):
use_regex = True
if type_.startswith('re:'):
pattern += '/({pattern})'.format(pattern=type_[3:])
else:
raise ValueError('invalid URL segment type')
if type_ not in self.segment_patterns:
raise ValueError('invalid URL segment type')
pattern += self.segment_patterns[type_]
parser = self.segment_parsers.get(type_)
self.segments.append({'parser': parser, 'name': name,
'type': type_})
else:
pattern += '/' + segment
self.segments.append({'parser': self._static_segment(segment)})
if use_regex:
import re
self.regex = re.compile('^' + pattern + '$')
self.segments.append({'parser': None})
self.regex = re.compile('^' + pattern + '$')
return self.regex
def match(self, path):
"""Match a path against the URL pattern.
Returns a dictionary with the values of all dynamic path segments if a
matche is found, or ``None`` if the path does not match this pattern.
"""
args = {}
if self.regex:
g = self.regex.match(path)
if not g:
return
i = 1
for segment in self.segments:
if 'name' not in segment:
continue
value = g.group(i)
if segment['type'] == 'int':
value = int(value)
args[segment['name']] = value
i += 1
else:
if len(path) == 0 or path[0] != '/':
return
path = path[1:]
args = {}
for segment in self.segments:
if path is None:
return
arg, path = segment['parser'](path)
g = (self.regex or self.compile()).match(path)
if not g:
return
i = 1
for segment in self.segments:
if 'name' not in segment:
continue
arg = g.group(i)
if segment['parser']:
arg = self.segment_parsers[segment['type']](arg)
if arg is None:
return
if 'name' in segment:
args[segment['name']] = arg
if path is not None:
return
args[segment['name']] = arg
i += 1
return args
def _static_segment(self, segment):
def _static(value):
s = value.split('/', 1)
if s[0] == segment:
return '', s[1] if len(s) > 1 else None
return None, None
return _static
def _string_segment(self, value):
s = value.split('/', 1)
if len(s[0]) == 0:
return None, None
return s[0], s[1] if len(s) > 1 else None
def _int_segment(self, value):
s = value.split('/', 1)
try:
return int(s[0]), s[1] if len(s) > 1 else None
except ValueError:
return None, None
def __repr__(self): # pragma: no cover
return 'URLPattern: {}'.format(self.url_pattern)
@@ -1359,9 +1372,9 @@ class Microdot:
print_exception(exc)
res = await self.dispatch_request(req)
if res != Response.already_handled: # pragma: no branch
await res.write(writer)
try:
if res != Response.already_handled: # pragma: no branch
await res.write(writer)
await writer.aclose()
except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS:

298
src/microdot/multipart.py Normal file
View File

@@ -0,0 +1,298 @@
import os
from random import choice
from microdot import abort, iscoroutine, AsyncBytesIO
from microdot.helpers import wraps
class FormDataIter:
"""Asynchronous iterator that parses a ``multipart/form-data`` body and
returns form fields and files as they are parsed.
:param request: the request object to parse.
Example usage::
from microdot.multipart import FormDataIter
@app.post('/upload')
async def upload(request):
async for name, value in FormDataIter(request):
print(name, value)
The iterator returns no values when the request has a content type other
than ``multipart/form-data``. For a file field, the returned value is of
type :class:`FileUpload`, which supports the
:meth:`read() <FileUpload.read>` and :meth:`save() <FileUpload.save>`
methods. Values for regular fields are provided as strings.
The request body is read efficiently in chunks of size
:attr:`buffer_size <FormDataIter.buffer_size>`. On iterations in which a
file field is encountered, the file must be consumed before moving on to
the next iteration, as the internal stream stored in ``FileUpload``
instances is invalidated at the end of the iteration.
"""
#: The size of the buffer used to read chunks of the request body. This
#: size must be large enough to hold at least one complete header or
#: boundary line, so it is not recommended to lower it, but it can be made
#: higher to improve performance at the expense of RAM.
buffer_size = 256
def __init__(self, request):
self.request = request
self.buffer = None
try:
mimetype, boundary = request.content_type.rsplit('; boundary=', 1)
except ValueError:
return # not a multipart request
if mimetype.split(';', 1)[0] == \
'multipart/form-data': # pragma: no branch
self.boundary = b'--' + boundary.encode()
self.extra_size = len(boundary) + 4
self.buffer = b''
def __aiter__(self):
return self
async def __anext__(self):
if self.buffer is None:
raise StopAsyncIteration
# make sure we have consumed the previous entry
while await self._read_buffer(self.buffer_size) != b'':
pass
# make sure we are at a boundary
await self._fill_buffer()
s = self.buffer.split(self.boundary, 1)
if len(s) != 2 or s[0] != b'':
abort(400) # pragma: no cover
self.buffer = s[1]
if self.buffer[:2] == b'--':
# we have reached the end
raise StopAsyncIteration
elif self.buffer[:2] != b'\r\n':
abort(400) # pragma: no cover
self.buffer = self.buffer[2:]
# parse the headers of this part
name = ''
filename = None
content_type = None
while True:
await self._fill_buffer()
lines = self.buffer.split(b'\r\n', 1)
if len(lines) != 2:
abort(400) # pragma: no cover
line, self.buffer = lines
if line == b'':
# we reached the end of the headers
break
header, value = line.decode().split(':', 1)
header = header.lower()
value = value.strip()
if header == 'content-disposition':
parts = value.split(';')
if len(parts) < 2 or parts[0] != 'form-data':
abort(400) # pragma: no cover
for part in parts[1:]:
part = part.strip()
if part.startswith('name="'):
name = part[6:-1]
elif part.startswith('filename="'): # pragma: no branch
filename = part[10:-1]
elif header == 'content-type': # pragma: no branch
content_type = value
if filename is None:
# this is a regular form field, so we read the value
value = b''
while True:
v = await self._read_buffer(self.buffer_size)
value += v
if len(v) < self.buffer_size: # pragma: no branch
break
return name, value.decode()
return name, FileUpload(filename, content_type, self._read_buffer)
async def _fill_buffer(self):
if self.buffer[-len(self.boundary) - 4:] == self.boundary + b'--\r\n':
# we have reached the end of the body
return
self.buffer += await self.request.stream.read(
self.buffer_size + self.extra_size - len(self.buffer))
async def _read_buffer(self, n=-1):
data = b''
while n == -1 or len(data) < n:
await self._fill_buffer()
s = self.buffer.split(self.boundary, 1)
data += s[0][:n] if n != -1 else s[0]
self.buffer = s[0][n:] if n != -1 else b''
if len(s) == 2: # pragma: no branch
# the end of this part is in the buffer
if len(self.buffer) < 2:
# we have read all the way to the end of this part
data = data[:-(2 - len(self.buffer))] # remove last "\r\n"
self.buffer += self.boundary + s[1]
return data
return data
class FileUpload:
"""Class that represents an uploaded file.
:param filename: the name of the uploaded file.
:param content_type: the content type of the uploaded file.
:param read: a coroutine that reads from the uploaded file's stream.
An uploaded file can be read from the stream using the :meth:`read()`
method or saved to a file using the :meth:`save()` method.
Instances of this class do not normally need to be created directly.
"""
#: The size at which the file is copied to a temporary file.
max_memory_size = 1024
def __init__(self, filename, content_type, read):
self.filename = filename
self.content_type = content_type
self._read = read
self._close = None
async def read(self, n=-1):
"""Read up to ``n`` bytes from the uploaded file's stream.
:param n: the maximum number of bytes to read. If ``n`` is -1 or not
given, the entire file is read.
"""
return await self._read(n)
async def save(self, path_or_file):
"""Save the uploaded file to the given path or file object.
:param path_or_file: the path to save the file to, or a file object
to which the file is to be written.
The file is read and written in chunks of size
:attr:`FormDataIter.buffer_size`.
"""
if isinstance(path_or_file, str):
f = open(path_or_file, 'wb')
else:
f = path_or_file
while True:
data = await self.read(FormDataIter.buffer_size)
if not data:
break
f.write(data)
if f != path_or_file:
f.close()
async def copy(self, max_memory_size=None):
"""Copy the uploaded file to a temporary file, to allow the parsing of
the multipart form to continue.
:param max_memory_size: the maximum size of the file to keep in memory.
If not given, then the class attribute of the
same name is used.
"""
max_memory_size = max_memory_size or FileUpload.max_memory_size
buffer = await self.read(max_memory_size)
if len(buffer) < max_memory_size:
f = AsyncBytesIO(buffer)
self._read = f.read
return self
# create a temporary file
while True:
tmpname = "".join([
choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ')
for _ in range(12)
])
try:
f = open(tmpname, 'x+b')
except OSError as e: # pragma: no cover
if e.errno == 17:
# EEXIST
continue
elif e.errno == 2:
# ENOENT
# some MicroPython platforms do not support mode "x"
f = open(tmpname, 'w+b')
if f.read(1) != b'':
f.close()
continue
else:
raise
break
f.write(buffer)
await self.save(f)
f.seek(0)
async def read(n=-1):
return f.read(n)
async def close():
f.close()
os.remove(tmpname)
self._read = read
self._close = close
return self
async def close(self):
"""Close an open file.
This method must be called to free memory or temporary files created by
the ``copy()`` method.
Note that when using the ``@with_form_data`` decorator this method is
called automatically when the request ends.
"""
if self._close:
await self._close()
self._close = None
def with_form_data(f):
"""Decorator that parses a ``multipart/form-data`` body and updates the
request object with the parsed form fields and files.
Example usage::
from microdot.multipart import with_form_data
@app.post('/upload')
@with_form_data
async def upload(request):
print('form fields:', request.form)
print('files:', request.files)
Note: this decorator calls the :meth:`FileUpload.copy()
<microdot.multipart.FileUpload.copy>` method on all uploaded files, so that
the request can be parsed in its entirety. The files are either copied to
memory or a temporary file, depending on their size. The temporary files
are automatically deleted when the request ends.
"""
@wraps(f)
async def wrapper(request, *args, **kwargs):
form = {}
files = {}
async for name, value in FormDataIter(request):
if isinstance(value, FileUpload):
files[name] = await value.copy()
else:
form[name] = value
if form or files:
request._form = form
request._files = files
try:
ret = f(request, *args, **kwargs)
if iscoroutine(ret):
ret = await ret
finally:
if request.files:
for file in request.files.values():
await file.close()
return ret
return wrapper

View File

@@ -1,7 +1,11 @@
import asyncio
import json
from microdot.helpers import wraps
try:
import orjson as json
except ImportError:
import json
class SSE:
"""Server-Sent Events object.
@@ -25,8 +29,8 @@ class SSE:
given, it must be a string.
"""
if isinstance(data, (dict, list)):
data = json.dumps(data).encode()
elif isinstance(data, str):
data = json.dumps(data)
if isinstance(data, str):
data = data.encode()
elif not isinstance(data, bytes):
data = str(data).encode()
@@ -57,7 +61,14 @@ def sse_response(request, event_function, *args, **kwargs):
sse = SSE()
async def sse_task_wrapper():
await event_function(request, sse, *args, **kwargs)
try:
await event_function(request, sse, *args, **kwargs)
except asyncio.CancelledError: # pragma: no cover
pass
except Exception as exc:
# the SSE task raised an exception so we need to pass it to the
# main route so that it is re-raised there
sse.queue.append(exc)
sse.event.set()
task = asyncio.create_task(sse_task_wrapper())
@@ -75,7 +86,11 @@ def sse_response(request, event_function, *args, **kwargs):
except IndexError:
await sse.event.wait()
sse.event.clear()
if event is None:
if isinstance(event, Exception):
# if the event is an exception we re-raise it here so that it
# can be handled appropriately
raise event
elif event is None:
raise StopAsyncIteration
return event

View File

@@ -1,4 +1,4 @@
import json
import asyncio
from microdot.microdot import Request, Response, AsyncBytesIO
try:
@@ -6,6 +6,11 @@ try:
except: # pragma: no cover # noqa: E722
WebSocket = None
try:
import orjson as json
except ImportError:
import json
__all__ = ['TestClient', 'TestResponse']
@@ -19,7 +24,7 @@ class TestResponse:
#: explicitly sets it on the response object.
self.reason = None
#: A dictionary with the response headers.
self.headers = None
self.headers = {}
#: The body of the response, as a bytes object.
self.body = None
#: The body of the response, decoded to a UTF-8 string. Set to
@@ -28,6 +33,11 @@ class TestResponse:
#: The body of the JSON response, decoded to a dictionary or list. Set
#: ``Note`` if the response does not have a JSON payload.
self.json = None
#: The body of the SSE response, decoded to a list of events, each
#: given as a dictionary with a ``data`` key and optionally also
#: ``event`` and ``id`` keys. Set to ``None`` if the response does not
#: have an SSE payload.
self.events = None
def _initialize_response(self, res):
self.status_code = res.status_code
@@ -37,10 +47,13 @@ class TestResponse:
async def _initialize_body(self, res):
self.body = b''
iter = res.body_iter()
async for body in iter: # pragma: no branch
if isinstance(body, str):
body = body.encode()
self.body += body
try:
async for body in iter: # pragma: no branch
if isinstance(body, str):
body = body.encode()
self.body += body
except asyncio.CancelledError: # pragma: no cover
pass
if hasattr(iter, 'aclose'): # pragma: no branch
await iter.aclose()
@@ -56,6 +69,32 @@ class TestResponse:
if content_type.split(';')[0] == 'application/json':
self.json = json.loads(self.text)
def _process_sse_body(self):
if 'Content-Type' in self.headers: # pragma: no branch
content_type = self.headers['Content-Type']
if content_type.split(';')[0] == 'text/event-stream':
self.events = []
for sse_event in self.body.split(b'\n\n'):
data = None
event = None
event_id = None
for line in sse_event.split(b'\n'):
if line.startswith(b'data:'):
data = line[5:].strip()
elif line.startswith(b'event:'):
event = line[6:].strip().decode()
elif line.startswith(b'id:'):
event_id = line[3:].strip().decode()
if data:
data_json = None
try:
data_json = json.loads(data)
except ValueError:
pass
self.events.append({
"data": data, "data_json": data_json,
"event": event, "event_id": event_id})
@classmethod
async def create(cls, res):
test_res = cls()
@@ -64,6 +103,7 @@ class TestResponse:
await test_res._initialize_body(res)
test_res._process_text_body()
test_res._process_json_body()
test_res._process_sse_body()
return test_res
@@ -101,10 +141,10 @@ class TestClient:
if body is None:
body = b''
elif isinstance(body, (dict, list)):
body = json.dumps(body).encode()
body = json.dumps(body)
if 'Content-Type' not in headers: # pragma: no cover
headers['Content-Type'] = 'application/json'
elif isinstance(body, str):
if isinstance(body, str):
body = body.encode()
if body and 'Content-Length' not in headers:
headers['Content-Length'] = str(len(body))
@@ -195,7 +235,7 @@ class TestClient:
('127.0.0.1', 1234))
res = await self.app.dispatch_request(req)
if res == Response.already_handled:
return None
return TestResponse()
res.complete()
self._update_cookies(res)

View File

@@ -149,18 +149,18 @@ class WebSocket:
raise WebSocketError('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 = await self.request.sock[0].readexactly(2)
length = int.from_bytes(length, 'big')
elif length == -8:
length = await self.request.sock[0].read(8)
length = await self.request.sock[0].readexactly(8)
length = int.from_bytes(length, 'big')
max_allowed_length = Request.max_body_length \
if self.max_message_length == -1 else self.max_message_length
if length > max_allowed_length:
raise WebSocketError('Message too large')
if has_mask: # pragma: no cover
mask = await self.request.sock[0].read(4)
payload = await self.request.sock[0].read(length)
mask = await self.request.sock[0].readexactly(4)
payload = await self.request.sock[0].readexactly(length)
if has_mask: # pragma: no cover
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
return opcode, payload

View File

@@ -4,6 +4,7 @@ from tests.test_request import * # noqa: F401, F403
from tests.test_response import * # noqa: F401, F403
from tests.test_urlencode import * # noqa: F401, F403
from tests.test_url_pattern import * # noqa: F401, F403
from tests.test_multipart import * # noqa: F401, F403
from tests.test_websocket import * # noqa: F401, F403
from tests.test_sse import * # noqa: F401, F403
from tests.test_cors import * # noqa: F401, F403

View File

@@ -45,6 +45,38 @@ class TestAuth(unittest.TestCase):
b'foo:baz').decode()}))
self.assertEqual(res.status_code, 401)
def test_basic_optional_auth(self):
app = Microdot()
basic_auth = BasicAuth()
@basic_auth.authenticate
def authenticate(request, username, password):
if username == 'foo' and password == 'bar':
return {'username': username}
@app.route('/')
@basic_auth.optional
def index(request):
return request.g.current_user['username'] \
if request.g.current_user else ''
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
res = self._run(client.get('/', headers={
'Authorization': 'Basic ' + binascii.b2a_base64(
b'foo:bar').decode()}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'foo')
res = self._run(client.get('/', headers={
'Authorization': 'Basic ' + binascii.b2a_base64(
b'foo:baz').decode()}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
def test_token_auth(self):
app = Microdot()
token_auth = TokenAuth()
@@ -67,7 +99,7 @@ class TestAuth(unittest.TestCase):
'Authorization': 'Basic foo'}))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={'Authorization': 'foo'}))
res = self._run(client.get('/', headers={'Authorization': 'invalid'}))
self.assertEqual(res.status_code, 401)
res = self._run(client.get('/', headers={
@@ -75,6 +107,39 @@ class TestAuth(unittest.TestCase):
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'user')
def test_token_optional_auth(self):
app = Microdot()
token_auth = TokenAuth()
@token_auth.authenticate
def authenticate(request, token):
if token == 'foo':
return 'user'
@app.route('/')
@token_auth.optional
def index(request):
return request.g.current_user or ''
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
res = self._run(client.get('/', headers={
'Authorization': 'Basic foo'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
res = self._run(client.get('/', headers={'Authorization': 'foo'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, '')
res = self._run(client.get('/', headers={
'Authorization': 'Bearer foo'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'user')
def test_token_auth_custom_header(self):
app = Microdot()
token_auth = TokenAuth(header='X-Auth-Token')

View File

@@ -771,7 +771,7 @@ class TestMicrodot(unittest.TestCase):
client = TestClient(app)
res = self._run(client.get('/'))
self.assertEqual(res, None)
self.assertEqual(res.body, None)
def test_mount(self):
subapp = Microdot()

223
tests/test_multipart.py Normal file
View File

@@ -0,0 +1,223 @@
import asyncio
import os
import unittest
from microdot import Microdot
from microdot.multipart import with_form_data, FileUpload, FormDataIter
from microdot.test_client import TestClient
class TestMultipart(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_simple_form(self):
app = Microdot()
@app.post('/sync')
@with_form_data
def sync_route(req):
return dict(req.form)
@app.post('/async')
@with_form_data
async def async_route(req):
return dict(req.form)
client = TestClient(app)
res = self._run(client.post(
'/sync', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="baz"\r\n\r\nbaz\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {'foo': 'bar', 'baz': 'baz'})
res = self._run(client.post(
'/async', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="baz"\r\n\r\nbaz\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {'foo': 'bar', 'baz': 'baz'})
def test_form_with_files(self):
saved_max_memory_size = FileUpload.max_memory_size
FileUpload.max_memory_size = 5
app = Microdot()
@app.post('/async')
@with_form_data
async def async_route(req):
d = dict(req.form)
for name, file in req.files.items():
d[name] = '{}|{}|{}'.format(file.filename, file.content_type,
(await file.read()).decode())
return d
client = TestClient(app)
res = self._run(client.post(
'/async', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="f"; filename="f"\r\n'
b'Content-Type: text/plain\r\n\r\nbaz\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="g"; filename="g"\r\n'
b'Content-Type: text/html\r\n\r\n<p>hello</p>\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="x"\r\n\r\ny\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {'foo': 'bar', 'x': 'y',
'f': 'f|text/plain|baz',
'g': 'g|text/html|<p>hello</p>'})
FileUpload.max_memory_size = saved_max_memory_size
def test_large_file_upload(self):
saved_buffer_size = FormDataIter.buffer_size
FormDataIter.buffer_size = 100
saved_max_memory_size = FileUpload.max_memory_size
FileUpload.max_memory_size = 200
app = Microdot()
@app.post('/')
@with_form_data
async def index(req):
return {"len": len(await req.files['f'].read())}
client = TestClient(app)
res = self._run(client.post(
'/', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="f"; filename="f"\r\n'
b'Content-Type: text/plain\r\n\r\n' + b'*' * 398 + b'\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {'len': 398})
FormDataIter.buffer_size = saved_buffer_size
FileUpload.max_memory_size = saved_max_memory_size
def test_file_save(self):
app = Microdot()
@app.post('/async')
@with_form_data
async def async_route(req):
for _, file in req.files.items():
await file.save('_x.txt')
client = TestClient(app)
res = self._run(client.post(
'/async', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="f"; filename="f"\r\n'
b'Content-Type: text/plain\r\n\r\nbaz\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 204)
with open('_x.txt', 'rb') as f:
self.assertEqual(f.read(), b'baz')
os.unlink('_x.txt')
def test_no_form(self):
app = Microdot()
@app.post('/async')
@with_form_data
async def async_route(req):
return str(req.form)
client = TestClient(app)
res = self._run(client.post('/async', body={'foo': 'bar'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.text, 'None')
def test_upload_iterator(self):
app = Microdot()
@app.post('/async')
async def async_route(req):
d = {}
async for name, value in FormDataIter(req):
if isinstance(value, FileUpload):
d[name] = '{}|{}|{}'.format(value.filename,
value.content_type,
(await value.read(4)).decode())
else:
d[name] = value
return d
client = TestClient(app)
res = self._run(client.post(
'/async', headers={
'Content-Type': 'multipart/form-data; boundary=boundary',
},
body=(
b'--boundary\r\n'
b'Content-Disposition: form-data; name="foo"\r\n\r\nbar\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="f"; filename="f"\r\n'
b'Content-Type: text/plain\r\n\r\nbaz\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="g"; filename="g.h"\r\n'
b'Content-Type: text/html\r\n\r\n<p>hello</p>\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="x"\r\n\r\ny\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="h"; filename="hh"\r\n'
b'Content-Type: text/plain\r\n\r\nyy' + (b'z' * 500) + b'\r\n'
b'--boundary\r\n'
b'Content-Disposition: form-data; name="i"; filename="i.1"\r\n'
b'Content-Type: text/plain\r\n\r\n1234\r\n'
b'--boundary--\r\n')
))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json, {
'foo': 'bar',
'f': 'f|text/plain|baz',
'g': 'g.h|text/html|<p>h',
'x': 'y',
'h': 'hh|text/plain|yyzz',
'i': 'i.1|text/plain|1234',
})

View File

@@ -35,16 +35,17 @@ class TestRequest(unittest.TestCase):
def test_headers(self):
fd = get_async_request_fd('GET', '/foo', headers={
'Content-Type': 'application/json',
'Cookie': 'foo=bar;abc=def',
'Cookie': 'foo=bar;nothing;abc=def;',
'Content-Length': '3'}, body='aaa')
req = self._run(Request.create('app', fd, 'writer', 'addr'))
self.assertEqual(req.headers, {
'Host': 'example.com:1234',
'Content-Type': 'application/json',
'Cookie': 'foo=bar;abc=def',
'Cookie': 'foo=bar;nothing;abc=def;',
'Content-Length': '3'})
self.assertEqual(req.content_type, 'application/json')
self.assertEqual(req.cookies, {'foo': 'bar', 'abc': 'def'})
self.assertEqual(req.cookies, {'foo': 'bar', 'nothing': '',
'abc': 'def', '': ''})
self.assertEqual(req.content_length, 3)
self.assertEqual(req.body, b'aaa')

View File

@@ -42,3 +42,40 @@ class TestWebSocket(unittest.TestCase):
'data: [42, "foo", "bar"]\n\n'
'data: foo\n\n'
'data: foo\n\n'))
self.assertEqual(len(response.events), 8)
self.assertEqual(response.events[0], {
'data': b'foo', 'data_json': None, 'event': None,
'event_id': None})
self.assertEqual(response.events[1], {
'data': b'bar', 'data_json': None, 'event': 'test',
'event_id': None})
self.assertEqual(response.events[2], {
'data': b'bar', 'data_json': None, 'event': 'test',
'event_id': 'id42'})
self.assertEqual(response.events[3], {
'data': b'bar', 'data_json': None, 'event': None,
'event_id': 'id42'})
self.assertEqual(response.events[4], {
'data': b'{"foo": "bar"}', 'data_json': {'foo': 'bar'},
'event': None, 'event_id': None})
self.assertEqual(response.events[5], {
'data': b'[42, "foo", "bar"]', 'data_json': [42, 'foo', 'bar'],
'event': None, 'event_id': None})
self.assertEqual(response.events[6], {
'data': b'foo', 'data_json': None, 'event': None,
'event_id': None})
self.assertEqual(response.events[7], {
'data': b'foo', 'data_json': None, 'event': None,
'event_id': None})
def test_sse_exception(self):
app = Microdot()
@app.route('/sse')
@with_sse
async def handle_sse(request, sse):
await sse.send('foo')
await sse.send(1 / 0)
client = TestClient(app)
self.assertRaises(ZeroDivisionError, self._run, client.get('/sse'))

View File

@@ -119,5 +119,30 @@ class TestURLPattern(unittest.TestCase):
self.assertIsNone(p.match('/foo/abc/def/123/test'))
def test_invalid_url_patterns(self):
self.assertRaises(ValueError, URLPattern, '/users/<foo/bar')
self.assertRaises(ValueError, URLPattern, '/users/<badtype:id>')
p = URLPattern('/users/<foo/bar')
self.assertRaises(ValueError, p.compile)
p = URLPattern('/users/<badtype:id>')
self.assertRaises(ValueError, p.compile)
def test_custom_url_pattern(self):
URLPattern.register_type('hex', '[0-9a-f]+')
p = URLPattern('/users/<hex:id>')
self.assertEqual(p.match('/users/a1'), {'id': 'a1'})
self.assertIsNone(p.match('/users/ab12z'))
URLPattern.register_type('hex', '[0-9a-f]+',
parser=lambda value: int(value, 16))
p = URLPattern('/users/<hex:id>')
self.assertEqual(p.match('/users/a1'), {'id': 161})
self.assertIsNone(p.match('/users/ab12z'))
def hex_parser(value):
try:
return int(value, 16)
except ValueError:
return None
URLPattern.register_type('hex', parser=hex_parser)
p = URLPattern('/users/<hex:id>')
self.assertEqual(p.match('/users/a1'), {'id': 161})
self.assertIsNone(p.match('/users/ab12z'))

View File

@@ -1,5 +1,5 @@
import unittest
from microdot.microdot import urlencode, urldecode_str, urldecode_bytes
from microdot.microdot import urlencode, urldecode
class TestURLEncode(unittest.TestCase):
@@ -7,5 +7,7 @@ class TestURLEncode(unittest.TestCase):
self.assertEqual(urlencode('?foo=bar&x'), '%3Ffoo%3Dbar%26x')
def test_urldecode(self):
self.assertEqual(urldecode_str('%3Ffoo%3Dbar%26x'), '?foo=bar&x')
self.assertEqual(urldecode_bytes(b'%3Ffoo%3Dbar%26x'), '?foo=bar&x')
self.assertEqual(urldecode('%3Ffoo%3Dbar%26x'), '?foo=bar&x')
self.assertEqual(urldecode(b'%3Ffoo%3Dbar%26x'), '?foo=bar&x')
self.assertEqual(urldecode('dot%e2%80%a2dot'), 'dot•dot')
self.assertEqual(urldecode(b'dot%e2%80%a2dot'), 'dot•dot')

View File

@@ -46,11 +46,11 @@ class TestWebSocket(unittest.TestCase):
client = TestClient(app)
res = self._run(client.websocket('/echo', ws))
self.assertIsNone(res)
self.assertIsNone(res.body)
self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537])
res = self._run(client.websocket('/divzero', ws))
self.assertIsNone(res)
self.assertIsNone(res.body)
WebSocket.max_message_length = -1
@unittest.skipIf(sys.implementation.name == 'micropython',
@@ -74,7 +74,7 @@ class TestWebSocket(unittest.TestCase):
client = TestClient(app)
res = self._run(client.websocket('/echo', ws))
self.assertIsNone(res)
self.assertIsNone(res.body)
self.assertEqual(results, [])
Request.max_body_length = saved_max_body_length

View File

@@ -1,16 +1,17 @@
[tox]
envlist=flake8,py38,py39,py310,py311,py312,upy,cpy,benchmark,docs
envlist=flake8,py38,py39,py310,py311,py312,py313,py314upy,cpy,benchmark,docs
skipsdist=True
skip_missing_interpreters=True
[gh-actions]
python =
3.7: py37
3.8: py38
3.9: py39
3.10: py310
3.11: py311
3.12: py312
3.13: py313
3.14: py314
pypy3: pypy3
[testenv]