Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2d2896c3 | ||
|
|
38f5a27b33 | ||
|
|
d0808efa6b | ||
|
|
ce9de6e37a | ||
|
|
d61785b2e8 | ||
|
|
680cbefc19 | ||
|
|
2d4189100a | ||
|
|
27fc03f100 | ||
|
|
f70c524fb0 | ||
|
|
79897e7980 | ||
|
|
7edc7c3a38 |
12
CHANGES.md
12
CHANGES.md
@@ -1,5 +1,17 @@
|
||||
# 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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "microdot"
|
||||
version = "2.3.4"
|
||||
version = "2.4.0"
|
||||
authors = [
|
||||
{ name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
|
||||
]
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
|
||||
send_file, URLPattern, AsyncBytesIO, iscoroutine # noqa: F401
|
||||
|
||||
__version__ = '2.4.0'
|
||||
|
||||
@@ -130,6 +130,8 @@ class Microdot(BaseMicrodot):
|
||||
try:
|
||||
while not cancelled: # pragma: no branch
|
||||
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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user