39 Commits

Author SHA1 Message Date
Miguel Grinberg
5f7efcc3f8 Release 0.8.2 2022-04-20 10:15:17 +01:00
Mark Blakeney
0f278321c8 Remove stray/debug remnant print() (#38) 2022-04-20 10:13:38 +01:00
Miguel Grinberg
acf20cc20c Version 0.8.2.dev0 2022-03-18 23:51:38 +00:00
Miguel Grinberg
453e133cc2 Release 0.8.1 2022-03-18 23:51:28 +00:00
Miguel Grinberg
29a9f6f46c Optimizations for request streams and bodies 2022-02-21 18:11:19 +01:00
Miguel Grinberg
9d3222ae4b Version 0.8.1.dev0 2022-02-18 17:41:16 +00:00
Miguel Grinberg
f23a6be2db Release 0.8.0 2022-02-18 17:40:59 +00:00
Miguel Grinberg
992fa722c1 Support streamed request payloads (Fixes #26) 2022-02-18 17:32:14 +00:00
Steve Li
e16fb94b2d Use case insensitive comparisons for HTTP headers (#33) 2022-01-31 12:10:23 +00:00
Miguel Grinberg
c130d8f2d4 simplified hello_async.py example 2022-01-22 23:23:31 +00:00
Miguel Grinberg
bd82c4deab More robust logic to read request body (Fixes #31) 2021-10-23 19:03:31 +01:00
Miguel Grinberg
7bc5d724f0 Version 0.7.3.dev0 2021-09-28 17:23:17 +01:00
Miguel Grinberg
f23c78533e Release 0.7.2 2021-09-28 17:21:05 +01:00
Miguel Grinberg
d29ed6aaa1 Document a security risk in the send_file function 2021-09-28 17:15:07 +01:00
Miguel Grinberg
8e5fb92ff1 Validate redirect URLs 2021-09-28 17:12:15 +01:00
Miguel Grinberg
06015934b8 Return a 400 error when request object could not be created 2021-09-28 17:09:02 +01:00
Miguel Grinberg
568cd51fd2 Version 0.7.2.dev0 2021-09-27 23:01:20 +01:00
Miguel Grinberg
2fe9793389 Release 0.7.1 2021-09-27 22:58:32 +01:00
Miguel Grinberg
de9c991a9a Limit the size of each request line 2021-09-27 20:03:18 +01:00
Miguel Grinberg
d75449eb32 Version 0.7.1.dev0 2021-09-27 17:14:48 +01:00
Miguel Grinberg
e508abc333 Release 0.7.0 2021-09-27 17:12:42 +01:00
Miguel Grinberg
5003a5b3d9 Limit the size of the request body 2021-09-27 17:01:43 +01:00
Miguel Grinberg
4ed101dfc6 Add security policy 2021-09-27 13:57:04 +01:00
Mark Blakeney
833fecb105 Add documentation for request.client_addr (#27) 2021-09-22 12:04:28 +01:00
Miguel Grinberg
d527bdb7c3 Added documentation for reason argument in the Response object 2021-08-11 12:00:46 +01:00
Miguel Grinberg
2516b296a7 Version 0.6.1.dev0 2021-08-11 10:37:04 +01:00
Miguel Grinberg
5061145f5c Release 0.6.0 2021-08-11 10:36:42 +01:00
Miguel Grinberg
122c638bae Fix codecov badge link #nolog 2021-08-11 10:33:10 +01:00
Miguel Grinberg
bd74bcab74 Accept a custom reason phrase for the HTTP response (Fixes #25) 2021-08-11 10:29:08 +01:00
Miguel Grinberg
5cd3ace516 More unit tests 2021-08-02 15:53:13 +01:00
Miguel Grinberg
da32f23e35 Better handling of content types in form and json methods (Fixes #24) 2021-08-02 15:39:32 +01:00
Mark Blakeney
0641466faa Copy client headers to avoid write back (#23) 2021-07-28 10:43:54 +01:00
Miguel Grinberg
dd3fc20507 Make mime type check for form submissions more robust 2021-06-06 20:05:32 +01:00
Miguel Grinberg
46963ba464 Work around a bug in uasyncio's create_server() function 2021-06-06 20:05:12 +01:00
Miguel Grinberg
1a8db51cb3 Installation instructions 2021-06-06 12:24:09 +01:00
Miguel Grinberg
d903c42370 Minor wording update in the documentation #nolog 2021-06-06 12:17:22 +01:00
Miguel Grinberg
8b4ebbd953 Run tests with pytest 2021-06-06 12:09:03 +01:00
Miguel Grinberg
a82ed55f56 Last version of the microdot-asyncio package 2021-06-06 11:54:51 +01:00
Miguel Grinberg
ac87f0542f Version 0.5.1.dev0 2021-06-06 11:49:01 +01:00
22 changed files with 580 additions and 168 deletions

View File

@@ -2,10 +2,10 @@ name: build
on: on:
push: push:
branches: branches:
- master - main
pull_request: pull_request:
branches: branches:
- master - main
jobs: jobs:
lint: lint:
name: lint name: lint

View File

@@ -1,5 +1,48 @@
# Microdot change log # Microdot change log
**Release 0.8.2** - 2022-04-20
- Remove debugging print statement [#38](https://github.com/miguelgrinberg/microdot/issues/38) ([commit](https://github.com/miguelgrinberg/microdot/commit/0f278321c8bd65c5cb67425eb837e6581cbb0054)) (thanks **Mark Blakeney**!)
**Release 0.8.1** - 2022-03-18
- Optimizations for request streams and bodies ([commit](https://github.com/miguelgrinberg/microdot/commit/29a9f6f46c737aa0fd452766c23bd83008594ac4))
**Release 0.8.0** - 2022-02-18
- Support streamed request payloads [#26](https://github.com/miguelgrinberg/microdot/issues/26) ([commit](https://github.com/miguelgrinberg/microdot/commit/992fa722c1312c0ac0ee9fbd5e23ad7b52d3caca))
- Use case insensitive comparisons for HTTP headers [#33](https://github.com/miguelgrinberg/microdot/issues/33) ([commit](https://github.com/miguelgrinberg/microdot/commit/e16fb94b2d1e88ef681d70f7f456c37ee9859df6)) (thanks **Steve Li**!)
- More robust logic to read request body [#31](https://github.com/miguelgrinberg/microdot/issues/31) ([commit](https://github.com/miguelgrinberg/microdot/commit/bd82c4deabf40d37e6b7397b08e8eb40ba2b6a42))
- Simplified `hello_async.py` example ([commit](https://github.com/miguelgrinberg/microdot/commit/c130d8f2d45dcce9606dda25d31d653ce91faf92))
**Release 0.7.2** - 2021-09-28
- Document a security risk in the send_file function ([commit](https://github.com/miguelgrinberg/microdot/commit/d29ed6aaa1f2080fcf471bf6ae0f480f95ff1716)) (thanks **Ky Tran**!)
- Validate redirect URLs ([commit](https://github.com/miguelgrinberg/microdot/commit/8e5fb92ff1ccd50972b0c1cb5a6c3bd5eb54d86b)) (thanks **Ky Tran**!)
- Return a 400 error when request object could not be created ([commit](https://github.com/miguelgrinberg/microdot/commit/06015934b834622d39f52b3e13d16bfee9dc8e5a))
**Release 0.7.1** - 2021-09-27
- Breaking change: Limit the size of each request line to 2KB. A different maximum can be set in `Request.max_readline`. ([commit](https://github.com/miguelgrinberg/microdot/commit/de9c991a9ab836d57d5c08bf4282f99f073b502a)) (thanks **Ky Tran**!)
**Release 0.7.0** - 2021-09-27
- Breaking change: Limit the size of the request body to 16KB. A different maximum can be set in `Request.max_content_length`. ([commit](https://github.com/miguelgrinberg/microdot/commit/5003a5b3d948a7cf365857b419bebf6e388593a1))
- Add documentation for `request.client_addr` [#27](https://github.com/miguelgrinberg/microdot/issues/27) ([commit](https://github.com/miguelgrinberg/microdot/commit/833fecb105ce456b95f1d2a6ea96dceca1075814)) (thanks **Mark Blakeney**!)
- Added documentation for reason argument in the Response object ([commit](https://github.com/miguelgrinberg/microdot/commit/d527bdb7c32ab918a1ecf6956cf3a9f544504354))
**Release 0.6.0** - 2021-08-11
- Better handling of content types in form and json methods [#24](https://github.com/miguelgrinberg/microdot/issues/24) ([commit](https://github.com/miguelgrinberg/microdot/commit/da32f23e35f871470a40638e7000e84b0ff6d17f))
- Accept a custom reason phrase for the HTTP response [#25](https://github.com/miguelgrinberg/microdot/issues/25) ([commit](https://github.com/miguelgrinberg/microdot/commit/bd74bcab74f283c89aadffc8f9c20d6ff0f771ce))
- Make mime type check for form submissions more robust ([commit](https://github.com/miguelgrinberg/microdot/commit/dd3fc20507715a23d0fa6fa3aae3715c8fbc0351))
- Copy client headers to avoid write back [#23](https://github.com/miguelgrinberg/microdot/issues/23) ([commit](https://github.com/miguelgrinberg/microdot/commit/0641466faa9dda0c54f78939ac05993c0812e84a)) (thanks **Mark Blakeney**!)
- Work around a bug in uasyncio's create_server() function ([commit](https://github.com/miguelgrinberg/microdot/commit/46963ba4644d7abc8dc653c99bc76222af526964))
- More unit tests ([commit](https://github.com/miguelgrinberg/microdot/commit/5cd3ace5166ec549579b0b1149ae3d7be195974a))
- Installation instructions ([commit](https://github.com/miguelgrinberg/microdot/commit/1a8db51cb3754308da6dcc227512dcdeb4ce4557))
- Run tests with pytest ([commit](https://github.com/miguelgrinberg/microdot/commit/8b4ebbd9535b3c083fb2a955284609acba07f05e))
- Deprecated the microdot-asyncio package ([commit](https://github.com/miguelgrinberg/microdot/commit/a82ed55f56e14fbcea93e8171af86ab42657fa96))
**Release 0.5.0** - 2021-06-06 **Release 0.5.0** - 2021-06-06
- [Documentation](https://microdot.readthedocs.io/en/latest/) site ([commit](https://github.com/miguelgrinberg/microdot/commit/12cd60305b7b48ab151da52661fc5988684dbcd8)) - [Documentation](https://microdot.readthedocs.io/en/latest/) site ([commit](https://github.com/miguelgrinberg/microdot/commit/12cd60305b7b48ab151da52661fc5988684dbcd8))

View File

@@ -1,5 +1,5 @@
# microdot # microdot
[![Build status](https://github.com/miguelgrinberg/microdot/workflows/build/badge.svg)](https://github.com/miguelgrinberg/microdot/actions) [![codecov](https://codecov.io/gh/miguelgrinberg/microdot/branch/master/graph/badge.svg)](https://codecov.io/gh/miguelgrinberg/microdot) [![Build status](https://github.com/miguelgrinberg/microdot/workflows/build/badge.svg)](https://github.com/miguelgrinberg/microdot/actions) [![codecov](https://codecov.io/gh/miguelgrinberg/microdot/branch/main/graph/badge.svg)](https://codecov.io/gh/miguelgrinberg/microdot)
A minimalistic Python web framework for microcontrollers inspired by Flask A minimalistic Python web framework for microcontrollers inspired by Flask

9
SECURITY.md Normal file
View File

@@ -0,0 +1,9 @@
# Security Policy
## Reporting a Vulnerability
If you think you've found a vulnerability on this project, please send me (Miguel Grinberg) an email at miguel.grinberg@gmail.com with a description of the problem. I will personally review the issue and respond to you with next steps.
If the issue is highly sensitive, you are welcome to encrypt your message. Here is my [PGP key](https://keyserver.ubuntu.com/pks/lookup?search=miguel.grinberg%40gmail.com&fingerprint=on&op=index).
Please do not disclose vulnerabilities publicly before discussing how to proceed with me.

View File

@@ -1,33 +0,0 @@
#!/bin/bash
VERSION=$1
if [[ "$VERSION" == "" ]]; then
echo Usage: $0 "<version>"
exit 1
fi
git diff --cached --exit-code >/dev/null
if [[ "$?" != "0" ]]; then
echo Commit your changes before using this script.
exit 1
fi
set -e
for PKG in microdot*; do
echo Building $PKG...
cd $PKG
sed -i "" "s/version.*$/version=\"$VERSION\",/" setup.py
git add setup.py
rm -rf dist
python setup.py sdist bdist_wheel --universal
cd ..
done
git commit -m "Release v$VERSION"
git tag v$VERSION
git push --tags origin master
for PKG in microdot*; do
echo Releasing $PKG...
cd $PKG
twine upload dist/*
cd ..
done

View File

@@ -12,8 +12,7 @@
# #
import os import os
import sys import sys
sys.path.insert(0, os.path.abspath('../microdot')) sys.path.insert(0, os.path.abspath('../src'))
sys.path.insert(1, os.path.abspath('../microdot-asyncio'))
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------

View File

@@ -6,8 +6,8 @@
Microdot Microdot
======== ========
Microdot is a minimalistic Python web framework for microcontrollers inspired Microdot is a minimalistic Python web framework inspired by
by `Flask <https://flask.palletsprojects.com/>`_, and designed to run on `Flask <https://flask.palletsprojects.com/>`_, and designed to run on
systems with limited resources such as microcontrollers. It runs on standard systems with limited resources such as microcontrollers. It runs on standard
Python and on `MicroPython <https://micropython.org>`_. Python and on `MicroPython <https://micropython.org>`_.

View File

@@ -1,3 +1,13 @@
Installation
------------
Microdot can be installed with ``pip``::
pip install microdot
For platforms that do not support or cannot run ``pip``, you can also manually
copy and install the ``microdot.py`` and ``microdot_asyncio.py`` source files.
Examples Examples
-------- --------

View File

@@ -33,8 +33,4 @@ async def shutdown(request):
return 'The server is shutting down...' return 'The server is shutting down...'
async def main(): app.run(debug=True)
await app.start_server(debug=True)
asyncio.run(main())

View File

@@ -1,6 +1,6 @@
[metadata] [metadata]
name = microdot-asyncio name = microdot-asyncio
version = 0.4.0 version = 0.5.0
author = Miguel Grinberg author = Miguel Grinberg
author_email = miguel.grinberg@gmail.com author_email = miguel.grinberg@gmail.com
description = AsyncIO support for the Microdot web framework' description = AsyncIO support for the Microdot web framework'

View File

@@ -1,7 +1,6 @@
import sys import sys
sys.path.insert(0, 'microdot') sys.path.insert(0, 'src')
sys.path.insert(1, 'microdot-asyncio')
sys.path.insert(2, 'tests/libs') sys.path.insert(2, 'tests/libs')
import unittest import unittest

View File

@@ -1,6 +1,6 @@
[metadata] [metadata]
name = microdot name = microdot
version = 0.5.0 version = 0.8.2
author = Miguel Grinberg author = Miguel Grinberg
author_email = miguel.grinberg@gmail.com author_email = miguel.grinberg@gmail.com
description = The impossibly small web framework for MicroPython description = The impossibly small web framework for MicroPython

View File

@@ -172,6 +172,7 @@ class Request():
"""An HTTP request class. """An HTTP request class.
:var app: The application instance to which this request belongs. :var app: The application instance to which this request belongs.
:var client_addr: The address of the client, as a tuple (host, port).
:var method: The HTTP method of the request. :var method: The HTTP method of the request.
:var path: The path portion of the URL. :var path: The path portion of the URL.
:var query_string: The query string portion of the URL. :var query_string: The query string portion of the URL.
@@ -180,7 +181,8 @@ class Request():
:var cookies: A dictionary with the cookies included in the request. :var cookies: A dictionary with the cookies included in the request.
:var content_length: The parsed ``Content-Length`` header. :var content_length: The parsed ``Content-Length`` header.
:var content_type: The parsed ``Content-Type`` header. :var content_type: The parsed ``Content-Type`` header.
:var body: A stream from where the body can be read. :var stream: The input stream, containing the request body.
:var body: The body of the request, as bytes.
:var json: The parsed JSON body, as a dictionary or list, or ``None`` if :var json: The parsed JSON body, as a dictionary or list, or ``None`` if
the request does not have a JSON body. the request does not have a JSON body.
:var form: The parsed form submission body, as a :class:`MultiDict` object, :var form: The parsed form submission body, as a :class:`MultiDict` object,
@@ -188,11 +190,40 @@ class Request():
:var g: A general purpose container for applications to store data during :var g: A general purpose container for applications to store data during
the life of the request. the life of the request.
""" """
#: Specify the maximum payload size that is accepted. Requests with larger
#: payloads will be rejected with a 413 status code. Applications can
#: change this maximum as necessary.
#:
#: Example::
#:
#: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed
max_content_length = 16 * 1024
#: Specify the maximum payload size that can be stored in ``body``.
#: Requests with payloads that are larger than this size and up to
#: ``max_content_length`` bytes will be accepted, but the application will
#: only be able to access the body of the request by reading from
#: ``stream``. Set to 0 if you always access the body as a stream.
#:
#: Example::
#:
#: Request.max_body_length = 4 * 1024 # up to 4KB bodies read
max_body_length = 16 * 1024
#: Specify the maximum length allowed for a line in the request. Requests
#: with longer lines will not be correctly interpreted. Applications can
#: change this maximum as necessary.
#:
#: Example::
#:
#: Request.max_readline = 16 * 1024 # 16KB lines allowed
max_readline = 2 * 1024
class G: class G:
pass pass
def __init__(self, app, client_addr, method, url, http_version, headers, def __init__(self, app, client_addr, method, url, http_version, headers,
body): body=None, stream=None):
self.app = app self.app = app
self.client_addr = client_addr self.client_addr = client_addr
self.method = method self.method = method
@@ -209,15 +240,19 @@ class Request():
self.content_length = 0 self.content_length = 0
self.content_type = None self.content_type = None
for header, value in self.headers.items(): for header, value in self.headers.items():
if header == 'Content-Length': header = header.lower()
if header == 'content-length':
self.content_length = int(value) self.content_length = int(value)
elif header == 'Content-Type': elif header == 'content-type':
self.content_type = value self.content_type = value
elif header == 'Cookie': elif header == 'cookie':
for cookie in value.split(';'): for cookie in value.split(';'):
name, value = cookie.strip().split('=', 1) name, value = cookie.strip().split('=', 1)
self.cookies[name] = value self.cookies[name] = value
self.body = body self._body = body
self.body_used = False
self._stream = stream
self.stream_used = False
self._json = None self._json = None
self._form = None self._form = None
self.g = Request.G() self.g = Request.G()
@@ -234,30 +269,24 @@ class Request():
This method returns a newly created ``Request`` object. This method returns a newly created ``Request`` object.
""" """
# request line # request line
line = client_stream.readline().strip().decode() line = Request._safe_readline(client_stream).strip().decode()
if not line: # pragma: no cover if not line:
return None return None
method, url, http_version = line.split() method, url, http_version = line.split()
http_version = http_version.split('/', 1)[1] http_version = http_version.split('/', 1)[1]
# headers # headers
headers = {} headers = {}
content_length = 0
while True: while True:
line = client_stream.readline().strip().decode() line = Request._safe_readline(client_stream).strip().decode()
if line == '': if line == '':
break break
header, value = line.split(':', 1) header, value = line.split(':', 1)
value = value.strip() value = value.strip()
headers[header] = value headers[header] = value
if header == 'Content-Length':
content_length = int(value)
# body
body = client_stream.read(content_length) if content_length else b''
return Request(app, client_addr, method, url, http_version, headers, return Request(app, client_addr, method, url, http_version, headers,
body) stream=client_stream)
def _parse_urlencoded(self, urlencoded): def _parse_urlencoded(self, urlencoded):
data = MultiDict() data = MultiDict()
@@ -265,22 +294,59 @@ class Request():
data[urldecode(k)] = urldecode(v) data[urldecode(k)] = urldecode(v)
return data return data
@property
def body(self):
if self.stream_used:
raise RuntimeError('Cannot use both stream and body')
if self._body is None:
self._body = b''
if self.content_length and \
self.content_length <= Request.max_body_length:
while len(self._body) < self.content_length:
data = self._stream.read(
self.content_length - len(self._body))
if len(data) == 0: # pragma: no cover
raise EOFError()
self._body += data
self.body_used = True
return self._body
@property
def stream(self):
if self.body_used:
raise RuntimeError('Cannot use both stream and body')
self.stream_used = True
return self._stream
@property @property
def json(self): def json(self):
if self.content_type != 'application/json':
return None
if self._json is None: if self._json is None:
if self.content_type is None:
return None
mime_type = self.content_type.split(';')[0]
if mime_type != 'application/json':
return None
self._json = json.loads(self.body.decode()) self._json = json.loads(self.body.decode())
return self._json return self._json
@property @property
def form(self): def form(self):
if self.content_type != 'application/x-www-form-urlencoded':
return None
if self._form is None: if self._form is None:
if self.content_type is None:
return None
mime_type = self.content_type.split(';')[0]
if mime_type != 'application/x-www-form-urlencoded':
return None
self._form = self._parse_urlencoded(self.body.decode()) self._form = self._parse_urlencoded(self.body.decode())
return self._form return self._form
@staticmethod
def _safe_readline(stream):
line = stream.readline(Request.max_readline + 1)
if len(line) > Request.max_readline:
raise ValueError('line too long')
return line
class Response(): class Response():
"""An HTTP response class. """An HTTP response class.
@@ -290,6 +356,9 @@ class Response():
:param status_code: The numeric HTTP status code of the response. The :param status_code: The numeric HTTP status code of the response. The
default is 200. default is 200.
:param headers: A dictionary of headers to include in the response. :param headers: A dictionary of headers to include in the response.
:param reason: A custom reason phrase to add after the status code. The
default is "OK" for responses with a 200 status code and
"N/A" for any other status codes.
""" """
types_map = { types_map = {
'css': 'text/css', 'css': 'text/css',
@@ -303,9 +372,10 @@ class Response():
} }
send_file_buffer_size = 1024 send_file_buffer_size = 1024
def __init__(self, body='', status_code=200, headers=None): def __init__(self, body='', status_code=200, headers=None, reason=None):
self.status_code = status_code self.status_code = status_code
self.headers = headers or {} self.headers = headers.copy() if headers else {}
self.reason = reason
if isinstance(body, (dict, list)): if isinstance(body, (dict, list)):
self.body = json.dumps(body).encode() self.body = json.dumps(body).encode()
self.headers['Content-Type'] = 'application/json' self.headers['Content-Type'] = 'application/json'
@@ -358,9 +428,10 @@ class Response():
self.complete() self.complete()
# status code # status code
reason = self.reason if self.reason is not None else \
('OK' if self.status_code == 200 else 'N/A')
stream.write('HTTP/1.0 {status_code} {reason}\r\n'.format( stream.write('HTTP/1.0 {status_code} {reason}\r\n'.format(
status_code=self.status_code, status_code=self.status_code, reason=reason).encode())
reason='OK' if self.status_code == 200 else 'N/A').encode())
# headers # headers
for header, value in self.headers.items(): for header, value in self.headers.items():
@@ -392,6 +463,8 @@ class Response():
:param status_code: The 3xx status code to use for the redirect. The :param status_code: The 3xx status code to use for the redirect. The
default is 302. default is 302.
""" """
if '\x0d' in location or '\x0a' in location:
raise ValueError('invalid redirect URL')
return cls(status_code=status_code, headers={'Location': location}) return cls(status_code=status_code, headers={'Location': location})
@classmethod @classmethod
@@ -404,6 +477,10 @@ class Response():
:param content_type: The ``Content-Type`` header to use in the :param content_type: The ``Content-Type`` header to use in the
response. If omitted, it is generated response. If omitted, it is generated
automatically from the file extension. automatically from the file extension.
Security note: The filename is assumed to be trusted. Never pass
filenames provided by the user before validating and sanitizing them
first.
""" """
if content_type is None: if content_type is None:
ext = filename.split('.')[-1] ext = filename.split('.')[-1]
@@ -756,46 +833,58 @@ class Microdot():
else: else:
stream = sock stream = sock
req = Request.create(self, stream, addr) req = None
try:
req = Request.create(self, stream, addr)
except Exception as exc: # pragma: no cover
print_exception(exc)
if req: if req:
f = self.find_route(req) if req.content_length > req.max_content_length:
try: if 413 in self.error_handlers:
res = None res = self.error_handlers[413](req)
if f:
for handler in self.before_request_handlers:
res = handler(req)
if res:
break
if res is None:
res = f(req, **req.url_args)
if isinstance(res, tuple):
res = Response(*res)
elif not isinstance(res, Response):
res = Response(res)
for handler in self.after_request_handlers:
res = handler(req, res) or res
elif 404 in self.error_handlers:
res = self.error_handlers[404](req)
else: else:
res = 'Not found', 404 res = 'Payload too large', 413
except Exception as exc: else:
print_exception(exc) f = self.find_route(req)
res = None try:
if exc.__class__ in self.error_handlers: res = None
try: if f:
res = self.error_handlers[exc.__class__](req, exc) for handler in self.before_request_handlers:
except Exception as exc2: # pragma: no cover res = handler(req)
print_exception(exc2) if res:
if res is None: break
if 500 in self.error_handlers: if res is None:
res = self.error_handlers[500](req) res = f(req, **req.url_args)
if isinstance(res, tuple):
res = Response(*res)
elif not isinstance(res, Response):
res = Response(res)
for handler in self.after_request_handlers:
res = handler(req, res) or res
elif 404 in self.error_handlers:
res = self.error_handlers[404](req)
else: else:
res = 'Internal server error', 500 res = 'Not found', 404
if isinstance(res, tuple): except Exception as exc:
res = Response(*res) print_exception(exc)
elif not isinstance(res, Response): res = None
res = Response(res) if exc.__class__ in self.error_handlers:
res.write(stream) try:
res = self.error_handlers[exc.__class__](req, exc)
except Exception as exc2: # pragma: no cover
print_exception(exc2)
if res is None:
if 500 in self.error_handlers:
res = self.error_handlers[500](req)
else:
res = 'Internal server error', 500
else:
res = 'Bad request', 400
if isinstance(res, tuple):
res = Response(*res)
elif not isinstance(res, Response):
res = Response(res)
res.write(stream)
stream.close() stream.close()
if stream != sock: # pragma: no cover if stream != sock: # pragma: no cover
sock.close() sock.close()

View File

@@ -10,6 +10,12 @@ try:
import uasyncio as asyncio import uasyncio as asyncio
except ImportError: except ImportError:
import asyncio import asyncio
try:
import uio as io
except ImportError:
import io
from microdot import Microdot as BaseMicrodot from microdot import Microdot as BaseMicrodot
from microdot import print_exception from microdot import print_exception
from microdot import Request as BaseRequest from microdot import Request as BaseRequest
@@ -20,6 +26,23 @@ def _iscoroutine(coro):
return hasattr(coro, 'send') and hasattr(coro, 'throw') return hasattr(coro, 'send') and hasattr(coro, 'throw')
class _AsyncBytesIO:
def __init__(self, data):
self.stream = io.BytesIO(data)
async def read(self, n=-1):
return self.stream.read(n)
async def readline(self): # pragma: no cover
return self.stream.readline()
async def readexactly(self, n): # pragma: no cover
return self.stream.read(n)
async def readuntil(self, separator=b'\n'): # pragma: no cover
return self.stream.readuntil(separator=separator)
class Request(BaseRequest): class Request(BaseRequest):
@staticmethod @staticmethod
async def create(app, client_stream, client_addr): async def create(app, client_stream, client_addr):
@@ -34,7 +57,7 @@ class Request(BaseRequest):
object. object.
""" """
# request line # request line
line = (await client_stream.readline()).strip().decode() line = (await Request._safe_readline(client_stream)).strip().decode()
if not line: # pragma: no cover if not line: # pragma: no cover
return None return None
method, url, http_version = line.split() method, url, http_version = line.split()
@@ -44,21 +67,40 @@ class Request(BaseRequest):
headers = {} headers = {}
content_length = 0 content_length = 0
while True: while True:
line = (await client_stream.readline()).strip().decode() line = (await Request._safe_readline(
client_stream)).strip().decode()
if line == '': if line == '':
break break
header, value = line.split(':', 1) header, value = line.split(':', 1)
value = value.strip() value = value.strip()
headers[header] = value headers[header] = value
if header == 'Content-Length': if header.lower() == 'content-length':
content_length = int(value) content_length = int(value)
# body # body
body = await client_stream.read(content_length) \ body = b''
if content_length else b'' if content_length and content_length <= Request.max_body_length:
body = await client_stream.readexactly(content_length)
stream = None
else:
body = b''
stream = client_stream
return Request(app, client_addr, method, url, http_version, headers, return Request(app, client_addr, method, url, http_version, headers,
body) body=body, stream=stream)
@property
def stream(self):
if self._stream is None:
self._stream = _AsyncBytesIO(self._body)
return self._stream
@staticmethod
async def _safe_readline(stream):
line = (await stream.readline())
if len(line) > Request.max_readline:
raise ValueError('line too long')
return line
class Response(BaseResponse): class Response(BaseResponse):
@@ -69,14 +111,18 @@ class Response(BaseResponse):
:param status_code: The numeric HTTP status code of the response. The :param status_code: The numeric HTTP status code of the response. The
default is 200. default is 200.
:param headers: A dictionary of headers to include in the response. :param headers: A dictionary of headers to include in the response.
:param reason: A custom reason phrase to add after the status code. The
default is "OK" for responses with a 200 status code and
"N/A" for any other status codes.
""" """
async def write(self, stream): async def write(self, stream):
self.complete() self.complete()
# status code # status code
reason = self.reason if self.reason is not None else \
('OK' if self.status_code == 200 else 'N/A')
await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format( await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format(
status_code=self.status_code, status_code=self.status_code, reason=reason).encode())
reason='OK' if self.status_code == 200 else 'N/A').encode())
# headers # headers
for header, value in self.headers.items(): for header, value in self.headers.items():
@@ -95,7 +141,7 @@ class Response(BaseResponse):
await stream.awrite(buf) await stream.awrite(buf)
if len(buf) < self.send_file_buffer_size: if len(buf) < self.send_file_buffer_size:
break break
if hasattr(self.body, 'close'): if hasattr(self.body, 'close'): # pragma: no cover
self.body.close() self.body.close()
else: else:
await stream.awrite(self.body) await stream.awrite(self.body)
@@ -162,7 +208,14 @@ class Microdot(BaseMicrodot):
host=host, port=port)) host=host, port=port))
self.server = await asyncio.start_server(serve, host, port) self.server = await asyncio.start_server(serve, host, port)
await self.server.wait_closed() while True:
try:
await self.server.wait_closed()
break
except AttributeError: # pragma: no cover
# the task hasn't been initialized in the server object yet
# wait a bit and try again
await asyncio.sleep(0.1)
def run(self, host='0.0.0.0', port=5000, debug=False): def run(self, host='0.0.0.0', port=5000, debug=False):
"""Start the web server. This function does not normally return, as """Start the web server. This function does not normally return, as
@@ -199,52 +252,65 @@ class Microdot(BaseMicrodot):
self.server.close() self.server.close()
async def dispatch_request(self, reader, writer): async def dispatch_request(self, reader, writer):
req = await Request.create(self, reader, req = None
writer.get_extra_info('peername')) try:
req = await Request.create(self, reader,
writer.get_extra_info('peername'))
except Exception as exc: # pragma: no cover
print_exception(exc)
if req: if req:
f = self.find_route(req) if req.content_length > req.max_content_length:
try: if 413 in self.error_handlers:
res = None
if f:
for handler in self.before_request_handlers:
res = await self._invoke_handler(handler, req)
if res:
break
if res is None:
res = await self._invoke_handler(
f, req, **req.url_args)
if isinstance(res, tuple):
res = Response(*res)
elif not isinstance(res, Response):
res = Response(res)
for handler in self.after_request_handlers:
res = await self._invoke_handler(
handler, req, res) or res
elif 404 in self.error_handlers:
res = await self._invoke_handler( res = await self._invoke_handler(
self.error_handlers[404], req) self.error_handlers[413], req)
else: else:
res = 'Not found', 404 res = 'Payload too large', 413
except Exception as exc: else:
print_exception(exc) f = self.find_route(req)
res = None try:
if exc.__class__ in self.error_handlers: res = None
try: if f:
for handler in self.before_request_handlers:
res = await self._invoke_handler(handler, req)
if res:
break
if res is None:
res = await self._invoke_handler(
f, req, **req.url_args)
if isinstance(res, tuple):
res = Response(*res)
elif not isinstance(res, Response):
res = Response(res)
for handler in self.after_request_handlers:
res = await self._invoke_handler(
handler, req, res) or res
elif 404 in self.error_handlers:
res = await self._invoke_handler( res = await self._invoke_handler(
self.error_handlers[exc.__class__], req, exc) self.error_handlers[404], req)
except Exception as exc2: # pragma: no cover
print_exception(exc2)
if res is None:
if 500 in self.error_handlers:
res = await self._invoke_handler(
self.error_handlers[500], req)
else: else:
res = 'Internal server error', 500 res = 'Not found', 404
if isinstance(res, tuple): except Exception as exc:
res = Response(*res) print_exception(exc)
elif not isinstance(res, Response): res = None
res = Response(res) if exc.__class__ in self.error_handlers:
await res.write(writer) try:
res = await self._invoke_handler(
self.error_handlers[exc.__class__], req, exc)
except Exception as exc2: # pragma: no cover
print_exception(exc2)
if res is None:
if 500 in self.error_handlers:
res = await self._invoke_handler(
self.error_handlers[500], req)
else:
res = 'Internal server error', 500
else:
res = 'Bad request', 400
if isinstance(res, tuple):
res = Response(*res)
elif not isinstance(res, Response):
res = Response(res)
await res.write(writer)
await writer.aclose() await writer.aclose()
if self.debug and req: # pragma: no cover if self.debug and req: # pragma: no cover
print('{method} {path} {status_code}'.format( print('{method} {path} {status_code}'.format(

View File

@@ -65,6 +65,19 @@ class TestMicrodot(unittest.TestCase):
self.assertIn(b'Content-Type: text/plain\r\n', fd.response) self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nbar')) self.assertTrue(fd.response.endswith(b'\r\n\r\nbar'))
def test_empty_request(self):
app = Microdot()
mock_socket.clear_requests()
fd = mock_socket.FakeStream(b'\n')
mock_socket._requests.append(fd)
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n'))
self.assertIn(b'Content-Length: 11\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request'))
def test_method_decorators(self): def test_method_decorators(self):
app = Microdot() app = Microdot()
@@ -182,6 +195,42 @@ class TestMicrodot(unittest.TestCase):
self.assertIn(b'Content-Type: text/plain\r\n', fd.response) self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n404')) self.assertTrue(fd.response.endswith(b'\r\n\r\n404'))
def test_413(self):
app = Microdot()
@app.route('/')
def index(req):
return 'foo'
mock_socket.clear_requests()
fd = mock_socket.add_request('GET', '/foo', body='x' * 17000)
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 413 N/A\r\n'))
self.assertIn(b'Content-Length: 17\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nPayload too large'))
def test_413_handler(self):
app = Microdot()
@app.route('/')
def index(req):
return 'foo'
@app.errorhandler(413)
def handle_413(req):
return '413', 400
mock_socket.clear_requests()
fd = mock_socket.add_request('GET', '/foo', body='x' * 17000)
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n'))
self.assertIn(b'Content-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n413'))
def test_500(self): def test_500(self):
app = Microdot() app = Microdot()

View File

@@ -78,3 +78,51 @@ class TestRequest(unittest.TestCase):
body='foo=bar&abc=def&x=%2f%%') body='foo=bar&abc=def&x=%2f%%')
req = Request.create('app', fd, 'addr') req = Request.create('app', fd, 'addr')
self.assertIsNone(req.form) self.assertIsNone(req.form)
def test_large_line(self):
saved_max_readline = Request.max_readline
Request.max_readline = 16
fd = get_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded'},
body='foo=bar&abc=def&x=y')
with self.assertRaises(ValueError):
Request.create('app', fd, 'addr')
Request.max_readline = saved_max_readline
def test_stream(self):
fd = get_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '19'},
body='foo=bar&abc=def&x=y')
req = Request.create('app', fd, 'addr')
self.assertEqual(req.stream.read(), b'foo=bar&abc=def&x=y')
with self.assertRaises(RuntimeError):
req.body
def test_body(self):
fd = get_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '19'},
body='foo=bar&abc=def&x=y')
req = Request.create('app', fd, 'addr')
self.assertEqual(req.body, b'foo=bar&abc=def&x=y')
with self.assertRaises(RuntimeError):
req.stream
def test_large_payload(self):
saved_max_content_length = Request.max_content_length
saved_max_body_length = Request.max_body_length
Request.max_content_length = 32
Request.max_body_length = 16
fd = get_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded'},
body='foo=bar&abc=def&x=y')
req = Request.create('app', fd, 'addr')
self.assertEqual(req.body, b'')
self.assertEqual(req.stream.read(), b'foo=bar&abc=def&x=y')
Request.max_content_length = saved_max_content_length
Request.max_body_length = saved_max_body_length

View File

@@ -112,6 +112,28 @@ class TestResponse(unittest.TestCase):
self.assertEqual(res.headers, {'X-Test': 'Foo'}) self.assertEqual(res.headers, {'X-Test': 'Foo'})
self.assertEqual(res.body, b'foo') self.assertEqual(res.body, b'foo')
def test_create_with_reason(self):
res = Response('foo', reason='ALL GOOD!')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers, {})
self.assertEqual(res.reason, 'ALL GOOD!')
self.assertEqual(res.body, b'foo')
fd = io.BytesIO()
res.write(fd)
response = fd.getvalue()
self.assertIn(b'HTTP/1.0 200 ALL GOOD!\r\n', response)
def test_create_with_status_and_reason(self):
res = Response('not found', 404, reason='NOT FOUND')
self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers, {})
self.assertEqual(res.reason, 'NOT FOUND')
self.assertEqual(res.body, b'not found')
fd = io.BytesIO()
res.write(fd)
response = fd.getvalue()
self.assertIn(b'HTTP/1.0 404 NOT FOUND\r\n', response)
def test_cookies(self): def test_cookies(self):
res = Response('ok') res = Response('ok')
res.set_cookie('foo1', 'bar1') res.set_cookie('foo1', 'bar1')
@@ -145,6 +167,9 @@ class TestResponse(unittest.TestCase):
self.assertEqual(res.status_code, 301) self.assertEqual(res.status_code, 301)
self.assertEqual(res.headers['Location'], '/foo') self.assertEqual(res.headers['Location'], '/foo')
with self.assertRaises(ValueError):
Response.redirect('/foo\x0d\x0a\x0d\x0a<p>Foo</p>')
def test_send_file(self): def test_send_file(self):
files = [ files = [
('test.txt', 'text/plain'), ('test.txt', 'text/plain'),

View File

@@ -76,6 +76,19 @@ class TestMicrodotAsync(unittest.TestCase):
self.assertIn(b'Content-Type: text/plain\r\n', fd2.response) self.assertIn(b'Content-Type: text/plain\r\n', fd2.response)
self.assertTrue(fd2.response.endswith(b'\r\n\r\nbar-async')) self.assertTrue(fd2.response.endswith(b'\r\n\r\nbar-async'))
def test_empty_request(self):
app = Microdot()
mock_socket.clear_requests()
fd = mock_socket.FakeStream(b'\n')
mock_socket._requests.append(fd)
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n'))
self.assertIn(b'Content-Length: 11\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nBad request'))
def test_before_after_request(self): def test_before_after_request(self):
app = Microdot() app = Microdot()
@@ -160,6 +173,42 @@ class TestMicrodotAsync(unittest.TestCase):
self.assertIn(b'Content-Type: text/plain\r\n', fd.response) self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n404')) self.assertTrue(fd.response.endswith(b'\r\n\r\n404'))
def test_413(self):
app = Microdot()
@app.route('/')
def index(req):
return 'foo'
mock_socket.clear_requests()
fd = mock_socket.add_request('GET', '/foo', body='x' * 17000)
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 413 N/A\r\n'))
self.assertIn(b'Content-Length: 17\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\nPayload too large'))
def test_413_handler(self):
app = Microdot()
@app.route('/')
def index(req):
return 'foo'
@app.errorhandler(413)
async def handle_413(req):
return '413', 400
mock_socket.clear_requests()
fd = mock_socket.add_request('GET', '/foo', body='x' * 17000)
self._add_shutdown(app)
app.run()
self.assertTrue(fd.response.startswith(b'HTTP/1.0 400 N/A\r\n'))
self.assertIn(b'Content-Length: 3\r\n', fd.response)
self.assertIn(b'Content-Type: text/plain\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n413'))
def test_500(self): def test_500(self):
app = Microdot() app = Microdot()

View File

@@ -88,3 +88,43 @@ class TestRequestAsync(unittest.TestCase):
body='foo=bar&abc=def&x=%2f%%') body='foo=bar&abc=def&x=%2f%%')
req = _run(Request.create('app', fd, 'addr')) req = _run(Request.create('app', fd, 'addr'))
self.assertIsNone(req.form) self.assertIsNone(req.form)
def test_large_line(self):
saved_max_readline = Request.max_readline
Request.max_readline = 16
fd = get_async_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded'},
body='foo=bar&abc=def&x=y')
with self.assertRaises(ValueError):
_run(Request.create('app', fd, 'addr'))
Request.max_readline = saved_max_readline
def test_stream(self):
fd = get_async_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '19'},
body='foo=bar&abc=def&x=y')
req = _run(Request.create('app', fd, 'addr'))
self.assertEqual(req.body, b'foo=bar&abc=def&x=y')
data = _run(req.stream.read())
self.assertEqual(data, b'foo=bar&abc=def&x=y')
def test_large_payload(self):
saved_max_content_length = Request.max_content_length
saved_max_body_length = Request.max_body_length
Request.max_content_length = 32
Request.max_body_length = 16
fd = get_async_request_fd('GET', '/foo', headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': '19'},
body='foo=bar&abc=def&x=y')
req = _run(Request.create('app', fd, 'addr'))
self.assertEqual(req.body, b'')
data = _run(req.stream.read())
self.assertEqual(data, b'foo=bar&abc=def&x=y')
Request.max_content_length = saved_max_content_length
Request.max_body_length = saved_max_body_length

View File

@@ -85,6 +85,26 @@ class TestResponseAsync(unittest.TestCase):
self.assertIn(b'Content-Type: application/json\r\n', fd.response) self.assertIn(b'Content-Type: application/json\r\n', fd.response)
self.assertTrue(fd.response.endswith(b'\r\n\r\n[1, "2"]')) self.assertTrue(fd.response.endswith(b'\r\n\r\n[1, "2"]'))
def test_create_with_reason(self):
res = Response('foo', reason='ALL GOOD!')
self.assertEqual(res.status_code, 200)
self.assertEqual(res.headers, {})
self.assertEqual(res.reason, 'ALL GOOD!')
self.assertEqual(res.body, b'foo')
fd = FakeStreamAsync()
_run(res.write(fd))
self.assertIn(b'HTTP/1.0 200 ALL GOOD!\r\n', fd.response)
def test_create_with_status_and_reason(self):
res = Response('not found', 404, reason='NOT FOUND')
self.assertEqual(res.status_code, 404)
self.assertEqual(res.headers, {})
self.assertEqual(res.reason, 'NOT FOUND')
self.assertEqual(res.body, b'not found')
fd = FakeStreamAsync()
_run(res.write(fd))
self.assertIn(b'HTTP/1.0 404 NOT FOUND\r\n', fd.response)
def test_send_file(self): def test_send_file(self):
res = Response.send_file('tests/files/test.txt', res = Response.send_file('tests/files/test.txt',
content_type='text/html') content_type='text/html')

View File

@@ -56,7 +56,10 @@ class FakeStreamAsync:
async def readline(self): async def readline(self):
return self.stream.readline() return self.stream.readline()
async def read(self, n): async def read(self, n=-1):
return self.stream.read(n)
async def readexactly(self, n):
return self.stream.read(n) return self.stream.read(n)
async def awrite(self, data): async def awrite(self, data):

12
tox.ini
View File

@@ -13,17 +13,17 @@ python =
[testenv] [testenv]
commands= commands=
pip install -e microdot pip install -e .
pip install -e microdot-asyncio pytest -p no:logging --cov=src --cov-branch --cov-report=term-missing
coverage run --branch --include="microdot*.py" -m unittest tests deps=
coverage report --show-missing pytest
deps=coverage pytest-cov
[testenv:flake8] [testenv:flake8]
deps= deps=
flake8 flake8
commands= commands=
flake8 --ignore=W503 --exclude tests/libs microdot microdot-asyncio tests flake8 --ignore=W503 --exclude tests/libs src tests
[testenv:upy] [testenv:upy]
whitelist_externals=sh whitelist_externals=sh