Skip to content

Make show toolbar callback function async/sync compatible. #2066

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion debug_toolbar/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def require_show_toolbar(view):
def inner(request, *args, **kwargs):
from debug_toolbar.middleware import get_show_toolbar

show_toolbar = get_show_toolbar()
show_toolbar = get_show_toolbar(async_mode=False)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we pass async_mode based on the type of request WSGIRequest or ASGIRequest here ? I mean I am not sure whether require_show_toolbar would ever be exposed to a custom async view in async context or vice versa but this would save the conversion as it slows down the overall process. thoughts?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping require_show_toolbar was an undocumented API, but it is documented. Yes, we need to pass in a better value than always False. Good catch @salty-ivy!

It feels like we'd want async_mode defined by whether view is a coroutine though rather than based on WSGIRequest vs ASGIRequest. Not 100% sure though.

Copy link
Member

@salty-ivy salty-ivy Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can possible break in a situation where everything is in async context
including show_toolbar call back and if I use require_show_toolbar on an async view it will forcefully convert it into a sync func and we would get into similar error given that require_show_toolbar is exposed to such conditions :D

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like we'd want async_mode defined by whether view is a coroutine though rather than based on WSGIRequest vs ASGIRequest. Not 100% sure though.

may be checking both but it seems like that would cause a rabbit hole : (

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh. I totally missed that this decorator needs to be async friendly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like we'd want async_mode defined by whether view is a coroutine though rather than based on WSGIRequest vs ASGIRequest. Not 100% sure though.

This is correct, we should check whether view is a coroutine or not since django also converts the request object based on that, just checked : D

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah missed that too and yeah a iscoroutinefunction is the way other django decorator work.

if not show_toolbar(request):
raise Http404

Expand Down
39 changes: 34 additions & 5 deletions debug_toolbar/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import socket
from functools import cache

from asgiref.sync import iscoroutinefunction, markcoroutinefunction
from asgiref.sync import (
async_to_sync,
iscoroutinefunction,
markcoroutinefunction,
sync_to_async,
)
from django.conf import settings
from django.utils.module_loading import import_string

Expand Down Expand Up @@ -47,7 +52,12 @@ def show_toolbar(request):


@cache
def get_show_toolbar():
def show_toolbar_func_or_path():
"""
Fetch the show toolbar callback from settings

Cached to avoid importing multiple times.
"""
# If SHOW_TOOLBAR_CALLBACK is a string, which is the recommended
# setup, resolve it to the corresponding callable.
func_or_path = dt_settings.get_config()["SHOW_TOOLBAR_CALLBACK"]
Expand All @@ -57,6 +67,23 @@ def get_show_toolbar():
return func_or_path


def get_show_toolbar(async_mode):
"""
Get the callback function to show the toolbar.

Will wrap the function with sync_to_async or
async_to_sync depending on the status of async_mode
and whether the underlying function is a coroutine.
"""
show_toolbar = show_toolbar_func_or_path()
is_coroutine = iscoroutinefunction(show_toolbar)
if is_coroutine and not async_mode:
show_toolbar = async_to_sync(show_toolbar)
elif not is_coroutine and async_mode:
show_toolbar = sync_to_async(show_toolbar)
return show_toolbar


class DebugToolbarMiddleware:
"""
Middleware to set up Debug Toolbar on incoming request and render toolbar
Expand All @@ -82,7 +109,8 @@ def __call__(self, request):
if self.async_mode:
return self.__acall__(request)
# Decide whether the toolbar is active for this request.
show_toolbar = get_show_toolbar()
show_toolbar = get_show_toolbar(async_mode=self.async_mode)

if not show_toolbar(request) or DebugToolbar.is_toolbar_request(request):
return self.get_response(request)
toolbar = DebugToolbar(request, self.get_response)
Expand All @@ -103,8 +131,9 @@ def __call__(self, request):

async def __acall__(self, request):
# Decide whether the toolbar is active for this request.
show_toolbar = get_show_toolbar()
if not show_toolbar(request) or DebugToolbar.is_toolbar_request(request):
show_toolbar = get_show_toolbar(async_mode=self.async_mode)

if not await show_toolbar(request) or DebugToolbar.is_toolbar_request(request):
response = await self.get_response(request)
return response

Expand Down
2 changes: 2 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Pending
-------

* Added Django 5.2 to the tox matrix.
* Wrap ``SHOW_TOOLBAR_CALLBACK`` function with ``sync_to_async``
or ``async_to_sync`` to allow sync/async compatibility.

5.0.1 (2025-01-13)
------------------
Expand Down
93 changes: 93 additions & 0 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import asyncio
from unittest.mock import patch

from django.contrib.auth.models import User
from django.http import HttpResponse
from django.test import AsyncRequestFactory, RequestFactory, TestCase, override_settings

from debug_toolbar.middleware import DebugToolbarMiddleware


def show_toolbar_if_staff(request):
# Hit the database, but always return True
return User.objects.exists() or True


async def ashow_toolbar_if_staff(request):
# Hit the database, but always return True
has_users = await User.objects.afirst()
return has_users or True


class MiddlewareSyncAsyncCompatibilityTestCase(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.async_factory = AsyncRequestFactory()

@override_settings(DEBUG=True)
def test_sync_mode(self):
"""
test middleware switches to sync (__call__) based on get_response type
"""

request = self.factory.get("/")
middleware = DebugToolbarMiddleware(
lambda x: HttpResponse("<html><body>Test app</body></html>")
)

self.assertFalse(asyncio.iscoroutinefunction(middleware))

response = middleware(request)
self.assertEqual(response.status_code, 200)
self.assertIn(b"djdt", response.content)

@override_settings(DEBUG=True)
async def test_async_mode(self):
"""
test middleware switches to async (__acall__) based on get_response type
and returns a coroutine
"""

async def get_response(request):
return HttpResponse("<html><body>Test app</body></html>")

middleware = DebugToolbarMiddleware(get_response)
request = self.async_factory.get("/")

self.assertTrue(asyncio.iscoroutinefunction(middleware))

response = await middleware(request)
self.assertEqual(response.status_code, 200)
self.assertIn(b"djdt", response.content)

@override_settings(DEBUG=True)
@patch(
"debug_toolbar.middleware.show_toolbar_func_or_path",
return_value=ashow_toolbar_if_staff,
)
def test_async_show_toolbar_callback_sync_middleware(self, mocked_show):
def get_response(request):
return HttpResponse("<html><body>Hello world</body></html>")

middleware = DebugToolbarMiddleware(get_response)

request = self.factory.get("/")
response = middleware(request)
self.assertEqual(response.status_code, 200)
self.assertIn(b"djdt", response.content)

@override_settings(DEBUG=True)
@patch(
"debug_toolbar.middleware.show_toolbar_func_or_path",
return_value=show_toolbar_if_staff,
)
async def test_sync_show_toolbar_callback_async_middleware(self, mocked_show):
async def get_response(request):
return HttpResponse("<html><body>Hello world</body></html>")

middleware = DebugToolbarMiddleware(get_response)

request = self.async_factory.get("/")
response = await middleware(request)
self.assertEqual(response.status_code, 200)
self.assertIn(b"djdt", response.content)
46 changes: 0 additions & 46 deletions tests/test_middleware_compatibility.py

This file was deleted.