Compare commits

..

30 Commits

Author SHA1 Message Date
Miguel Grinberg
eb5e249e34 Release 2.3.3 2025-07-01 23:46:00 +01:00
Miguel Grinberg
9bc3dced6c Handle partial reads in WebSocket class (Fixes #294) 2025-06-30 18:32:21 +01:00
Miguel Grinberg
786e5e5337 Additional documentation for the URLPattern class 2025-06-30 18:23:46 +01:00
Ozuba
1d419ce59b Add svg to supported mimetypes (#302) 2025-06-30 12:24:24 +01:00
Miguel Grinberg
7c98c4589d Additional documentation on WebSocket and SSE disconnections 2025-06-28 11:01:22 +01:00
Miguel Grinberg
0f219fd494 fix linter errors #nolog 2025-06-28 10:48:20 +01:00
Miguel Grinberg
e146e2d08d More detailed documentation for current_user 2025-06-28 10:40:59 +01:00
Miguel Grinberg
dc61470fa9 More detailed documentation for route responses 2025-06-28 10:40:30 +01:00
Miguel Grinberg
d7a9c53563 Add a sub-application example 2025-06-20 23:59:04 +01:00
dependabot[bot]
4ddb09ceb3 Bump urllib3 from 2.2.2 to 2.5.0 in /examples/benchmark (#301) #nolog
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.2.2 to 2.5.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.2.2...2.5.0)

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-22 12:41:50 +00:00
Miguel Grinberg
1aa035378e Updates to change log #nolog 2025-03-22 12:40:27 +00:00
Miguel Grinberg
1edfb8daa7 Version 2.2.1.dev0 2025-03-22 12:37:02 +00:00
18 changed files with 467 additions and 65 deletions

View File

@@ -1,16 +1,42 @@
# Microdot change log
**Release 2.3.3** - 2025-07-01
- Handle partial reads in WebSocket class [#294](https://github.com/miguelgrinberg/microdot/issues/294) ([commit](https://github.com/miguelgrinberg/microdot/commit/9bc3dced6c1f582dde0496961d25170b448ad8d7))
- Add SVG to supported mimetypes [#302](https://github.com/miguelgrinberg/microdot/issues/302) ([commit](https://github.com/miguelgrinberg/microdot/commit/1d419ce59bf7006617109c05dc2d6fc6d1dc8235)) (thanks **Ozuba**!)
- Do not silence exceptions that occur in the SSE task ([commit](https://github.com/miguelgrinberg/microdot/commit/654a85f46b7dd7a1e94f81193c4a78a8a1e99936))
- Add Support for SSE responses in the test client ([commit](https://github.com/miguelgrinberg/microdot/commit/f5d3d931edfbacedebf5fdf938ef77c5ee910380))
- Documentation improvements for the `Request` class ([commit](https://github.com/miguelgrinberg/microdot/commit/3dffa05ffb229813156b71e10a85283bdaa26d5e))
- Additional documentation for the `URLPattern` class ([commit](https://github.com/miguelgrinberg/microdot/commit/786e5e533748e1343612c97123773aec9a1a99fc))
- More detailed documentation for route responses ([commit](https://github.com/miguelgrinberg/microdot/commit/dc61470fa959549bb43313906ba6ed9f686babc2))
- Additional documentation on WebSocket and SSE disconnections ([commit](https://github.com/miguelgrinberg/microdot/commit/7c98c4589de4774a88381b393444c75094532550))
- More detailed documentation for `current_user` ([commit](https://github.com/miguelgrinberg/microdot/commit/e146e2d08deddf9b924c7657f04db28d71f34221))
- Add a sub-application example ([commit](https://github.com/miguelgrinberg/microdot/commit/d7a9c535639268e415714b12ac898ae38e516308))
**Release 2.3.2** - 2025-05-08
- Use async error handlers in auth module [#298](https://github.com/miguelgrinberg/microdot/issues/298) ([commit](https://github.com/miguelgrinberg/microdot/commit/d9d7ff0825e4c5fbed6564d3684374bf3937df11))
**Release 2.3.1** - 2025-04-13
- Additional support needed when using `orjson` ([commit](https://github.com/miguelgrinberg/microdot/commit/cd0b3234ddb0c8ff4861d369836ec2aed77494db))
**Release 2.3.0** - 2025-04-12
- Support optional authentication methods ([commit](https://github.com/miguelgrinberg/microdot/commit/f317b15bdbf924007e5e3414e0c626baccc3ede6))
- Catch SSL exceptions while writing the response [#206](https://github.com/miguelgrinberg/microdot/issues/206) ([commit](https://github.com/miguelgrinberg/microdot/commit/e7ee74d6bba74cfd89b9ddc38f28e02514eb1791))
- Use `orjson` instead of `json` if available ([commit](https://github.com/miguelgrinberg/microdot/commit/086f2af3deab86d4340f3f1feb9e019de59f351d))
- Addressed typing warnings from pyright ([commit](https://github.com/miguelgrinberg/microdot/commit/b6f232db1125045d79c444c736a2ae59c5501fdd))
**Release 2.2.0** - 2025-03-22
- Support for multipart/form-data requests [#287](https://github.com/miguelgrinberg/microdot/issues/287) ([commit](https://github.com/miguelgrinberg/microdot/commit/11a91a60350518e426b557fae8dffe75912f8823))
- Support custom path components in URLs ([commit](https://github.com/miguelgrinberg/microdot/commit/c92b5ae28222af5a1094f5d2f70a45d4d17653d5))
- Expose the Jinja environment as Template.jinja_env ([commit](https://github.com/miguelgrinberg/microdot/commit/953dd9432122defe943f0637bbe7e01f2fc7743f))
- Delay route compilation to allow late register_type calls ([commit](https://github.com/miguelgrinberg/microdot/commit/aa76e6378b37faab52008a8aab8db75f81b29323))
- urldecoding should always be done in bytes ([commit](https://github.com/miguelgrinberg/microdot/commit/d203df75fef32c7cc0fe7cc6525e77522b37a289))
- Simplified urldecode logic ([commit](https://github.com/miguelgrinberg/microdot/commit/3bc31f10b2b2d4460c62366013278d87665f0f97))
- Support for `multipart/form-data` requests [#287](https://github.com/miguelgrinberg/microdot/issues/287) ([commit](https://github.com/miguelgrinberg/microdot/commit/11a91a60350518e426b557fae8dffe75912f8823))
- Support custom path components in URLs ([commit #1](https://github.com/miguelgrinberg/microdot/commit/c92b5ae28222af5a1094f5d2f70a45d4d17653d5) [commit #2](https://github.com/miguelgrinberg/microdot/commit/aa76e6378b37faab52008a8aab8db75f81b29323))
- Expose the Jinja environment as `Template.jinja_env` ([commit](https://github.com/miguelgrinberg/microdot/commit/953dd9432122defe943f0637bbe7e01f2fc7743f))
- Simplified urldecode logic ([commit #1](https://github.com/miguelgrinberg/microdot/commit/3bc31f10b2b2d4460c62366013278d87665f0f97) [commit #2](https://github.com/miguelgrinberg/microdot/commit/d203df75fef32c7cc0fe7cc6525e77522b37a289))
- Additional urldecode tests ([commit](https://github.com/miguelgrinberg/microdot/commit/99f65c0198590c0dfb402c24685b6f8dfba1935d))
- Update micropython version used in tests to 1.24.1 ([commit](https://github.com/miguelgrinberg/microdot/commit/4cc2e95338a7de3b03742389004147ee21285621))
- Documentation improvements ([commit](https://github.com/miguelgrinberg/microdot/commit/c6b99b6d8117d4e40e16d5b953dbf4deb023d24d))
- Update micropython version used in tests to 1.24.1 ([commit](https://github.com/miguelgrinberg/microdot/commit/4cc2e95338a7de3b03742389004147ee21285621))
**Release 2.1.0** - 2025-02-04

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
@@ -414,10 +460,25 @@ decorator::
While running an authenticated request, the user object returned by the
authenticaction function is accessible as ``request.g.current_user``.
If an endpoint is intended to work with or without authentication, then it can
be protected with the ``auth.optional`` decorator::
@app.route('/')
@auth.optional
async def index(request):
if request.g.current_user:
return f'Hello, {request.g.current_user}!'
else:
return 'Hello, anonymous user!'
As shown in the example, a route can check ``request.g.current_user`` to
determine if the user is authenticated or not.
Token Authentication
^^^^^^^^^^^^^^^^^^^^
To set up token authentication, create an instance of :class:`TokenAuth <microdot.auth.TokenAuth>`::
To set up token authentication, create an instance of
:class:`TokenAuth <microdot.auth.TokenAuth>`::
from microdot.auth import TokenAuth
@@ -431,13 +492,24 @@ or ``None`` if the token is invalid or expired::
return load_user_from_token(token)
As with Basic authentication, the ``auth`` instance is used as a decorator to
protect your routes::
protect your routes, and the authenticated user is accessible from the request
object as ``request.g.current_user``::
@app.route('/')
@auth
async def index(request):
return f'Hello, {request.g.current_user}!'
Optional authentication can also be used with tokens::
@app.route('/')
@auth.optional
async def index(request):
if request.g.current_user:
return f'Hello, {request.g.current_user}!'
else:
return 'Hello, anonymous user!'
User Logins
~~~~~~~~~~~

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

@@ -32,9 +32,9 @@ flask==3.0.0
# via
# -r requirements.in
# quart
gunicorn==22.0.0
gunicorn==23.0.0
# via -r requirements.in
h11==0.14.0
h11==0.16.0
# via
# hypercorn
# uvicorn
@@ -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 @@
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.2.0"
version = "2.3.3"
authors = [
{ name = "Miguel Grinberg", email = "miguel.grinberg@gmail.com" },
]

View File

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

View File

@@ -7,10 +7,14 @@ servers for MicroPython and standard Python.
"""
import asyncio
import io
import json
import re
import time
try:
import orjson as json
except ImportError:
import json
try:
from inspect import iscoroutinefunction, iscoroutine
from functools import partial
@@ -550,6 +554,7 @@ class Response:
'json': 'application/json',
'png': 'image/png',
'txt': 'text/plain',
'svg': 'image/svg+xml',
}
send_file_buffer_size = 1024
@@ -574,9 +579,9 @@ class Response:
self.headers = NoCaseDict(headers or {})
self.reason = reason
if isinstance(body, (dict, list)):
self.body = json.dumps(body).encode()
body = json.dumps(body)
self.headers['Content-Type'] = 'application/json; charset=UTF-8'
elif isinstance(body, str):
if isinstance(body, str):
self.body = body.encode()
else:
# this applies to bytes, file-like objects or generators
@@ -810,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+)',
@@ -819,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] == '<':
@@ -852,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:
@@ -1336,9 +1372,9 @@ class Microdot:
print_exception(exc)
res = await self.dispatch_request(req)
if res != Response.already_handled: # pragma: no branch
await res.write(writer)
try:
if res != Response.already_handled: # pragma: no branch
await res.write(writer)
await writer.aclose()
except OSError as exc: # pragma: no cover
if exc.errno in MUTED_SOCKET_ERRORS:

View File

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

View File

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

View File

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

View File

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

View File

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

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

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