Compare commits

...

19 Commits

Author SHA1 Message Date
Miguel Grinberg
4c2d2896c3 Release 2.4.0 2025-11-08 12:28:33 +00:00
Miguel Grinberg
38f5a27b33 adding version to __init__.py file (Fixes #312) 2025-11-08 12:19:32 +00:00
Miguel Grinberg
d0808efa6b SSE: add support for retry and comments 2025-11-08 12:01:05 +00:00
Miguel Grinberg
ce9de6e37a Ignore "muted" errors during request creation 2025-11-07 00:05:21 +00:00
Miguel Grinberg
d61785b2e8 Ignore expires and max_age arguments if passed to Response.delete_cookie (Fixes #323) 2025-11-04 10:03:39 +00:00
Miguel Grinberg
680cbefc19 Version 2.3.6.dev0 2025-10-18 00:11:10 +01:00
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
27 changed files with 604 additions and 47 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,26 @@
# Microdot change log
**Release 2.4.0** - 2025-11-08
- SSE: Add support for the retry command and keepalive comments ([commit](https://github.com/miguelgrinberg/microdot/commit/d0808efa6b32e00992596f1bb3d4c3a372df2168))
- Ignore `expires` and `max_age` arguments if passed to `Response.delete_cookie` [#323](https://github.com/miguelgrinberg/microdot/issues/323) ([commit](https://github.com/miguelgrinberg/microdot/commit/d61785b2e8d18438e5031de9c49e61642e5cfb3f))
- Ignore "muted" errors during request creation ([commit](https://github.com/miguelgrinberg/microdot/commit/ce9de6e37a6323664eb7666b817932f371f1e099))
- Add package version to `microdot/__init__.py` file [#312](https://github.com/miguelgrinberg/microdot/issues/312) ([commit](https://github.com/miguelgrinberg/microdot/commit/38f5a27b33c7968fc7414b67742e034e2b9a09ca))
**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))

View File

@@ -39,15 +39,15 @@ h11==0.16.0
# 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

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

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

View File

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

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

@@ -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
@@ -632,9 +632,13 @@ class Response:
"""Delete a cookie.
:param cookie: The cookie's name.
:param kwargs: Any cookie opens and flags supported by
``set_cookie()`` except ``expires`` and ``max_age``.
:param kwargs: Any cookie options and flags supported by
:meth:`set_cookie() <microdot.Response.set_cookie>`.
Values given for ``expires`` and ``max_age`` are
ignored.
"""
kwargs.pop('expires', None)
kwargs.pop('max_age', None)
self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT',
max_age=0, **kwargs)
@@ -944,7 +948,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
@@ -1368,6 +1371,11 @@ class Microdot:
try:
req = await Request.create(self, reader, writer,
writer.get_extra_info('peername'))
except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS:
pass
else:
raise
except Exception as exc: # pragma: no cover
print_exception(exc)

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

@@ -25,7 +25,10 @@ class SessionDict(dict):
class Session:
"""
:param app: The application instance.
:param key: The secret key, as a string or bytes object.
:param secret_key: The secret key, as a string or bytes object.
:param cookie_options: A dictionary with cookie options to pass as
arguments to :meth:`Response.set_cookie()
<microdot.Response.set_cookie>`.
"""
secret_key = None

View File

@@ -17,7 +17,8 @@ class SSE:
self.event = asyncio.Event()
self.queue = []
async def send(self, data, event=None, event_id=None):
async def send(self, data, event=None, event_id=None, retry=None,
comment=False):
"""Send an event to the client.
:param data: the data to send. It can be given as a string, bytes, dict
@@ -27,6 +28,12 @@ class SSE:
given, it must be a string.
:param event_id: an optional event id, to send along with the data. If
given, it must be a string.
:param retry: an optional reconnection time (in seconds) that the
client should use when the connection is lost.
:param comment: when set to ``True``, the data is sent as a comment
line, and all other parameters are ignored. This is
useful as a heartbeat mechanism that keeps the
connection alive.
"""
if isinstance(data, (dict, list)):
data = json.dumps(data)
@@ -34,11 +41,17 @@ class SSE:
data = data.encode()
elif not isinstance(data, bytes):
data = str(data).encode()
data = b'data: ' + data + b'\n\n'
if event_id:
data = b'id: ' + event_id.encode() + b'\n' + data
if event:
data = b'event: ' + event.encode() + b'\n' + data
if comment:
data = b': ' + data + b'\n\n'
else:
data = b'data: ' + data + b'\n\n'
if event_id:
data = b'id: ' + event_id.encode() + b'\n' + data
if event:
data = b'event: ' + event.encode() + b'\n' + data
if retry:
data = b'retry: ' + str(int(retry * 1000)).encode() + b'\n' + \
data
self.queue.append(data)
self.event.set()

View File

@@ -78,6 +78,7 @@ class TestResponse:
data = None
event = None
event_id = None
retry = None
for line in sse_event.split(b'\n'):
if line.startswith(b'data:'):
data = line[5:].strip()
@@ -85,6 +86,8 @@ class TestResponse:
event = line[6:].strip().decode()
elif line.startswith(b'id:'):
event_id = line[3:].strip().decode()
elif line.startswith(b'retry:'):
retry = int(line[7:].strip()) / 1000
if data:
data_json = None
try:
@@ -92,8 +95,9 @@ class TestResponse:
except ValueError:
pass
self.events.append({
"data": data, "data_json": data_json,
"event": event, "event_id": event_id})
'data': data, 'data_json': data_json,
'event': event, 'event_id': event_id,
'retry': retry})
@classmethod
async def create(cls, res):

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

@@ -204,9 +204,10 @@ class TestMicrodot(unittest.TestCase):
res.set_cookie('four', '4')
res.delete_cookie('two', path='/')
res.delete_cookie('one', path='/bad')
res.delete_cookie('five', max_age=123, expires='foo')
return res
client = TestClient(app, cookies={'one': '1', 'two': '2'})
client = TestClient(app, cookies={'one': '1', 'two': '2', 'five': '5'})
res = self._run(client.get('/', headers={'Cookie': 'three=3'}))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],

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

@@ -25,7 +25,9 @@ class TestWebSocket(unittest.TestCase):
await sse.send('bar', event='test')
await sse.send('bar', event='test', event_id='id42')
await sse.send('bar', event_id='id42')
await sse.send('bar', retry=2.5)
await sse.send({'foo': 'bar'})
await sse.send('ping', comment=True)
await sse.send([42, 'foo', 'bar'])
await sse.send(ValueError('foo'))
await sse.send(b'foo')
@@ -38,35 +40,40 @@ class TestWebSocket(unittest.TestCase):
'event: test\ndata: bar\n\n'
'event: test\nid: id42\ndata: bar\n\n'
'id: id42\ndata: bar\n\n'
'retry: 2500\ndata: bar\n\n'
'data: {"foo": "bar"}\n\n'
': ping\n\n'
'data: [42, "foo", "bar"]\n\n'
'data: foo\n\n'
'data: foo\n\n'))
self.assertEqual(len(response.events), 8)
self.assertEqual(len(response.events), 9)
self.assertEqual(response.events[0], {
'data': b'foo', 'data_json': None, 'event': None,
'event_id': None})
'event_id': None, "retry": None})
self.assertEqual(response.events[1], {
'data': b'bar', 'data_json': None, 'event': 'test',
'event_id': None})
'event_id': None, "retry": None})
self.assertEqual(response.events[2], {
'data': b'bar', 'data_json': None, 'event': 'test',
'event_id': 'id42'})
'event_id': 'id42', "retry": None})
self.assertEqual(response.events[3], {
'data': b'bar', 'data_json': None, 'event': None,
'event_id': 'id42'})
'event_id': 'id42', "retry": None})
self.assertEqual(response.events[4], {
'data': b'{"foo": "bar"}', 'data_json': {'foo': 'bar'},
'event': None, 'event_id': None})
'data': b'bar', 'data_json': None, 'event': None, 'event_id': None,
'retry': 2.5})
self.assertEqual(response.events[5], {
'data': b'[42, "foo", "bar"]', 'data_json': [42, 'foo', 'bar'],
'event': None, 'event_id': None})
'data': b'{"foo": "bar"}', 'data_json': {'foo': 'bar'},
'event': None, 'event_id': None, "retry": None})
self.assertEqual(response.events[6], {
'data': b'foo', 'data_json': None, 'event': None,
'event_id': None})
'data': b'[42, "foo", "bar"]', 'data_json': [42, 'foo', 'bar'],
'event': None, 'event_id': None, "retry": None})
self.assertEqual(response.events[7], {
'data': b'foo', 'data_json': None, 'event': None,
'event_id': None})
'event_id': None, "retry": None})
self.assertEqual(response.events[8], {
'data': b'foo', 'data_json': None, 'event': None,
'event_id': None, "retry": None})
def test_sse_exception(self):
app = Microdot()

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]