Skip to content
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

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

Merged
merged 17 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
30 changes: 23 additions & 7 deletions debug_toolbar/decorators.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
import functools

from asgiref.sync import iscoroutinefunction
from django.http import Http404
from django.utils.translation import get_language, override as language_override

from debug_toolbar import settings as dt_settings


def require_show_toolbar(view):
@functools.wraps(view)
def inner(request, *args, **kwargs):
from debug_toolbar.middleware import get_show_toolbar
"""
Async compatible decorator to restrict access to a view
based on the Debug Toolbar's visibility settings.
"""
from debug_toolbar.middleware import get_show_toolbar

if iscoroutinefunction(view):

show_toolbar = get_show_toolbar()
if not show_toolbar(request):
raise Http404
@functools.wraps(view)
async def inner(request, *args, **kwargs):
show_toolbar = get_show_toolbar(async_mode=True)
if not await show_toolbar(request):
raise Http404

return view(request, *args, **kwargs)
return await view(request, *args, **kwargs)
else:

@functools.wraps(view)
def inner(request, *args, **kwargs):
show_toolbar = get_show_toolbar(async_mode=False)
if not show_toolbar(request):
raise Http404

return view(request, *args, **kwargs)

return inner

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
3 changes: 3 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ Pending
* Added Django 5.2 to the tox matrix.
* Updated package metadata to include well-known labels.
* Added resources section to the documentation.
* Wrap ``SHOW_TOOLBAR_CALLBACK`` function with ``sync_to_async``
or ``async_to_sync`` to allow sync/async compatibility.
* Make ``require_toolbar`` decorator compatible to async views.

5.0.1 (2025-01-13)
------------------
Expand Down
2 changes: 1 addition & 1 deletion docs/panels.rst
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ Panels can ship their own templates, static files and views.
Any views defined for the third-party panel use the following decorators:

- ``debug_toolbar.decorators.require_show_toolbar`` - Prevents unauthorized
access to the view.
access to the view. This decorator is compatible with async views.
- ``debug_toolbar.decorators.render_with_toolbar_language`` - Supports
internationalization for any content rendered by the view. This will render
the response with the :ref:`TOOLBAR_LANGUAGE <TOOLBAR_LANGUAGE>` rather than
Expand Down
46 changes: 43 additions & 3 deletions tests/test_decorators.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,57 @@
from unittest.mock import patch

from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from django.http import Http404, HttpResponse
from django.test import AsyncRequestFactory, RequestFactory, TestCase
from django.test.utils import override_settings

from debug_toolbar.decorators import render_with_toolbar_language
from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar


@render_with_toolbar_language
def stub_view(request):
return HttpResponse(200)


@require_show_toolbar
def stub_require_toolbar_view(request):
return HttpResponse(200)


@require_show_toolbar
async def stub_require_toolbar_async_view(request):
return HttpResponse(200)


class TestRequireToolbar(TestCase):
"""
Tests require_toolbar functionality and async compatibility.
"""

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

@override_settings(DEBUG=True)
def test_require_toolbar_debug_true(self):
response = stub_require_toolbar_view(self.factory.get("/"))
self.assertEqual(response.status_code, 200)

def test_require_toolbar_debug_false(self):
with self.assertRaises(Http404):
stub_require_toolbar_view(self.factory.get("/"))

# Following tests additionally tests async compatibility
# of require_toolbar decorator
@override_settings(DEBUG=True)
async def test_require_toolbar_async_debug_true(self):
response = await stub_require_toolbar_async_view(self.async_factory.get("/"))
self.assertEqual(response.status_code, 200)

async def test_require_toolbar_async_debug_false(self):
with self.assertRaises(Http404):
await stub_require_toolbar_async_view(self.async_factory.get("/"))


@override_settings(DEBUG=True, LANGUAGE_CODE="fr")
class RenderWithToolbarLanguageTestCase(TestCase):
@override_settings(DEBUG_TOOLBAR_CONFIG={"TOOLBAR_LANGUAGE": "de"})
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.