From 6979265fa643ed982d062f38d386c37bbbef0d9b Mon Sep 17 00:00:00 2001 From: pgjones Date: Mon, 6 Jul 2020 20:54:26 +0100 Subject: [PATCH] Add `async` support This allows for async functions to be passed to the Flask class instance, for example as a view function, @app.route("/") async def index(): return "Async hello" this comes with a cost though of poorer performance than using the sync equivalent. asgiref is the standard way to run async code within a sync context, and is used in Django making it a safe and sane choice for this. --- CHANGES.rst | 2 ++ docs/async_await.rst | 46 ++++++++++++++++++++++++++++++++++++++++++ docs/design.rst | 12 +++++++++++ docs/index.rst | 1 + requirements/tests.in | 1 + requirements/tests.txt | 2 ++ setup.py | 5 ++++- src/flask/app.py | 6 +++--- src/flask/helpers.py | 41 +++++++++++++++++++++++++++++++++++++ src/flask/scaffold.py | 25 ++++++++++++++++++----- tests/test_async.py | 33 ++++++++++++++++++++++++++++++ 11 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 docs/async_await.rst create mode 100644 tests/test_async.py diff --git a/CHANGES.rst b/CHANGES.rst index d98d91fe..280a2dd5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -67,6 +67,8 @@ Unreleased - Add route decorators for common HTTP methods. For example, ``@app.post("/login")`` is a shortcut for ``@app.route("/login", methods=["POST"])``. :pr:`3907` +- Support async views, error handlers, before and after request, and + teardown functions. :pr:`3412` Version 1.1.2 diff --git a/docs/async_await.rst b/docs/async_await.rst new file mode 100644 index 00000000..b46fad3b --- /dev/null +++ b/docs/async_await.rst @@ -0,0 +1,46 @@ +.. _async_await: + +Using async and await +===================== + +.. versionadded:: 2.0 + +Routes, error handlers, before request, after request, and teardown +functions can all be coroutine functions if Flask is installed with +the ``async`` extra (``pip install flask[async]``). This allows code +such as, + +.. code-block:: python + + @app.route("/") + async def index(): + return await ... + +including the usage of any asyncio based libraries. + + +When to use Quart instead +------------------------- + +Flask's ``async/await`` support is less performant than async first +frameworks due to the way it is implemented. Therefore if you have a +mainly async codebase it would make sense to consider `Quart +`_. Quart is a reimplementation of +the Flask using ``async/await`` based on the ASGI standard (Flask is +based on the WSGI standard). + + +Decorators +---------- + +Decorators designed for Flask, such as those in Flask extensions are +unlikely to work. This is because the decorator will not await the +coroutine function nor will they themselves be awaitable. + + +Other event loops +----------------- + +At the moment Flask only supports asyncio - the +:meth:`flask.Flask.ensure_sync` should be overridden to support +alternative event loops. diff --git a/docs/design.rst b/docs/design.rst index ae76c921..b41a08c2 100644 --- a/docs/design.rst +++ b/docs/design.rst @@ -171,6 +171,18 @@ Also see the :doc:`/becomingbig` section of the documentation for some inspiration for larger applications based on Flask. +Async-await and ASGI support +---------------------------- + +Flask supports ``async`` coroutines for view functions, and certain +others by executing the coroutine on a seperate thread instead of +utilising an event loop on the main thread as an async first (ASGI) +frameworks would. This is necessary for Flask to remain backwards +compatibility with extensions and code built before ``async`` was +introduced into Python. This compromise introduces a performance cost +compared with the ASGI frameworks, due to the overhead of the threads. + + What Flask is, What Flask is Not -------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 151dde92..a1c49a90 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -59,6 +59,7 @@ instructions for web development with Flask. patterns/index deploying/index becomingbig + async_await API Reference diff --git a/requirements/tests.in b/requirements/tests.in index b5f5c912..88fe5481 100644 --- a/requirements/tests.in +++ b/requirements/tests.in @@ -1,4 +1,5 @@ pytest +asgiref blinker greenlet python-dotenv diff --git a/requirements/tests.txt b/requirements/tests.txt index 50c58b65..a44b876f 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -4,6 +4,8 @@ # # pip-compile requirements/tests.in # +asgiref==3.2.10 + # via -r requirements/tests.in attrs==20.3.0 # via pytest blinker==1.4 diff --git a/setup.py b/setup.py index 7ec4196f..88889f4c 100644 --- a/setup.py +++ b/setup.py @@ -9,5 +9,8 @@ setup( "itsdangerous>=0.24", "click>=5.1", ], - extras_require={"dotenv": ["python-dotenv"]}, + extras_require={ + "async": ["asgiref>=3.2"], + "dotenv": ["python-dotenv"], + }, ) diff --git a/src/flask/app.py b/src/flask/app.py index 5be079c7..65ec5046 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -1050,7 +1050,7 @@ class Flask(Scaffold): "View function mapping is overwriting an existing" f" endpoint function: {endpoint}" ) - self.view_functions[endpoint] = view_func + self.view_functions[endpoint] = self.ensure_sync(view_func) @setupmethod def template_filter(self, name=None): @@ -1165,7 +1165,7 @@ class Flask(Scaffold): .. versionadded:: 0.8 """ - self.before_first_request_funcs.append(f) + self.before_first_request_funcs.append(self.ensure_sync(f)) return f @setupmethod @@ -1198,7 +1198,7 @@ class Flask(Scaffold): .. versionadded:: 0.9 """ - self.teardown_appcontext_funcs.append(f) + self.teardown_appcontext_funcs.append(self.ensure_sync(f)) return f @setupmethod diff --git a/src/flask/helpers.py b/src/flask/helpers.py index 73a3fd82..46244d29 100644 --- a/src/flask/helpers.py +++ b/src/flask/helpers.py @@ -2,6 +2,7 @@ import os import socket import warnings from functools import update_wrapper +from functools import wraps from threading import RLock import werkzeug.utils @@ -729,3 +730,43 @@ def is_ip(value): return True return False + + +def run_async(func): + """Return a sync function that will run the coroutine function *func*.""" + try: + from asgiref.sync import async_to_sync + except ImportError: + raise RuntimeError( + "Install Flask with the 'async' extra in order to use async views." + ) + + @wraps(func) + def outer(*args, **kwargs): + """This function grabs the current context for the inner function. + + This is similar to the copy_current_xxx_context functions in the + ctx module, except it has an async inner. + """ + ctx = None + if _request_ctx_stack.top is not None: + ctx = _request_ctx_stack.top.copy() + + @wraps(func) + async def inner(*a, **k): + """This restores the context before awaiting the func. + + This is required as the func must be awaited within the + context. Simply calling func (as per the + copy_current_xxx_context functions) doesn't work as the + with block will close before the coroutine is awaited. + """ + if ctx is not None: + with ctx: + return await func(*a, **k) + else: + return await func(*a, **k) + + return async_to_sync(inner)(*args, **kwargs) + + return outer diff --git a/src/flask/scaffold.py b/src/flask/scaffold.py index 735c142c..7911bc71 100644 --- a/src/flask/scaffold.py +++ b/src/flask/scaffold.py @@ -4,6 +4,7 @@ import pkgutil import sys from collections import defaultdict from functools import update_wrapper +from inspect import iscoroutinefunction from jinja2 import FileSystemLoader from werkzeug.exceptions import default_exceptions @@ -12,6 +13,7 @@ from werkzeug.exceptions import HTTPException from .cli import AppGroup from .globals import current_app from .helpers import locked_cached_property +from .helpers import run_async from .helpers import send_from_directory from .templating import _default_template_ctx_processor @@ -484,7 +486,7 @@ class Scaffold: """ def decorator(f): - self.view_functions[endpoint] = f + self.view_functions[endpoint] = self.ensure_sync(f) return f return decorator @@ -508,7 +510,7 @@ class Scaffold: return value from the view, and further request handling is stopped. """ - self.before_request_funcs[None].append(f) + self.before_request_funcs.setdefault(None, []).append(self.ensure_sync(f)) return f @setupmethod @@ -524,7 +526,7 @@ class Scaffold: should not be used for actions that must execute, such as to close resources. Use :meth:`teardown_request` for that. """ - self.after_request_funcs[None].append(f) + self.after_request_funcs.setdefault(None, []).append(self.ensure_sync(f)) return f @setupmethod @@ -563,7 +565,7 @@ class Scaffold: debugger can still access it. This behavior can be controlled by the ``PRESERVE_CONTEXT_ON_EXCEPTION`` configuration variable. """ - self.teardown_request_funcs[None].append(f) + self.teardown_request_funcs.setdefault(None, []).append(self.ensure_sync(f)) return f @setupmethod @@ -659,7 +661,7 @@ class Scaffold: " instead." ) - self.error_handler_spec[None][code][exc_class] = f + self.error_handler_spec[None][code][exc_class] = self.ensure_sync(f) @staticmethod def _get_exc_class_and_code(exc_class_or_code): @@ -684,6 +686,19 @@ class Scaffold: else: return exc_class, None + def ensure_sync(self, func): + """Ensure that the returned function is sync and calls the async func. + + .. versionadded:: 2.0 + + Override if you wish to change how asynchronous functions are + run. + """ + if iscoroutinefunction(func): + return run_async(func) + else: + return func + def _endpoint_from_view_func(view_func): """Internal helper that returns the default endpoint for a given diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 00000000..d47d36ce --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,33 @@ +import asyncio + +import pytest + +from flask import abort +from flask import Flask +from flask import request + + +@pytest.fixture(name="async_app") +def _async_app(): + app = Flask(__name__) + + @app.route("/", methods=["GET", "POST"]) + async def index(): + await asyncio.sleep(0) + return request.method + + @app.route("/error") + async def error(): + abort(412) + + return app + + +def test_async_request_context(async_app): + test_client = async_app.test_client() + response = test_client.get("/") + assert b"GET" in response.get_data() + response = test_client.post("/") + assert b"POST" in response.get_data() + response = test_client.get("/error") + assert response.status_code == 412