From 12cd60305b7b48ab151da52661fc5988684dbcd8 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Fri, 4 Jun 2021 17:50:48 +0100 Subject: [PATCH] Documentation --- docs/Makefile | 20 ++ docs/_static/css/custom.css | 3 + docs/api.rst | 56 ++++++ docs/conf.py | 71 ++++++++ docs/index.rst | 19 ++ docs/make.bat | 35 ++++ microdot-asyncio/microdot_asyncio.py | 97 +++++++++- microdot/microdot.py | 263 ++++++++++++++++++++++++++- 8 files changed, 557 insertions(+), 7 deletions(-) create mode 100644 docs/Makefile create mode 100644 docs/_static/css/custom.css create mode 100644 docs/api.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat 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):