pallets-eco / flask-debugtoolbar

A toolbar overlay for debugging Flask applications
https://flask-debugtoolbar.readthedocs.io
BSD 3-Clause "New" or "Revised" License
952 stars 146 forks source link

incompatible with flask 2 and async #158

Open abulka opened 3 years ago

abulka commented 3 years ago

It seems that flask-debugtoolbar is incompatible with flask 2 async endpoints.

Repro:

Run this flask project (app.py) and hit /data to trigger an async call - which works.

Then uncomment the line enabling the DebugToolbarExtension and we get an exception.

import asyncio
from flask import Flask
from flask import render_template
from icecream import ic
from flask_debugtoolbar import DebugToolbarExtension

app = Flask(__name__)

app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
app.config['JSON_SORT_KEYS'] = False
app.secret_key = 'super secret key'

# flask debug toolbar - https://flask-debugtoolbar.readthedocs.io/en/latest/index.html#configuration
app.debug = True
app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False
app.config['DEBUG_TB_TEMPLATE_EDITOR_ENABLED'] = True
# toolbar = DebugToolbarExtension(app)  # <<<< UNCOMMENT THIS AND WATCH IT CRASH

@app.route('/')
def root():
    return "hi"

async def async_get_data():
    ic('in async function')
    await asyncio.sleep(2)
    ic('out of async function')
    return 'Done!'

@app.route("/data")
async def get_data():
    data = await async_get_data()
    return data
renegadevi commented 3 years ago

Same issue when using async. I am getting this error message.

Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 2073, in wsgi_app
    response = self.handle_exception(e)
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 2070, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1516, in full_dispatch_request
    return self.finalize_request(rv)
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1535, in finalize_request
    response = self.make_response(rv)
  File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1727, in make_response
    raise TypeError(
TypeError: The view function did not return a valid response. The return type must be a string, dict, tuple, Response instance, or WSGI callable, but it was a coroutine.

Edit: Did work (sometimes) if wrap it inside a render_template(), but for other responses like json, instead of ignore it like it should, it tries to run it anyway if using async.

foarsitter commented 3 years ago

The flask-debugtoolbar has its own dispatch_request method which implementation differs from the Flask version.

https://github.com/pallets/flask/blob/44bc286c03ff3f8e783b4f79f75eb3a464940ca0/src/flask/app.py#L1480-L1502

    def dispatch_request(self) -> ResponseReturnValue:
        """Does the request dispatching.  Matches the URL and returns the
        return value of the view or error handler.  This does not have to
        be a response object.  In order to convert the return value to a
        proper response object, call :func:`make_response`.

        .. versionchanged:: 0.7
           This no longer does the exception handling, this code was
           moved to the new :meth:`full_dispatch_request`.
        """
        req = _request_ctx_stack.top.request
        if req.routing_exception is not None:
            self.raise_routing_exception(req)
        rule = req.url_rule
        # if we provide automatic options for this URL and the
        # request came with the OPTIONS method, reply automatically
        if (
            getattr(rule, "provide_automatic_options", False)
            and req.method == "OPTIONS"
        ):
            return self.make_default_options_response()
        # otherwise dispatch to the handler for that endpoint
        return self.ensure_sync(self.view_functions[rule.endpoint])(**req.view_args)

https://github.com/flask-debugtoolbar/flask-debugtoolbar/blob/d474a6a689be916d65c2adf173e6517290902abe/flask_debugtoolbar/__init__.py#L117-L137

I'm not an expert on this but I guess calling ensure_sync in dispatch_request will solve this.

jeffwidman commented 2 years ago

Happy to merge a PR if you or anyone else wants to dig into it.

On the surface, it looks relatively straightforward--port the updated version from Flask itself into here, along with the update to call process_view, but I'm sure there's a wrinkle or two to keep things interesting.

My current work is unrelated to Flask, so won't have time to look into it myself anytime soon.

caarmen commented 3 months ago

I've quickly tried the following hack/changes. Seems to work:

:one: override DebugToolbarExtension to handle coroutines:

from asyncio import iscoroutinefunction
from asgiref.sync import async_to_sync
from flask_debugtoolbar import DebugToolbarExtension

class MyDebugToolbarExtension(DebugToolbarExtension):
    def process_view(self, app, view_func, view_kwargs):
        processed_view = super().process_view(app, view_func, view_kwargs)
        if iscoroutinefunction(processed_view):
            processed_view = async_to_sync(processed_view)
        return processed_view

:two: Enable sql query recording of an async engine:

from flask_sqlalchemy import record_queries
from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(db_url)
record_queries._listen(engine.sync_engine)

Note that this is calling a "private" function, _listen in the record_queries module.

Disclaimer: I've only just discovered flask-sqlalchemy and flask-debugtoolbar, so there may be better ways of doing this!