Skip to content

Commit a42c189

Browse files
Make show toolbar callback function async/sync compatible. (#2066)
This checks if the SHOW_TOOLBAR_CALLBACK is a coroutine if we're in async mode and the reverse if it's not. It will automatically wrap the function with sync_to_async or async_to_sync when necessary. * ASGI check approach with added test and docs * async compatible require_toolbar and tests * add docs for async require_toolbar --------- Co-authored-by: Aman Pandey <[email protected]>
1 parent e5c9561 commit a42c189

File tree

7 files changed

+197
-62
lines changed

7 files changed

+197
-62
lines changed

debug_toolbar/decorators.py

+23-7
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,37 @@
11
import functools
22

3+
from asgiref.sync import iscoroutinefunction
34
from django.http import Http404
45
from django.utils.translation import get_language, override as language_override
56

67
from debug_toolbar import settings as dt_settings
78

89

910
def require_show_toolbar(view):
10-
@functools.wraps(view)
11-
def inner(request, *args, **kwargs):
12-
from debug_toolbar.middleware import get_show_toolbar
11+
"""
12+
Async compatible decorator to restrict access to a view
13+
based on the Debug Toolbar's visibility settings.
14+
"""
15+
from debug_toolbar.middleware import get_show_toolbar
16+
17+
if iscoroutinefunction(view):
1318

14-
show_toolbar = get_show_toolbar()
15-
if not show_toolbar(request):
16-
raise Http404
19+
@functools.wraps(view)
20+
async def inner(request, *args, **kwargs):
21+
show_toolbar = get_show_toolbar(async_mode=True)
22+
if not await show_toolbar(request):
23+
raise Http404
1724

18-
return view(request, *args, **kwargs)
25+
return await view(request, *args, **kwargs)
26+
else:
27+
28+
@functools.wraps(view)
29+
def inner(request, *args, **kwargs):
30+
show_toolbar = get_show_toolbar(async_mode=False)
31+
if not show_toolbar(request):
32+
raise Http404
33+
34+
return view(request, *args, **kwargs)
1935

2036
return inner
2137

debug_toolbar/middleware.py

+34-5
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
import socket
77
from functools import cache
88

9-
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
9+
from asgiref.sync import (
10+
async_to_sync,
11+
iscoroutinefunction,
12+
markcoroutinefunction,
13+
sync_to_async,
14+
)
1015
from django.conf import settings
1116
from django.utils.module_loading import import_string
1217

@@ -47,7 +52,12 @@ def show_toolbar(request):
4752

4853

4954
@cache
50-
def get_show_toolbar():
55+
def show_toolbar_func_or_path():
56+
"""
57+
Fetch the show toolbar callback from settings
58+
59+
Cached to avoid importing multiple times.
60+
"""
5161
# If SHOW_TOOLBAR_CALLBACK is a string, which is the recommended
5262
# setup, resolve it to the corresponding callable.
5363
func_or_path = dt_settings.get_config()["SHOW_TOOLBAR_CALLBACK"]
@@ -57,6 +67,23 @@ def get_show_toolbar():
5767
return func_or_path
5868

5969

70+
def get_show_toolbar(async_mode):
71+
"""
72+
Get the callback function to show the toolbar.
73+
74+
Will wrap the function with sync_to_async or
75+
async_to_sync depending on the status of async_mode
76+
and whether the underlying function is a coroutine.
77+
"""
78+
show_toolbar = show_toolbar_func_or_path()
79+
is_coroutine = iscoroutinefunction(show_toolbar)
80+
if is_coroutine and not async_mode:
81+
show_toolbar = async_to_sync(show_toolbar)
82+
elif not is_coroutine and async_mode:
83+
show_toolbar = sync_to_async(show_toolbar)
84+
return show_toolbar
85+
86+
6087
class DebugToolbarMiddleware:
6188
"""
6289
Middleware to set up Debug Toolbar on incoming request and render toolbar
@@ -82,7 +109,8 @@ def __call__(self, request):
82109
if self.async_mode:
83110
return self.__acall__(request)
84111
# Decide whether the toolbar is active for this request.
85-
show_toolbar = get_show_toolbar()
112+
show_toolbar = get_show_toolbar(async_mode=self.async_mode)
113+
86114
if not show_toolbar(request) or DebugToolbar.is_toolbar_request(request):
87115
return self.get_response(request)
88116
toolbar = DebugToolbar(request, self.get_response)
@@ -103,8 +131,9 @@ def __call__(self, request):
103131

104132
async def __acall__(self, request):
105133
# Decide whether the toolbar is active for this request.
106-
show_toolbar = get_show_toolbar()
107-
if not show_toolbar(request) or DebugToolbar.is_toolbar_request(request):
134+
show_toolbar = get_show_toolbar(async_mode=self.async_mode)
135+
136+
if not await show_toolbar(request) or DebugToolbar.is_toolbar_request(request):
108137
response = await self.get_response(request)
109138
return response
110139

docs/changes.rst

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ Pending
77
* Added Django 5.2 to the tox matrix.
88
* Updated package metadata to include well-known labels.
99
* Added resources section to the documentation.
10+
* Wrap ``SHOW_TOOLBAR_CALLBACK`` function with ``sync_to_async``
11+
or ``async_to_sync`` to allow sync/async compatibility.
12+
* Make ``require_toolbar`` decorator compatible to async views.
1013

1114
5.0.1 (2025-01-13)
1215
------------------

docs/panels.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ Panels can ship their own templates, static files and views.
321321
Any views defined for the third-party panel use the following decorators:
322322

323323
- ``debug_toolbar.decorators.require_show_toolbar`` - Prevents unauthorized
324-
access to the view.
324+
access to the view. This decorator is compatible with async views.
325325
- ``debug_toolbar.decorators.render_with_toolbar_language`` - Supports
326326
internationalization for any content rendered by the view. This will render
327327
the response with the :ref:`TOOLBAR_LANGUAGE <TOOLBAR_LANGUAGE>` rather than

tests/test_decorators.py

+43-3
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,57 @@
11
from unittest.mock import patch
22

3-
from django.http import HttpResponse
4-
from django.test import RequestFactory, TestCase
3+
from django.http import Http404, HttpResponse
4+
from django.test import AsyncRequestFactory, RequestFactory, TestCase
55
from django.test.utils import override_settings
66

7-
from debug_toolbar.decorators import render_with_toolbar_language
7+
from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar
88

99

1010
@render_with_toolbar_language
1111
def stub_view(request):
1212
return HttpResponse(200)
1313

1414

15+
@require_show_toolbar
16+
def stub_require_toolbar_view(request):
17+
return HttpResponse(200)
18+
19+
20+
@require_show_toolbar
21+
async def stub_require_toolbar_async_view(request):
22+
return HttpResponse(200)
23+
24+
25+
class TestRequireToolbar(TestCase):
26+
"""
27+
Tests require_toolbar functionality and async compatibility.
28+
"""
29+
30+
def setUp(self):
31+
self.factory = RequestFactory()
32+
self.async_factory = AsyncRequestFactory()
33+
34+
@override_settings(DEBUG=True)
35+
def test_require_toolbar_debug_true(self):
36+
response = stub_require_toolbar_view(self.factory.get("/"))
37+
self.assertEqual(response.status_code, 200)
38+
39+
def test_require_toolbar_debug_false(self):
40+
with self.assertRaises(Http404):
41+
stub_require_toolbar_view(self.factory.get("/"))
42+
43+
# Following tests additionally tests async compatibility
44+
# of require_toolbar decorator
45+
@override_settings(DEBUG=True)
46+
async def test_require_toolbar_async_debug_true(self):
47+
response = await stub_require_toolbar_async_view(self.async_factory.get("/"))
48+
self.assertEqual(response.status_code, 200)
49+
50+
async def test_require_toolbar_async_debug_false(self):
51+
with self.assertRaises(Http404):
52+
await stub_require_toolbar_async_view(self.async_factory.get("/"))
53+
54+
1555
@override_settings(DEBUG=True, LANGUAGE_CODE="fr")
1656
class RenderWithToolbarLanguageTestCase(TestCase):
1757
@override_settings(DEBUG_TOOLBAR_CONFIG={"TOOLBAR_LANGUAGE": "de"})

tests/test_middleware.py

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import asyncio
2+
from unittest.mock import patch
3+
4+
from django.contrib.auth.models import User
5+
from django.http import HttpResponse
6+
from django.test import AsyncRequestFactory, RequestFactory, TestCase, override_settings
7+
8+
from debug_toolbar.middleware import DebugToolbarMiddleware
9+
10+
11+
def show_toolbar_if_staff(request):
12+
# Hit the database, but always return True
13+
return User.objects.exists() or True
14+
15+
16+
async def ashow_toolbar_if_staff(request):
17+
# Hit the database, but always return True
18+
has_users = await User.objects.afirst()
19+
return has_users or True
20+
21+
22+
class MiddlewareSyncAsyncCompatibilityTestCase(TestCase):
23+
def setUp(self):
24+
self.factory = RequestFactory()
25+
self.async_factory = AsyncRequestFactory()
26+
27+
@override_settings(DEBUG=True)
28+
def test_sync_mode(self):
29+
"""
30+
test middleware switches to sync (__call__) based on get_response type
31+
"""
32+
33+
request = self.factory.get("/")
34+
middleware = DebugToolbarMiddleware(
35+
lambda x: HttpResponse("<html><body>Test app</body></html>")
36+
)
37+
38+
self.assertFalse(asyncio.iscoroutinefunction(middleware))
39+
40+
response = middleware(request)
41+
self.assertEqual(response.status_code, 200)
42+
self.assertIn(b"djdt", response.content)
43+
44+
@override_settings(DEBUG=True)
45+
async def test_async_mode(self):
46+
"""
47+
test middleware switches to async (__acall__) based on get_response type
48+
and returns a coroutine
49+
"""
50+
51+
async def get_response(request):
52+
return HttpResponse("<html><body>Test app</body></html>")
53+
54+
middleware = DebugToolbarMiddleware(get_response)
55+
request = self.async_factory.get("/")
56+
57+
self.assertTrue(asyncio.iscoroutinefunction(middleware))
58+
59+
response = await middleware(request)
60+
self.assertEqual(response.status_code, 200)
61+
self.assertIn(b"djdt", response.content)
62+
63+
@override_settings(DEBUG=True)
64+
@patch(
65+
"debug_toolbar.middleware.show_toolbar_func_or_path",
66+
return_value=ashow_toolbar_if_staff,
67+
)
68+
def test_async_show_toolbar_callback_sync_middleware(self, mocked_show):
69+
def get_response(request):
70+
return HttpResponse("<html><body>Hello world</body></html>")
71+
72+
middleware = DebugToolbarMiddleware(get_response)
73+
74+
request = self.factory.get("/")
75+
response = middleware(request)
76+
self.assertEqual(response.status_code, 200)
77+
self.assertIn(b"djdt", response.content)
78+
79+
@override_settings(DEBUG=True)
80+
@patch(
81+
"debug_toolbar.middleware.show_toolbar_func_or_path",
82+
return_value=show_toolbar_if_staff,
83+
)
84+
async def test_sync_show_toolbar_callback_async_middleware(self, mocked_show):
85+
async def get_response(request):
86+
return HttpResponse("<html><body>Hello world</body></html>")
87+
88+
middleware = DebugToolbarMiddleware(get_response)
89+
90+
request = self.async_factory.get("/")
91+
response = await middleware(request)
92+
self.assertEqual(response.status_code, 200)
93+
self.assertIn(b"djdt", response.content)

tests/test_middleware_compatibility.py

-46
This file was deleted.

0 commit comments

Comments
 (0)