diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..d4bb2cb
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = .
+BUILDDIR = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css
new file mode 100644
index 0000000..ad43638
--- /dev/null
+++ b/docs/_static/css/custom.css
@@ -0,0 +1,3 @@
+.py .class, .py .method, .py .property {
+ margin-top: 20px;
+}
diff --git a/docs/api.rst b/docs/api.rst
new file mode 100644
index 0000000..5ce62f4
--- /dev/null
+++ b/docs/api.rst
@@ -0,0 +1,56 @@
+API Reference
+=============
+
+``microdot`` module
+-------------------
+
+The ``microdot`` module defines a few classes that help implement HTTP-based
+servers for MicroPython and standard Python, with multithreading support for
+Python interpreters that support it.
+
+``Microdot`` class
+~~~~~~~~~~~~~~~~~~
+
+.. autoclass:: microdot.Microdot
+ :members:
+
+``Request`` class
+~~~~~~~~~~~~~~~~~
+
+.. autoclass:: microdot.Request
+ :members:
+
+``Response`` class
+~~~~~~~~~~~~~~~~~~
+
+.. autoclass:: microdot.Response
+ :members:
+
+``microdot_asyncio`` module
+---------------------------
+
+The ``microdot_asyncio`` module defines a few classes that help implement
+HTTP-based servers for MicroPython and standard Python that use ``asyncio``
+and coroutines.
+
+``Microdot`` class
+~~~~~~~~~~~~~~~~~~
+
+.. autoclass:: microdot_asyncio.Microdot
+ :inherited-members:
+ :members:
+
+``Request`` class
+~~~~~~~~~~~~~~~~~
+
+.. autoclass:: microdot_asyncio.Request
+ :inherited-members:
+ :members:
+
+``Response`` class
+~~~~~~~~~~~~~~~~~~
+
+.. autoclass:: microdot_asyncio.Response
+ :inherited-members:
+ :members:
+
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..397fce3
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,71 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+import sys
+sys.path.insert(0, os.path.abspath('../microdot'))
+sys.path.insert(1, os.path.abspath('../microdot-asyncio'))
+
+
+# -- Project information -----------------------------------------------------
+
+project = 'Microdot'
+copyright = '2021, Miguel Grinberg'
+author = 'Miguel Grinberg'
+
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+ 'sphinx.ext.autodoc',
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'alabaster'
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+html_css_files = [
+ 'css/custom.css',
+]
+
+html_theme_options = {
+ 'github_user': 'miguelgrinberg',
+ 'github_repo': 'microdot',
+ 'github_banner': True,
+ 'github_button': True,
+ 'github_type': 'star',
+ 'fixed_sidebar': True,
+}
+
+autodoc_default_options = {
+ 'member-order': 'bysource',
+}
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..4e28cde
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,19 @@
+.. Microdot documentation master file, created by
+ sphinx-quickstart on Fri Jun 4 17:40:19 2021.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Microdot
+========
+
+A minimalistic Python web framework for microcontrollers inspired by
+`Flask `_.
+
+.. toctree::
+ :maxdepth: 3
+
+ api
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..2119f51
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/microdot-asyncio/microdot_asyncio.py b/microdot-asyncio/microdot_asyncio.py
index b2b46de..0fad5db 100644
--- a/microdot-asyncio/microdot_asyncio.py
+++ b/microdot-asyncio/microdot_asyncio.py
@@ -1,3 +1,11 @@
+"""
+microdot_asyncio
+----------------
+
+The ``microdot_asyncio`` module defines a few classes that help implement
+HTTP-based servers for MicroPython and standard Python that use ``asyncio``
+and coroutines.
+"""
try:
import uasyncio as asyncio
except ImportError:
@@ -14,9 +22,19 @@ def _iscoroutine(coro):
class Request(BaseRequest):
@staticmethod
- async def create(app, stream, client_addr):
+ async def create(app, client_stream, client_addr):
+ """Create a request object.
+
+ :param app: The Microdot application instance.
+ :param client_stream: An input stream from where the request data can
+ be read.
+ :param client_addr: The address of the client, as a tuple.
+
+ This method is a coroutine. It returns a newly created ``Request``
+ object.
+ """
# request line
- line = (await stream.readline()).strip().decode()
+ line = (await client_stream.readline()).strip().decode()
if not line: # pragma: no cover
return None
method, url, http_version = line.split()
@@ -26,7 +44,7 @@ class Request(BaseRequest):
headers = {}
content_length = 0
while True:
- line = (await stream.readline()).strip().decode()
+ line = (await client_stream.readline()).strip().decode()
if line == '':
break
header, value = line.split(':', 1)
@@ -36,7 +54,7 @@ class Request(BaseRequest):
content_length = int(value)
# body
- body = await stream.read(content_length) \
+ body = await client_stream.read(content_length) \
if content_length else b''
return Request(app, client_addr, method, url, http_version, headers,
@@ -44,6 +62,14 @@ class Request(BaseRequest):
class Response(BaseResponse):
+ """An HTTP response class.
+
+ :param body: The body of the response. If a dictionary or list is given,
+ a JSON formatter is used to generate the body.
+ :param status_code: The numeric HTTP status code of the response. The
+ default is 200.
+ :param headers: A dictionary of headers to include in the response.
+ """
async def write(self, stream):
self.complete()
@@ -77,6 +103,41 @@ class Response(BaseResponse):
class Microdot(BaseMicrodot):
async def start_server(self, host='0.0.0.0', port=5000, debug=False):
+ """Start the Microdot web server as a coroutine. This coroutine does
+ not normally return, as the server enters an endless listening loop.
+ The :func:`shutdown` function provides a method for terminating the
+ server gracefully.
+
+ :param host: The hostname or IP address of the network interface that
+ will be listening for requests. A value of ``'0.0.0.0'``
+ (the default) indicates that the server should listen for
+ requests on all the available interfaces, and a value of
+ ``127.0.0.1`` indicates that the server should listen
+ for requests only on the internal networking interface of
+ the host.
+ :param port: The port number to listen for requests. The default is
+ port 5000.
+ :param debug: If ``True``, the server logs debugging information. The
+ default is ``False``.
+
+ This method is a coroutine.
+
+ Example::
+
+ import asyncio
+ from microdot_asyncio import Microdot
+
+ app = Microdot()
+
+ @app.route('/')
+ async def index():
+ return 'Hello, world!'
+
+ async def main():
+ await app.start_server(debug=True)
+
+ asyncio.run(main())
+ """
self.debug = debug
async def serve(reader, writer):
@@ -104,6 +165,34 @@ class Microdot(BaseMicrodot):
await self.server.wait_closed()
def run(self, host='0.0.0.0', port=5000, debug=False):
+ """Start the web server. This function does not normally return, as
+ the server enters an endless listening loop. The :func:`shutdown`
+ function provides a method for terminating the server gracefully.
+
+ :param host: The hostname or IP address of the network interface that
+ will be listening for requests. A value of ``'0.0.0.0'``
+ (the default) indicates that the server should listen for
+ requests on all the available interfaces, and a value of
+ ``127.0.0.1`` indicates that the server should listen
+ for requests only on the internal networking interface of
+ the host.
+ :param port: The port number to listen for requests. The default is
+ port 5000.
+ :param debug: If ``True``, the server logs debugging information. The
+ default is ``False``.
+
+ Example::
+
+ from microdot_asyncio import Microdot
+
+ app = Microdot()
+
+ @app.route('/')
+ async def index():
+ return 'Hello, world!'
+
+ app.run(debug=True)
+ """
asyncio.run(self.start_server(host=host, port=port, debug=debug))
def shutdown(self):
diff --git a/microdot/microdot.py b/microdot/microdot.py
index 55de3ea..7a0855d 100644
--- a/microdot/microdot.py
+++ b/microdot/microdot.py
@@ -1,3 +1,11 @@
+"""
+microdot
+--------
+
+The ``microdot`` module defines a few classes that help implement HTTP-based
+servers for MicroPython and standard Python, with multithreading support for
+Python interpreters that support it.
+"""
try:
from sys import print_exception
except ImportError: # pragma: no cover
@@ -16,21 +24,21 @@ try: # pragma: no cover
import threading
def create_thread(f, *args, **kwargs):
- """Use the threading module."""
+ # use the threading module
threading.Thread(target=f, args=args, kwargs=kwargs).start()
except ImportError: # pragma: no cover
try:
import _thread
def create_thread(f, *args, **kwargs):
- """Use MicroPython's _thread module."""
+ # use MicroPython's _thread module
def run():
f(*args, **kwargs)
_thread.start_new_thread(run, ())
except ImportError:
def create_thread(f, *args, **kwargs):
- """No threads available, call function synchronously."""
+ # no threads available, call function synchronously
f(*args, **kwargs)
concurrency_mode = 'sync'
@@ -70,6 +78,8 @@ def urldecode(string):
class Request():
+ """An HTTP request class."""
+
class G:
pass
@@ -106,6 +116,15 @@ class Request():
@staticmethod
def create(app, client_stream, client_addr):
+ """Create a request object.
+
+ :param app: The Microdot application instance.
+ :param client_stream: An input stream from where the request data can
+ be read.
+ :param client_addr: The address of the client, as a tuple.
+
+ This method returns a newly created ``Request`` object.
+ """
# request line
line = client_stream.readline().strip().decode()
if not line: # pragma: no cover
@@ -140,6 +159,9 @@ class Request():
@property
def json(self):
+ """The parsed JSON body of the request, or ``None`` if the request
+ does not have a JSON body.
+ """
if self.content_type != 'application/json':
return None
if self._json is None:
@@ -148,6 +170,9 @@ class Request():
@property
def form(self):
+ """The parsed form data from the request, or ``None`` if the request
+ does not have form data.
+ """
if self.content_type != 'application/x-www-form-urlencoded':
return None
if self._form is None:
@@ -156,6 +181,14 @@ class Request():
class Response():
+ """An HTTP response class.
+
+ :param body: The body of the response. If a dictionary or list is given,
+ a JSON formatter is used to generate the body.
+ :param status_code: The numeric HTTP status code of the response. The
+ default is 200.
+ :param headers: A dictionary of headers to include in the response.
+ """
types_map = {
'css': 'text/css',
'gif': 'image/gif',
@@ -182,6 +215,17 @@ class Response():
def set_cookie(self, cookie, value, path=None, domain=None, expires=None,
max_age=None, secure=False, http_only=False):
+ """Add a cookie to the response.
+
+ :param cookie: The cookie's name.
+ :param value: The cookie's value.
+ :param path: The cookie's path.
+ :param domain: The cookie's domain.
+ :param expires: The cookie expiration time, as a ``datetime`` object.
+ :param max_age: The cookie's ``Max-Age`` value.
+ :param secure: The cookie's ``secure`` flag.
+ :param http_only: The cookie's ``HttpOnly`` flag.
+ """
http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value)
if path:
http_cookie += '; Path=' + path
@@ -240,10 +284,25 @@ class Response():
@classmethod
def redirect(cls, location, status_code=302):
+ """Return a redirect response.
+
+ :param location: The URL to redirect to.
+ :param status_code: The 3xx status code to use for the redirect. The
+ default is 302.
+ """
return cls(status_code=status_code, headers={'Location': location})
@classmethod
def send_file(cls, filename, status_code=200, content_type=None):
+ """Send file contents in a response.
+
+ :param filename: The filename of the file.
+ :param status_code: The 3xx status code to use for the redirect. The
+ default is 302.
+ :param content_type: The ``Content-Type`` header to use in the
+ response. If omitted, it is generated
+ automatically from the file extension.
+ """
if content_type is None:
ext = filename.split('.')[-1]
if ext in Response.types_map:
@@ -308,6 +367,19 @@ class URLPattern():
class Microdot():
+ """An HTTP application class.
+
+ This class implements an HTTP application instance and is heavily
+ influenced by the ``Flask`` class of the Flask framework. It is typically
+ declared near the start of the main application script.
+
+ Example::
+
+ from microdot import Microdot
+
+ app = Microdot()
+ """
+
def __init__(self):
self.url_map = []
self.before_request_handlers = []
@@ -318,6 +390,35 @@ class Microdot():
self.server = None
def route(self, url_pattern, methods=None):
+ """Decorator that is used to register a function as a request handler
+ for a given URL.
+
+ :param url_pattern: The URL pattern that will be compared against
+ incoming requests.
+ :param methods: The list of HTTP methods to be handled by the
+ decorated function. If omitted, only ``GET`` requests
+ are handled.
+
+ The URL pattern can be a static path (for example, ``/users`` or
+ ``/api/invoices/search``) or a path with dynamic components enclosed
+ in ``<`` and ``>`` (for example, ``/users/`` or
+ ``/invoices//products``). Dynamic path components can also
+ include a type prefix, separated from the name with a colon (for
+ example, ``/users/``). The type can be ``string`` (the
+ default), ``int``, ``path`` or ``re:[regular-expression]``.
+
+ The first argument of the decorated function must be
+ the request object. Any path arguments that are specified in the URL
+ pattern are passed as keyword arguments. The return value of the
+ function must be a :class:`Response` instance, or the arguments to
+ be passed to this class.
+
+ Example::
+
+ @app.route('/')
+ def index(request):
+ return 'Hello, world!'
+ """
def decorated(f):
self.url_map.append(
(methods or ['GET'], URLPattern(url_pattern), f))
@@ -325,35 +426,179 @@ class Microdot():
return decorated
def get(self, url_pattern):
+ """Decorator that is used to register a function as a ``GET`` request
+ handler for a given URL.
+
+ :param url_pattern: The URL pattern that will be compared against
+ incoming requests.
+
+ This decorator can be used as an alias to the ``route`` decorator with
+ ``methods=['GET']``.
+
+ Example::
+
+ @app.get('/users/')
+ def get_user(request, id):
+ # ...
+ """
return self.route(url_pattern, methods=['GET'])
def post(self, url_pattern):
+ """Decorator that is used to register a function as a ``POST`` request
+ handler for a given URL.
+
+ :param url_pattern: The URL pattern that will be compared against
+ incoming requests.
+
+ This decorator can be used as an alias to the``route`` decorator with
+ ``methods=['POST']``.
+
+ Example::
+
+ @app.post('/users')
+ def create_user(request):
+ # ...
+ """
return self.route(url_pattern, methods=['POST'])
def put(self, url_pattern):
+ """Decorator that is used to register a function as a ``PUT`` request
+ handler for a given URL.
+
+ :param url_pattern: The URL pattern that will be compared against
+ incoming requests.
+
+ This decorator can be used as an alias to the ``route`` decorator with
+ ``methods=['PUT']``.
+
+ Example::
+
+ @app.put('/users/')
+ def edit_user(request, id):
+ # ...
+ """
return self.route(url_pattern, methods=['PUT'])
def patch(self, url_pattern):
+ """Decorator that is used to register a function as a ``PATCH`` request
+ handler for a given URL.
+
+ :param url_pattern: The URL pattern that will be compared against
+ incoming requests.
+
+ This decorator can be used as an alias to the ``route`` decorator with
+ ``methods=['PATCH']``.
+
+ Example::
+
+ @app.patch('/users/')
+ def edit_user(request, id):
+ # ...
+ """
return self.route(url_pattern, methods=['PATCH'])
def delete(self, url_pattern):
+ """Decorator that is used to register a function as a ``DELETE``
+ request handler for a given URL.
+
+ :param url_pattern: The URL pattern that will be compared against
+ incoming requests.
+
+ This decorator can be used as an alias to the ``route`` decorator with
+ ``methods=['DELETE']``.
+
+ Example::
+
+ @app.delete('/users/')
+ def delete_user(request, id):
+ # ...
+ """
return self.route(url_pattern, methods=['DELETE'])
def before_request(self, f):
+ """Decorator to register a function to run before each request is
+ handled. The decorated function must take a single argument, the
+ request object.
+
+ Example::
+
+ @app.before_request
+ def func(request):
+ # ...
+ """
self.before_request_handlers.append(f)
return f
def after_request(self, f):
+ """Decorator to register a function to run after each request is
+ handled. The decorated function must take two arguments, the request
+ and response objects. The return value of the function must be an
+ updated response object.
+
+ Example::
+
+ @app.before_request
+ def func(request, response):
+ # ...
+ """
self.after_request_handlers.append(f)
return f
def errorhandler(self, status_code_or_exception_class):
+ """Decorator to register a function as an error handler. Error handler
+ functions for numeric HTTP status codes must accept a single argument,
+ the request object. Error handler functions for Python exceptions
+ must accept two arguments, the request object and the exception
+ object.
+
+ :param status_code_or_exception_class: The numeric HTTP status code or
+ Python exception class to
+ handle.
+
+ Examples::
+
+ @app.errorhandler(404)
+ def not_found(request):
+ return 'Not found'
+
+ @app.errorhandler(RuntimeError)
+ def runtime_error(request, exception):
+ return 'Runtime error'
+ """
def decorated(f):
self.error_handlers[status_code_or_exception_class] = f
return f
return decorated
def run(self, host='0.0.0.0', port=5000, debug=False):
+ """Start the web server. This function does not normally return, as
+ the server enters an endless listening loop. The :func:`shutdown`
+ function provides a method for terminating the server gracefully.
+
+ :param host: The hostname or IP address of the network interface that
+ will be listening for requests. A value of ``'0.0.0.0'``
+ (the default) indicates that the server should listen for
+ requests on all the available interfaces, and a value of
+ ``127.0.0.1`` indicates that the server should listen
+ for requests only on the internal networking interface of
+ the host.
+ :param port: The port number to listen for requests. The default is
+ port 5000.
+ :param debug: If ``True``, the server logs debugging information. The
+ default is ``False``.
+
+ Example::
+
+ from microdot import Microdot
+
+ app = Microdot()
+
+ @app.route('/')
+ def index():
+ return 'Hello, world!'
+
+ app.run(debug=True)
+ """
self.debug = debug
self.shutdown_requested = False
@@ -379,6 +624,18 @@ class Microdot():
create_thread(self.dispatch_request, sock, addr)
def shutdown(self):
+ """Request a server shutdown. The server will then exit its request
+ listening loop and the :func:`run` function will return. This function
+ can be safely called from a route handler, as it only schedules the
+ server to terminate as soon as the request completes.
+
+ Example::
+
+ @app.route('/shutdown')
+ def shutdown(request):
+ request.app.shutdown()
+ return 'The server is shutting down...'
+ """
self.shutdown_requested = True
def find_route(self, req):