Compare commits

...

32 Commits

Author SHA1 Message Date
Miguel Grinberg
2d4189100a Release 2.3.5 2025-10-18 00:10:45 +01:00
Miguel Grinberg
27fc03f100 Remove unused instance variable in Microdot class 2025-10-18 00:09:33 +01:00
Miguel Grinberg
f70c524fb0 always encode ASGI response bodies to bytes 2025-10-18 00:05:09 +01:00
dependabot[bot]
79897e7980 Bump h2 from 4.1.0 to 4.3.0 in /examples/benchmark (#319) #nolog
Bumps [h2](https://github.com/python-hyper/h2) from 4.1.0 to 4.3.0.
- [Changelog](https://github.com/python-hyper/h2/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/python-hyper/h2/compare/v4.1.0...v4.3.0)

---
updated-dependencies:
- dependency-name: h2
  dependency-version: 4.3.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-10-16 11:17:29 +01:00
Miguel Grinberg
7edc7c3a38 Version 2.3.5.dev0 2025-10-16 00:22:40 +01:00
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
32 changed files with 847 additions and 63 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,36 @@
# Microdot change log
**Release 2.3.5** - 2025-10-18
- Always encode ASGI response bodies to bytes ([commit](https://github.com/miguelgrinberg/microdot/commit/f70c524fb0bdc8c5fef2223c82f5e339445bc5fa))
- Remove unused instance variable in `Microdot` class ([commit](https://github.com/miguelgrinberg/microdot/commit/27fc03f10047e4483f8d19559025d728b14a27c8))
**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))

View File

@@ -13,6 +13,8 @@ Core API
.. autoclass:: microdot.Response
:members:
.. autoclass:: microdot.URLPattern
:members:
Multipart Forms
---------------

View File

@@ -116,6 +116,33 @@ Example::
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
~~~~~~~~~~~~~~~~~~
@@ -153,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
@@ -420,13 +466,13 @@ be protected with the ``auth.optional`` decorator::
@app.route('/')
@auth.optional
async def index(request):
if g.current_user:
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 ``g.current_user`` to determine if
the user is authenticated or not.
As shown in the example, a route can check ``request.g.current_user`` to
determine if the user is authenticated or not.
Token Authentication
^^^^^^^^^^^^^^^^^^^^
@@ -446,7 +492,8 @@ 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
@@ -458,7 +505,7 @@ Optional authentication can also be used with tokens::
@app.route('/')
@auth.optional
async def index(request):
if g.current_user:
if request.g.current_user:
return f'Hello, {request.g.current_user}!'
else:
return 'Hello, anonymous user!'

View File

@@ -601,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
@@ -627,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
@@ -642,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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -749,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
@@ -769,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
^^^^^^^^^^^^^^

View File

@@ -34,20 +34,20 @@ flask==3.0.0
# quart
gunicorn==23.0.0
# via -r requirements.in
h11==0.14.0
h11==0.16.0
# via
# hypercorn
# uvicorn
# wsproto
h2==4.1.0
h2==4.3.0
# via hypercorn
hpack==4.0.0
hpack==4.1.0
# via h2
humanize==4.9.0
# via -r requirements.in
hypercorn==0.15.0
# via quart
hyperframe==6.0.1
hyperframe==6.1.0
# via h2
idna==3.7
# via
@@ -84,7 +84,7 @@ pyproject-hooks==1.0.0
# via build
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,6 +1,6 @@
[project]
name = "microdot"
version = "2.3.1"
version = "2.3.5"
authors = [
{ name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
]

View File

@@ -127,19 +127,19 @@ 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__()
if isinstance(res_body, str):
res_body = res_body.encode()
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

@@ -85,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)}
@@ -158,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

@@ -366,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
@@ -554,6 +554,7 @@ class Response:
'json': 'application/json',
'png': 'image/png',
'txt': 'text/plain',
'svg': 'image/svg+xml',
}
send_file_buffer_size = 1024
@@ -814,6 +815,17 @@ 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+)',
@@ -823,12 +835,32 @@ class URLPattern():
'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 = ''
for segment in self.url_pattern.lstrip('/').split('/'):
if segment and segment[0] == '<':
@@ -856,12 +888,12 @@ class URLPattern():
self.regex = re.compile('^' + pattern + '$')
return self.regex
@classmethod
def register_type(cls, type_name, pattern='[^/]+', parser=None):
cls.segment_patterns[type_name] = '/({})'.format(pattern)
cls.segment_parsers[type_name] = parser
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 = {}
g = (self.regex or self.compile()).match(path)
if not g:
@@ -912,7 +944,6 @@ class Microdot:
self.after_request_handlers = []
self.after_error_request_handlers = []
self.error_handlers = {}
self.shutdown_requested = False
self.options_handler = self.default_options_handler
self.debug = False
self.server = None

View File

@@ -31,7 +31,10 @@ class FormDataIter:
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.
#: 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):
@@ -59,6 +62,7 @@ class FormDataIter:
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
@@ -111,6 +115,9 @@ class FormDataIter:
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))

View File

@@ -61,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())
@@ -79,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,3 +1,4 @@
import asyncio
from microdot.microdot import Request, Response, AsyncBytesIO
try:
@@ -32,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
@@ -41,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()
@@ -60,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()
@@ -68,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

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

@@ -35,7 +35,7 @@ class TestASGI(unittest.TestCase):
class R:
def __init__(self):
self.i = 0
self.body = [b're', b'sp', b'on', b'se', b'']
self.body = [b're', b'sp', b'on', 'se', b'']
async def read(self, n):
data = self.body[self.i]

View File

@@ -99,6 +99,37 @@ class TestMultipart(unittest.TestCase):
'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()

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

@@ -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]