Skip to content
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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.