13 Commits

Author SHA1 Message Date
Miguel Grinberg
7a6026006f Release 2.0.3 2024-01-07 10:50:28 +00:00
Miguel Grinberg
6712c47400 Pass keyword arguments to thread executor in the correct way (Fixes #195) 2024-01-07 10:44:16 +00:00
Miguel Grinberg
c8c91e8345 Update uasyncio to include new TLS support 2024-01-04 20:41:05 +00:00
Miguel Grinberg
5d188e8c0d Add a limit to WebSocket message size (Fixes #193) 2024-01-03 00:04:02 +00:00
Miguel Grinberg
b80b6b64d0 Documentation improvements 2023-12-28 12:59:19 +00:00
Miguel Grinberg
28007ea583 Version 2.0.3.dev0 2023-12-28 12:11:32 +00:00
Miguel Grinberg
300f8563ed Release 2.0.2 2023-12-28 12:10:46 +00:00
Miguel Grinberg
1fc11193da Support binary data in the SSE extension 2023-12-28 12:04:17 +00:00
Miguel Grinberg
79452a4699 Upgrade micropython tests to use v1.22, initial circuitpython work 2023-12-27 20:39:20 +00:00
Miguel Grinberg
84842e39c3 Improvements to migration guide 2023-12-26 20:00:07 +00:00
Miguel Grinberg
2a3c889717 typo in documentation #nolog 2023-12-26 17:07:20 +00:00
Tak Tran
ad368be993 Remove spurious async in documentation example (#187) 2023-12-23 14:08:12 +00:00
Miguel Grinberg
3df56c6ffe Version 2.0.2.dev0 2023-12-23 12:50:03 +00:00
26 changed files with 302 additions and 192 deletions

View File

@@ -16,6 +16,7 @@ jobs:
- run: python -m pip install --upgrade pip wheel - run: python -m pip install --upgrade pip wheel
- run: pip install tox tox-gh-actions - run: pip install tox tox-gh-actions
- run: tox -eflake8 - run: tox -eflake8
- run: tox -edocs
tests: tests:
name: tests name: tests
strategy: strategy:

View File

@@ -1,5 +1,19 @@
# Microdot change log # Microdot change log
**Release 2.0.3** - 2024-01-07
- Add a limit to WebSocket message size [#193](https://github.com/miguelgrinberg/microdot/issues/193) ([commit](https://github.com/miguelgrinberg/microdot/commit/5d188e8c0ddef6ce633ca702dbdd4a90f2799597))
- Pass keyword arguments to thread executor in the correct way [#195](https://github.com/miguelgrinberg/microdot/issues/195) ([commit](https://github.com/miguelgrinberg/microdot/commit/6712c47400d7c426c88032f65ab74466524eccab))
- Update uasyncio library used in tests to include new TLS support ([commit](https://github.com/miguelgrinberg/microdot/commit/c8c91e83457d24320f22c9a74e80b15e06b072ca))
- Documentation improvements ([commit](https://github.com/miguelgrinberg/microdot/commit/b80b6b64d02d21400ca8a5077f5ed1127cc202ae))
**Release 2.0.2** - 2023-12-28
- Support binary data in the SSE extension ([commit](https://github.com/miguelgrinberg/microdot/commit/1fc11193da0d298f5539e2ad218836910a13efb2))
- Upgrade micropython tests to use v1.22 + initial CircuitPython testing work ([commit](https://github.com/miguelgrinberg/microdot/commit/79452a46992351ccad2c0317c20bf50be0d76641))
- Improvements to migration guide ([commit](https://github.com/miguelgrinberg/microdot/commit/84842e39c360a8b3ddf36feac8af201fb19bbb0b))
- Remove spurious async in documentation example [#187](https://github.com/miguelgrinberg/microdot/issues/187) ([commit](https://github.com/miguelgrinberg/microdot/commit/ad368be993e2e3007579f1d3880e36d60c71da92)) (thanks **Tak Tran**!)
**Release 2.0.1** - 2023-12-23 **Release 2.0.1** - 2023-12-23
- Addressed some inadvertent mistakes in the template extensions ([commit](https://github.com/miguelgrinberg/microdot/commit/bd18ceb4424e9dfb52b1e6d498edd260aa24fc53)) - Addressed some inadvertent mistakes in the template extensions ([commit](https://github.com/miguelgrinberg/microdot/commit/bd18ceb4424e9dfb52b1e6d498edd260aa24fc53))

Binary file not shown.

View File

@@ -1,8 +1,8 @@
API Reference API Reference
============= =============
``microdot`` module Core API
------------------- --------
.. autoclass:: microdot.Microdot .. autoclass:: microdot.Microdot
:members: :members:
@@ -14,51 +14,57 @@ API Reference
:members: :members:
``websocket`` extension WebSocket
----------------------- ---------
.. automodule:: microdot.websocket .. automodule:: microdot.websocket
:members: :members:
``utemplate`` templating extension Server-Sent Events (SSE)
---------------------------------- ------------------------
.. automodule:: microdot.sse
:members:
Templates (uTemplate)
---------------------
.. automodule:: microdot.utemplate .. automodule:: microdot.utemplate
:members: :members:
``jinja`` templating extension Templates (Jinja)
------------------------------ -----------------
.. automodule:: microdot.jinja .. automodule:: microdot.jinja
:members: :members:
``session`` extension User Sessions
--------------------- -------------
.. automodule:: microdot.session .. automodule:: microdot.session
:members: :members:
``cors`` extension Cross-Origin Resource Sharing (CORS)
------------------ ------------------------------------
.. automodule:: microdot.cors .. automodule:: microdot.cors
:members: :members:
``test_client`` extension Test Client
------------------------- -----------
.. automodule:: microdot.test_client .. automodule:: microdot.test_client
:members: :members:
``asgi`` extension ASGI
------------------ ----
.. autoclass:: microdot.asgi.Microdot .. autoclass:: microdot.asgi.Microdot
:members: :members:
:exclude-members: shutdown, run :exclude-members: shutdown, run
``wsgi`` extension WSGI
------------------- ----
.. autoclass:: microdot.wsgi.Microdot .. autoclass:: microdot.wsgi.Microdot
:members: :members:

View File

@@ -25,14 +25,15 @@ and incorporated into a custom MicroPython firmware.
Use the following guidelines to know what files to copy: Use the following guidelines to know what files to copy:
- For a minimal setup with only the base web server functionality, copy * For a minimal setup with only the base web server functionality, copy
`microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_ `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
into your project. into your project.
- For a configuration that includes one or more optional extensions, create a * For a configuration that includes one or more optional extensions, create a
*microdot* directory in your device and copy the following files: *microdot* directory in your device and copy the following files:
- `__init__.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/__init__.py>`_
- `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_ * `__init__.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/__init__.py>`_
- any needed `extensions <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot>`_. * `microdot.py <https://github.com/miguelgrinberg/microdot/blob/main/src/microdot/microdot.py>`_
* any needed `extensions <https://github.com/miguelgrinberg/microdot/tree/main/src/microdot>`_.
Getting Started Getting Started
@@ -171,10 +172,8 @@ configure the web server.
app.run(port=4443, debug=True, ssl=sslctx) app.run(port=4443, debug=True, ssl=sslctx)
.. note:: .. note::
The ``ssl`` argument can only be used with CPython at this time, because When using CPython, the certificate and key files must be given in PEM
MicroPython's asyncio module does not currently support SSL certificates or format. When using MicroPython, these files must be given in DER format.
TLS encryption. Work on this is
`in progress <https://github.com/micropython/micropython/pull/11897>`_.
Defining Routes Defining Routes
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
@@ -297,7 +296,7 @@ match and the route will not be called.
A special type ``path`` can be used to capture the remainder of the path as a A special type ``path`` can be used to capture the remainder of the path as a
single argument. The difference between an argument of type ``path`` and one of single argument. The difference between an argument of type ``path`` and one of
type ``string`` is that the latter stops capturing when a ``/`` appears in the type ``string`` is that the latter stops capturing when a ``/`` appears in the
URL. URL::
@app.get('/tests/<path:path>') @app.get('/tests/<path:path>')
async def get_test(request, path): async def get_test(request, path):
@@ -462,7 +461,7 @@ the sub-applications to build the larger combined application::
from customers import customers_app from customers import customers_app
from orders import orders_app from orders import orders_app
async def create_app(): def create_app():
app = Microdot() app = Microdot()
app.mount(customers_app, url_prefix='/customers') app.mount(customers_app, url_prefix='/customers')
app.mount(orders_app, url_prefix='/orders') app.mount(orders_app, url_prefix='/orders')

View File

@@ -39,7 +39,7 @@ extension.
Any applications built using the asyncio extension will need to update their Any applications built using the asyncio extension will need to update their
imports from this:: imports from this::
from microdot.asyncio import Microdot from microdot_asyncio import Microdot
to this:: to this::
@@ -94,7 +94,7 @@ as a single string::
Streamed templates also have an asynchronous version:: Streamed templates also have an asynchronous version::
return await Template('index.html').generate_async(title='Home') return Template('index.html').generate_async(title='Home')
Class-based user sessions Class-based user sessions
~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -138,5 +138,8 @@ deployed with standard WSGI servers such as Gunicorn.
WebSocket support when using the WSGI extension is enabled when using a WebSocket support when using the WSGI extension is enabled when using a
compatible web server. At this time only Gunicorn is supported for WebSocket. compatible web server. At this time only Gunicorn is supported for WebSocket.
Given that WebSocket support is asynchronous, it would be better to switch to
the ASGI extension, which has full support for WebSocket as defined in the ASGI
specification.
As before, the WSGI extension is not available under MicroPython. As before, the WSGI extension is not available under MicroPython.

View File

@@ -1,4 +1,5 @@
import ssl import ssl
import sys
from microdot import Microdot from microdot import Microdot
app = Microdot() app = Microdot()
@@ -31,6 +32,7 @@ async def shutdown(request):
return 'The server is shutting down...' return 'The server is shutting down...'
ext = 'der' if sys.implementation.name == 'micropython' else 'pem'
sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) sslctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sslctx.load_cert_chain('cert.pem', 'key.pem') sslctx.load_cert_chain('cert.' + ext, 'key.' + ext)
app.run(port=4443, debug=True, ssl=sslctx) app.run(port=4443, debug=True, ssl=sslctx)

View File

@@ -13,12 +13,6 @@ class Stream:
def get_extra_info(self, v): def get_extra_info(self, v):
return self.e[v] return self.e[v]
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self.close()
def close(self): def close(self):
pass pass
@@ -63,6 +57,8 @@ class Stream:
while True: while True:
yield core._io_queue.queue_read(self.s) yield core._io_queue.queue_read(self.s)
l2 = self.s.readline() # may do multiple reads but won't block l2 = self.s.readline() # may do multiple reads but won't block
if l2 is None:
continue
l += l2 l += l2
if not l2 or l[-1] == 10: # \n (check l in case l2 is str) if not l2 or l[-1] == 10: # \n (check l in case l2 is str)
return l return l
@@ -100,19 +96,29 @@ StreamWriter = Stream
# Create a TCP stream connection to a remote host # Create a TCP stream connection to a remote host
# #
# async # async
def open_connection(host, port): def open_connection(host, port, ssl=None, server_hostname=None):
from errno import EINPROGRESS from errno import EINPROGRESS
import socket import socket
ai = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0] # TODO this is blocking! ai = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)[0] # TODO this is blocking!
s = socket.socket(ai[0], ai[1], ai[2]) s = socket.socket(ai[0], ai[1], ai[2])
s.setblocking(False) s.setblocking(False)
ss = Stream(s)
try: try:
s.connect(ai[-1]) s.connect(ai[-1])
except OSError as er: except OSError as er:
if er.errno != EINPROGRESS: if er.errno != EINPROGRESS:
raise er raise er
# wrap with SSL, if requested
if ssl:
if ssl is True:
import ssl as _ssl
ssl = _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT)
if not server_hostname:
server_hostname = host
s = ssl.wrap_socket(s, server_hostname=server_hostname, do_handshake_on_connect=False)
s.setblocking(False)
ss = Stream(s)
yield core._io_queue.queue_write(s) yield core._io_queue.queue_write(s)
return ss, ss return ss, ss
@@ -135,7 +141,7 @@ class Server:
async def wait_closed(self): async def wait_closed(self):
await self.task await self.task
async def _serve(self, s, cb): async def _serve(self, s, cb, ssl):
self.state = False self.state = False
# Accept incoming connections # Accept incoming connections
while True: while True:
@@ -156,6 +162,13 @@ class Server:
except: except:
# Ignore a failed accept # Ignore a failed accept
continue continue
if ssl:
try:
s2 = ssl.wrap_socket(s2, server_side=True, do_handshake_on_connect=False)
except OSError as e:
core.sys.print_exception(e)
s2.close()
continue
s2.setblocking(False) s2.setblocking(False)
s2s = Stream(s2, {"peername": addr}) s2s = Stream(s2, {"peername": addr})
core.create_task(cb(s2s, s2s)) core.create_task(cb(s2s, s2s))
@@ -163,7 +176,7 @@ class Server:
# Helper function to start a TCP stream server, running as a new task # Helper function to start a TCP stream server, running as a new task
# TODO could use an accept-callback on socket read activity instead of creating a task # TODO could use an accept-callback on socket read activity instead of creating a task
async def start_server(cb, host, port, backlog=5): async def start_server(cb, host, port, backlog=5, ssl=None):
import socket import socket
# Create and bind server socket. # Create and bind server socket.
@@ -176,7 +189,7 @@ async def start_server(cb, host, port, backlog=5):
# Create and return server object and task. # Create and return server object and task.
srv = Server() srv = Server()
srv.task = core.create_task(srv._serve(s, cb)) srv.task = core.create_task(srv._serve(s, cb, ssl))
try: try:
# Ensure that the _serve task has been scheduled so that it gets to # Ensure that the _serve task has been scheduled so that it gets to
# handle cancellation. # handle cancellation.

View File

@@ -1,79 +0,0 @@
from utime import *
from micropython import const
_TS_YEAR = const(0)
_TS_MON = const(1)
_TS_MDAY = const(2)
_TS_HOUR = const(3)
_TS_MIN = const(4)
_TS_SEC = const(5)
_TS_WDAY = const(6)
_TS_YDAY = const(7)
_TS_ISDST = const(8)
_WDAY = const(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))
_MDAY = const(
(
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
)
)
def strftime(datefmt, ts):
from io import StringIO
fmtsp = False
ftime = StringIO()
for k in datefmt:
if fmtsp:
if k == "a":
ftime.write(_WDAY[ts[_TS_WDAY]][0:3])
elif k == "A":
ftime.write(_WDAY[ts[_TS_WDAY]])
elif k == "b":
ftime.write(_MDAY[ts[_TS_MON] - 1][0:3])
elif k == "B":
ftime.write(_MDAY[ts[_TS_MON] - 1])
elif k == "d":
ftime.write("%02d" % ts[_TS_MDAY])
elif k == "H":
ftime.write("%02d" % ts[_TS_HOUR])
elif k == "I":
ftime.write("%02d" % (ts[_TS_HOUR] % 12))
elif k == "j":
ftime.write("%03d" % ts[_TS_YDAY])
elif k == "m":
ftime.write("%02d" % ts[_TS_MON])
elif k == "M":
ftime.write("%02d" % ts[_TS_MIN])
elif k == "P":
ftime.write("AM" if ts[_TS_HOUR] < 12 else "PM")
elif k == "S":
ftime.write("%02d" % ts[_TS_SEC])
elif k == "w":
ftime.write(str(ts[_TS_WDAY]))
elif k == "y":
ftime.write("%02d" % (ts[_TS_YEAR] % 100))
elif k == "Y":
ftime.write(str(ts[_TS_YEAR]))
else:
ftime.write(k)
fmtsp = False
elif k == "%":
fmtsp = True
else:
ftime.write(k)
val = ftime.getvalue()
ftime.close()
return val

View File

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

View File

@@ -45,6 +45,12 @@ class _BodyStream: # pragma: no cover
class Microdot(BaseMicrodot): class Microdot(BaseMicrodot):
"""A subclass of the core :class:`Microdot <microdot.Microdot>` class that
implements the ASGI protocol.
This class must be used as the application instance when running under an
ASGI web server.
"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.embedded_server = False self.embedded_server = False

View File

@@ -13,6 +13,7 @@ import time
try: try:
from inspect import iscoroutinefunction, iscoroutine from inspect import iscoroutinefunction, iscoroutine
from functools import partial
async def invoke_handler(handler, *args, **kwargs): async def invoke_handler(handler, *args, **kwargs):
"""Invoke a handler and return the result. """Invoke a handler and return the result.
@@ -23,7 +24,7 @@ try:
ret = await handler(*args, **kwargs) ret = await handler(*args, **kwargs)
else: else:
ret = await asyncio.get_running_loop().run_in_executor( ret = await asyncio.get_running_loop().run_in_executor(
None, handler, *args, **kwargs) None, partial(handler, *args, **kwargs))
return ret return ret
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
def iscoroutine(coro): def iscoroutine(coro):
@@ -595,7 +596,7 @@ class Response:
if expires: if expires:
if isinstance(expires, str): if isinstance(expires, str):
http_cookie += '; Expires=' + expires http_cookie += '; Expires=' + expires
else: else: # pragma: no cover
http_cookie += '; Expires=' + time.strftime( http_cookie += '; Expires=' + time.strftime(
'%a, %d %b %Y %H:%M:%S GMT', expires.timetuple()) '%a, %d %b %Y %H:%M:%S GMT', expires.timetuple())
if max_age: if max_age:

View File

@@ -134,7 +134,7 @@ def with_session(f):
return 'Hello, World!' return 'Hello, World!'
Note that the decorator does not save the session. To update the session, Note that the decorator does not save the session. To update the session,
call the :func:`update_session <microdot.session.update_session>` function. call the :func:`session.save() <microdot.session.SessionDict.save>` method.
""" """
async def wrapper(request, *args, **kwargs): async def wrapper(request, *args, **kwargs):
return await invoke_handler( return await invoke_handler(

View File

@@ -3,18 +3,33 @@ import json
class SSE: class SSE:
"""Server-Sent Events object.
An object of this class is sent to handler functions to manage the SSE
connection.
"""
def __init__(self): def __init__(self):
self.event = asyncio.Event() self.event = asyncio.Event()
self.queue = [] self.queue = []
async def send(self, data, event=None): async def send(self, data, event=None):
"""Send an event to the client.
:param data: the data to send. It can be given as a string, bytes, dict
or list. Dictionaries and lists are serialized to JSON.
Any other types are converted to string before sending.
:param event: an optional event name, to send along with the data. If
given, it must be a string.
"""
if isinstance(data, (dict, list)): if isinstance(data, (dict, list)):
data = json.dumps(data) data = json.dumps(data).encode()
elif not isinstance(data, str): elif isinstance(data, str):
data = str(data) data = data.encode()
data = f'data: {data}\n\n' elif not isinstance(data, bytes):
data = str(data).encode()
data = b'data: ' + data + b'\n\n'
if event: if event:
data = f'event: {event}\n{data}' data = b'event: ' + event.encode() + b'\n' + data
self.queue.append(data) self.queue.append(data)
self.event.set() self.event.set()
@@ -30,19 +45,9 @@ def sse_response(request, event_function, *args, **kwargs):
:param args: additional positional arguments to be passed to the response. :param args: additional positional arguments to be passed to the response.
:param kwargs: additional keyword arguments to be passed to the response. :param kwargs: additional keyword arguments to be passed to the response.
Example:: This is a low-level function that can be used to implement a custom SSE
endpoint. In general the :func:`microdot.sse.with_sse` decorator should be
@app.route('/events') used instead.
async def events_route(request):
async def events(request, sse):
# send an unnamed event with string data
await sse.send('hello')
# send an unnamed event with JSON data
await sse.send({'foo': 'bar'})
# send a named event
await sse.send('hello', event='greeting')
return sse_response(request, events)
""" """
sse = SSE() sse = SSE()
@@ -85,9 +90,14 @@ def with_sse(f):
@app.route('/events') @app.route('/events')
@with_sse @with_sse
async def events(request, sse): async def events(request, sse):
for i in range(10): # send an unnamed event with string data
await asyncio.sleep(1) await sse.send('hello')
await sse.send(f'{i}')
# send an unnamed event with JSON data
await sse.send({'foo': 'bar'})
# send a named event
await sse.send('hello', event='greeting')
""" """
async def sse_handler(request, *args, **kwargs): async def sse_handler(request, *args, **kwargs):
return sse_response(request, f, *args, **kwargs) return sse_response(request, f, *args, **kwargs)

View File

@@ -292,6 +292,8 @@ class TestClient:
async def awrite(self, data): async def awrite(self, data):
if self.started: if self.started:
h = WebSocket._parse_frame_header(data[0:2]) h = WebSocket._parse_frame_header(data[0:2])
if h[1] not in [WebSocket.TEXT, WebSocket.BINARY]:
return
if h[3] < 0: if h[3] < 0:
data = data[2 - h[3]:] data = data[2 - h[3]:]
else: else:

View File

@@ -1,10 +1,20 @@
import binascii import binascii
import hashlib import hashlib
from microdot import Response from microdot import Request, Response
from microdot.microdot import MUTED_SOCKET_ERRORS from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
class WebSocketError(Exception):
"""Exception raised when an error occurs in a WebSocket connection."""
pass
class WebSocket: class WebSocket:
"""A WebSocket connection object.
An instance of this class is sent to handler functions to manage the
WebSocket connection.
"""
CONT = 0 CONT = 0
TEXT = 1 TEXT = 1
BINARY = 2 BINARY = 2
@@ -12,6 +22,18 @@ class WebSocket:
PING = 9 PING = 9
PONG = 10 PONG = 10
#: Specify the maximum message size that can be received when calling the
#: ``receive()`` method. Messages with payloads that are larger than this
#: size will be rejected and the connection closed. Set to 0 to disable
#: the size check (be aware of potential security issues if you do this),
#: or to -1 to use the value set in
#: ``Request.max_body_length``. The default is -1.
#:
#: Example::
#:
#: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages
max_message_length = -1
def __init__(self, request): def __init__(self, request):
self.request = request self.request = request
self.closed = False self.closed = False
@@ -26,6 +48,7 @@ class WebSocket:
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
async def receive(self): async def receive(self):
"""Receive a message from the client."""
while True: while True:
opcode, payload = await self._read_frame() opcode, payload = await self._read_frame()
send_opcode, data = self._process_websocket_frame(opcode, payload) send_opcode, data = self._process_websocket_frame(opcode, payload)
@@ -35,12 +58,20 @@ class WebSocket:
return data return data
async def send(self, data, opcode=None): async def send(self, data, opcode=None):
"""Send a message to the client.
:param data: the data to send, given as a string or bytes.
:param opcode: a custom frame opcode to use. If not given, the opcode
is ``TEXT`` or ``BINARY`` depending on the type of the
data.
"""
frame = self._encode_websocket_frame( frame = self._encode_websocket_frame(
opcode or (self.TEXT if isinstance(data, str) else self.BINARY), opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
data) data)
await self.request.sock[1].awrite(frame) await self.request.sock[1].awrite(frame)
async def close(self): async def close(self):
"""Close the websocket connection."""
if not self.closed: # pragma: no cover if not self.closed: # pragma: no cover
self.closed = True self.closed = True
await self.send(b'', self.CLOSE) await self.send(b'', self.CLOSE)
@@ -72,7 +103,7 @@ class WebSocket:
fin = header[0] & 0x80 fin = header[0] & 0x80
opcode = header[0] & 0x0f opcode = header[0] & 0x0f
if fin == 0 or opcode == cls.CONT: # pragma: no cover if fin == 0 or opcode == cls.CONT: # pragma: no cover
raise OSError(32, 'Continuation frames not supported') raise WebSocketError('Continuation frames not supported')
has_mask = header[1] & 0x80 has_mask = header[1] & 0x80
length = header[1] & 0x7f length = header[1] & 0x7f
if length == 126: if length == 126:
@@ -87,7 +118,7 @@ class WebSocket:
elif opcode == self.BINARY: elif opcode == self.BINARY:
pass pass
elif opcode == self.CLOSE: elif opcode == self.CLOSE:
raise OSError(32, 'Websocket connection closed') raise WebSocketError('Websocket connection closed')
elif opcode == self.PING: elif opcode == self.PING:
return self.PONG, payload return self.PONG, payload
elif opcode == self.PONG: # pragma: no branch elif opcode == self.PONG: # pragma: no branch
@@ -114,7 +145,7 @@ class WebSocket:
async def _read_frame(self): async def _read_frame(self):
header = await self.request.sock[0].read(2) header = await self.request.sock[0].read(2)
if len(header) != 2: # pragma: no cover if len(header) != 2: # pragma: no cover
raise OSError(32, 'Websocket connection closed') raise WebSocketError('Websocket connection closed')
fin, opcode, has_mask, length = self._parse_frame_header(header) fin, opcode, has_mask, length = self._parse_frame_header(header)
if length == -2: if length == -2:
length = await self.request.sock[0].read(2) length = await self.request.sock[0].read(2)
@@ -122,6 +153,10 @@ class WebSocket:
elif length == -8: elif length == -8:
length = await self.request.sock[0].read(8) length = await self.request.sock[0].read(8)
length = int.from_bytes(length, 'big') 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 if has_mask: # pragma: no cover
mask = await self.request.sock[0].read(4) mask = await self.request.sock[0].read(4)
payload = await self.request.sock[0].read(length) payload = await self.request.sock[0].read(length)
@@ -161,11 +196,19 @@ def websocket_wrapper(f, upgrade_function):
ws = await upgrade_function(request) ws = await upgrade_function(request)
try: try:
await f(request, ws, *args, **kwargs) await f(request, ws, *args, **kwargs)
await ws.close() # pragma: no cover
except OSError as exc: except OSError as exc:
if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover
raise raise
return '' except WebSocketError:
pass
except Exception as exc:
print_exception(exc)
finally: # pragma: no cover
try:
await ws.close()
except Exception:
pass
return Response.already_handled
return wrapper return wrapper

View File

@@ -9,6 +9,12 @@ from microdot.websocket import WebSocket, websocket_upgrade, \
class Microdot(BaseMicrodot): class Microdot(BaseMicrodot):
"""A subclass of the core :class:`Microdot <microdot.Microdot>` class that
implements the WSGI protocol.
This class must be used as the application instance when running under a
WSGI web server.
"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.loop = asyncio.new_event_loop() self.loop = asyncio.new_event_loop()

View File

@@ -25,6 +25,14 @@ class TestMicrodot(unittest.TestCase):
async def index2(req): async def index2(req):
return 'foo-async' return 'foo-async'
@app.route('/arg/<id>')
def index3(req, id):
return id
@app.route('/arg/async/<id>')
async def index4(req, id):
return f'async-{id}'
client = TestClient(app) client = TestClient(app)
res = self._run(client.get('/')) res = self._run(client.get('/'))
@@ -45,6 +53,24 @@ class TestMicrodot(unittest.TestCase):
self.assertEqual(res.body, b'foo-async') self.assertEqual(res.body, b'foo-async')
self.assertEqual(res.json, None) self.assertEqual(res.json, None)
res = self._run(client.get('/arg/123'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Content-Length'], '3')
self.assertEqual(res.text, '123')
self.assertEqual(res.body, b'123')
self.assertEqual(res.json, None)
res = self._run(client.get('/arg/async/123'))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers['Content-Type'],
'text/plain; charset=UTF-8')
self.assertEqual(res.headers['Content-Length'], '9')
self.assertEqual(res.text, 'async-123')
self.assertEqual(res.body, b'async-123')
self.assertEqual(res.json, None)
def test_post_request(self): def test_post_request(self):
app = Microdot() app = Microdot()

View File

@@ -1,5 +1,4 @@
import asyncio import asyncio
from datetime import datetime
import unittest import unittest
from microdot import Response from microdot import Response
from tests.mock_socket import FakeStreamAsync from tests.mock_socket import FakeStreamAsync
@@ -186,12 +185,12 @@ class TestResponse(unittest.TestCase):
res.set_cookie('foo2', 'bar2', path='/', partitioned=True) res.set_cookie('foo2', 'bar2', path='/', partitioned=True)
res.set_cookie('foo3', 'bar3', domain='example.com:1234') res.set_cookie('foo3', 'bar3', domain='example.com:1234')
res.set_cookie('foo4', 'bar4', res.set_cookie('foo4', 'bar4',
expires=datetime(2019, 11, 5, 2, 23, 54)) expires='Tue, 05 Nov 2019 02:23:54 GMT')
res.set_cookie('foo5', 'bar5', max_age=123, res.set_cookie('foo5', 'bar5', max_age=123,
expires='Thu, 01 Jan 1970 00:00:00 GMT') expires='Thu, 01 Jan 1970 00:00:00 GMT')
res.set_cookie('foo6', 'bar6', secure=True, http_only=True) res.set_cookie('foo6', 'bar6', secure=True, http_only=True)
res.set_cookie('foo7', 'bar7', path='/foo', domain='example.com:1234', res.set_cookie('foo7', 'bar7', path='/foo', domain='example.com:1234',
expires=datetime(2019, 11, 5, 2, 23, 54), max_age=123, expires='Tue, 05 Nov 2019 02:23:54 GMT', max_age=123,
secure=True, http_only=True) secure=True, http_only=True)
res.delete_cookie('foo8', http_only=True) res.delete_cookie('foo8', http_only=True)
self.assertEqual(res.headers, {'Set-Cookie': [ self.assertEqual(res.headers, {'Set-Cookie': [

View File

@@ -26,6 +26,7 @@ class TestWebSocket(unittest.TestCase):
await sse.send({'foo': 'bar'}) await sse.send({'foo': 'bar'})
await sse.send([42, 'foo', 'bar']) await sse.send([42, 'foo', 'bar'])
await sse.send(ValueError('foo')) await sse.send(ValueError('foo'))
await sse.send(b'foo')
client = TestClient(app) client = TestClient(app)
response = self._run(client.get('/sse')) response = self._run(client.get('/sse'))
@@ -35,4 +36,5 @@ class TestWebSocket(unittest.TestCase):
'event: test\ndata: bar\n\n' 'event: test\ndata: bar\n\n'
'data: {"foo": "bar"}\n\n' 'data: {"foo": "bar"}\n\n'
'data: [42, "foo", "bar"]\n\n' 'data: [42, "foo", "bar"]\n\n'
'data: foo\n\n'
'data: foo\n\n')) 'data: foo\n\n'))

View File

@@ -1,8 +1,8 @@
import asyncio import asyncio
import sys import sys
import unittest import unittest
from microdot import Microdot from microdot import Microdot, Request
from microdot.websocket import with_websocket, WebSocket from microdot.websocket import with_websocket, WebSocket, WebSocketError
from microdot.test_client import TestClient from microdot.test_client import TestClient
@@ -17,6 +17,7 @@ class TestWebSocket(unittest.TestCase):
return self.loop.run_until_complete(coro) return self.loop.run_until_complete(coro)
def test_websocket_echo(self): def test_websocket_echo(self):
WebSocket.max_message_length = 65537
app = Microdot() app = Microdot()
@app.route('/echo') @app.route('/echo')
@@ -26,34 +27,10 @@ class TestWebSocket(unittest.TestCase):
data = await ws.receive() data = await ws.receive()
await ws.send(data) await ws.send(data)
results = [] @app.route('/divzero')
def ws():
data = yield 'hello'
results.append(data)
data = yield b'bye'
results.append(data)
data = yield b'*' * 300
results.append(data)
data = yield b'+' * 65537
results.append(data)
client = TestClient(app)
res = self._run(client.websocket('/echo', ws))
self.assertIsNone(res)
self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537])
@unittest.skipIf(sys.implementation.name == 'micropython',
'no support for async generators in MicroPython')
def test_websocket_echo_async_client(self):
app = Microdot()
@app.route('/echo')
@with_websocket @with_websocket
async def index(req, ws): async def divzero(req, ws):
while True: 1 / 0
data = await ws.receive()
await ws.send(data)
results = [] results = []
@@ -72,6 +49,35 @@ class TestWebSocket(unittest.TestCase):
self.assertIsNone(res) self.assertIsNone(res)
self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537]) self.assertEqual(results, ['hello', b'bye', b'*' * 300, b'+' * 65537])
res = self._run(client.websocket('/divzero', ws))
self.assertIsNone(res)
WebSocket.max_message_length = -1
@unittest.skipIf(sys.implementation.name == 'micropython',
'no support for async generators in MicroPython')
def test_websocket_large_message(self):
saved_max_body_length = Request.max_body_length
Request.max_body_length = 10
app = Microdot()
@app.route('/echo')
@with_websocket
async def index(req, ws):
data = await ws.receive()
await ws.send(data)
results = []
async def ws():
data = yield '0123456789abcdef'
results.append(data)
client = TestClient(app)
res = self._run(client.websocket('/echo', ws))
self.assertIsNone(res)
self.assertEqual(results, [])
Request.max_body_length = saved_max_body_length
def test_bad_websocket_request(self): def test_bad_websocket_request(self):
app = Microdot() app = Microdot()
@@ -106,7 +112,7 @@ class TestWebSocket(unittest.TestCase):
(None, 'foo')) (None, 'foo'))
self.assertEqual(ws._process_websocket_frame(WebSocket.BINARY, b'foo'), self.assertEqual(ws._process_websocket_frame(WebSocket.BINARY, b'foo'),
(None, b'foo')) (None, b'foo'))
self.assertRaises(OSError, ws._process_websocket_frame, self.assertRaises(WebSocketError, ws._process_websocket_frame,
WebSocket.CLOSE, b'') WebSocket.CLOSE, b'')
self.assertEqual(ws._process_websocket_frame(WebSocket.PING, b'foo'), self.assertEqual(ws._process_websocket_frame(WebSocket.PING, b'foo'),
(WebSocket.PONG, b'foo')) (WebSocket.PONG, b'foo'))

View File

@@ -1,23 +1,24 @@
FROM ubuntu:22.04 FROM ubuntu:22.04
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG VERSION=master
ENV VERSION=$VERSION
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y build-essential libffi-dev git pkg-config python3 && \ apt-get install -y build-essential libffi-dev git pkg-config python3 && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
git clone https://github.com/micropython/micropython.git && \ git clone https://github.com/micropython/micropython.git && \
cd micropython && \ cd micropython && \
git checkout $VERSION && \
git submodule update --init && \ git submodule update --init && \
cd mpy-cross && \ cd mpy-cross && \
make && \ make && \
cd .. && \ cd .. && \
cd ports/unix && \ cd ports/unix && \
make && \ make && \
make test && \
make install && \ make install && \
apt-get purge --auto-remove -y build-essential libffi-dev git pkg-config python3 && \ apt-get purge --auto-remove -y build-essential libffi-dev git pkg-config python3 && \
cd ../../.. && \ cd ../../.. && \
rm -rf micropython rm -rf micropython
CMD ["/usr/local/bin/micropython"] CMD ["/usr/local/bin/micropython"]

View File

@@ -0,0 +1,24 @@
FROM ubuntu:22.04
ARG DEBIAN_FRONTEND=noninteractive
ARG VERSION=main
ENV VERSION=$VERSION
RUN apt-get update && \
apt-get install -y build-essential libffi-dev git pkg-config python3 && \
rm -rf /var/lib/apt/lists/* && \
git clone https://github.com/adafruit/circuitpython.git && \
cd circuitpython && \
git checkout $VERSION && \
git submodule update --init lib tools frozen && \
cd mpy-cross && \
make && \
cd .. && \
cd ports/unix && \
make && \
make install && \
apt-get purge --auto-remove -y build-essential libffi-dev git pkg-config python3 && \
cd ../../.. && \
rm -rf circuitpython
CMD ["/usr/local/bin/micropython"]

11
tools/update-circuitpython.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# this script updates the micropython binary in the /bin directory that is
# used to run unit tests under GitHub Actions builds
DOCKER=${DOCKER:-docker}
VERSION=${1:-main}
$DOCKER build -f Dockerfile.circuitpython --build-arg VERSION=$VERSION -t circuitpython .
$DOCKER create -t --name dummy-circuitpython circuitpython
$DOCKER cp dummy-circuitpython:/usr/local/bin/micropython ../bin/circuitpython
$DOCKER rm dummy-circuitpython

View File

@@ -3,8 +3,9 @@
# used to run unit tests under GitHub Actions builds # used to run unit tests under GitHub Actions builds
DOCKER=${DOCKER:-docker} DOCKER=${DOCKER:-docker}
VERSION=${1:-master}
$DOCKER build -t micropython . $DOCKER build --build-arg VERSION=$VERSION -t micropython .
$DOCKER create -it --name dummy-micropython micropython $DOCKER create -it --name dummy-micropython micropython
$DOCKER cp dummy-micropython:/usr/local/bin/micropython ../bin/micropython $DOCKER cp dummy-micropython:/usr/local/bin/micropython ../bin/micropython
$DOCKER rm dummy-micropython $DOCKER rm dummy-micropython

17
tox.ini
View File

@@ -1,5 +1,5 @@
[tox] [tox]
envlist=flake8,py38,py39,py310,py311,py312,upy,benchmark envlist=flake8,py38,py39,py310,py311,py312,upy,cpy,benchmark,docs
skipsdist=True skipsdist=True
skip_missing_interpreters=True skip_missing_interpreters=True
@@ -29,10 +29,13 @@ setenv=
allowlist_externals=sh allowlist_externals=sh
commands=sh -c "bin/micropython run_tests.py" commands=sh -c "bin/micropython run_tests.py"
[testenv:cpy]
allowlist_externals=sh
commands=sh -c "bin/circuitpython run_tests.py"
[testenv:upy-mac] [testenv:upy-mac]
allowlist_externals=micropython allowlist_externals=micropython
commands=micropython run_tests.py commands=micropython run_tests.py
deps=
[testenv:benchmark] [testenv:benchmark]
deps= deps=
@@ -55,3 +58,13 @@ deps=
flake8 flake8
commands= commands=
flake8 --ignore=W503 --exclude examples/templates/utemplate/templates src tests examples flake8 --ignore=W503 --exclude examples/templates/utemplate/templates src tests examples
[testenv:docs]
changedir=docs
deps=
sphinx
pyjwt
allowlist_externals=
make
commands=
make html