Compare commits

...

11 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
12 changed files with 82 additions and 30 deletions

View File

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

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

@@ -1,6 +1,6 @@
[project]
name = "microdot"
version = "2.3.4"
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

@@ -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})

View File

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

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

@@ -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()