Skip to content

Async compatible debug-toolbar middleware #1938

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 1 commit into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 44 additions & 1 deletion debug_toolbar/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import socket
from functools import lru_cache

from asgiref.sync import iscoroutinefunction, markcoroutinefunction
from django.conf import settings
from django.utils.module_loading import import_string

Expand Down Expand Up @@ -62,14 +63,50 @@ class DebugToolbarMiddleware:
on outgoing response.
"""

sync_capable = True
async_capable = True

def __init__(self, get_response):
self.get_response = get_response
# If get_response is a coroutine function, turns us into async mode so
# a thread is not consumed during a whole request.
self.async_mode = iscoroutinefunction(self.get_response)

if self.async_mode:
# Mark the class as async-capable, but do the actual switch inside
# __call__ to avoid swapping out dunder methods.
markcoroutinefunction(self)

def __call__(self, request):
# Decide whether the toolbar is active for this request.
if self.async_mode:
return self.__acall__(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):
return self.get_response(request)
toolbar = DebugToolbar(request, self.get_response)
# Activate instrumentation ie. monkey-patch.
for panel in toolbar.enabled_panels:
panel.enable_instrumentation()
try:
# Run panels like Django middleware.
response = toolbar.process_request(request)
finally:
clear_stack_trace_caches()
# Deactivate instrumentation ie. monkey-unpatch. This must run
# regardless of the response. Keep 'return' clauses below.
for panel in reversed(toolbar.enabled_panels):
panel.disable_instrumentation()

return self._postprocess(request, response, toolbar)

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):
response = await self.get_response(request)
return response

toolbar = DebugToolbar(request, self.get_response)

Expand All @@ -78,14 +115,20 @@ def __call__(self, request):
panel.enable_instrumentation()
try:
# Run panels like Django middleware.
response = toolbar.process_request(request)
response = await toolbar.process_request(request)
finally:
clear_stack_trace_caches()
# Deactivate instrumentation ie. monkey-unpatch. This must run
# regardless of the response. Keep 'return' clauses below.
for panel in reversed(toolbar.enabled_panels):
panel.disable_instrumentation()

return self._postprocess(request, response, toolbar)

def _postprocess(self, request, response, toolbar):
"""
Post-process the response.
"""
# Generate the stats for all requests when the toolbar is being shown,
# but not necessarily inserted.
for panel in reversed(toolbar.enabled_panels):
Expand Down
7 changes: 7 additions & 0 deletions debug_toolbar/panels/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.core.handlers.asgi import ASGIRequest
from django.template.loader import render_to_string

from debug_toolbar import settings as dt_settings
Expand All @@ -9,6 +10,8 @@ class Panel:
Base class for panels.
"""

is_async = True

def __init__(self, toolbar, get_response):
self.toolbar = toolbar
self.get_response = get_response
Expand All @@ -21,6 +24,10 @@ def panel_id(self):

@property
def enabled(self) -> bool:
# check if the panel is async compatible
if not self.is_async and isinstance(self.toolbar.request, ASGIRequest):
return False

# The user's cookies should override the default value
cookie_value = self.toolbar.request.COOKIES.get("djdt" + self.panel_id)
if cookie_value is not None:
Expand Down
1 change: 1 addition & 0 deletions debug_toolbar/panels/profiling.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ class ProfilingPanel(Panel):
Panel that displays profiling information.
"""

is_async = False
title = _("Profiling")

template = "debug_toolbar/panels/profiling.html"
Expand Down
1 change: 1 addition & 0 deletions debug_toolbar/panels/redirects.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class RedirectsPanel(Panel):
Panel that intercepts redirects and displays a page with debug info.
"""

is_async = False
has_content = False

nav_title = _("Intercept redirects")
Expand Down
2 changes: 2 additions & 0 deletions debug_toolbar/panels/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class RequestPanel(Panel):

title = _("Request")

is_async = False

@property
def nav_subtitle(self):
"""
Expand Down
2 changes: 2 additions & 0 deletions debug_toolbar/panels/sql/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ class SQLPanel(Panel):
the request.
"""

is_async = False

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._sql_time = 0
Expand Down
1 change: 1 addition & 0 deletions debug_toolbar/panels/staticfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class StaticFilesPanel(panels.Panel):
A panel to display the found staticfiles.
"""

is_async = False
name = "Static files"
template = "debug_toolbar/panels/staticfiles.html"

Expand Down
2 changes: 2 additions & 0 deletions debug_toolbar/panels/timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class TimerPanel(Panel):
Panel that displays the time a response took in milliseconds.
"""

is_async = False

def nav_subtitle(self):
stats = self.get_stats()
if hasattr(self, "_start_rusage"):
Expand Down
10 changes: 7 additions & 3 deletions docs/architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ Problematic Parts
when the panel module is loaded
- ``debug.panels.sql``: This package is particularly complex, but provides
the main benefit of the toolbar
- Support for async and multi-threading: This is currently unsupported, but
is being implemented as per the
`Async compatible toolbar project <https://github.com/orgs/jazzband/projects/9>`_.
- Support for async and multi-threading: ``debug_toolbar.middleware.DebugToolbarMiddleware``
is now async compatible and can process async requests. However certain
panels such as ``SQLPanel``, ``TimerPanel``, ``StaticFilesPanel``,
``RequestPanel``, ``RedirectsPanel`` and ``ProfilingPanel`` aren't fully
compatible and currently being worked on. For now, these panels
are disabled by default when running in async environment.
follow the progress of this issue in `Async compatible toolbar project <https://github.com/orgs/jazzband/projects/9>`_.
39 changes: 39 additions & 0 deletions tests/panels/test_async_panel_compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from django.http import HttpResponse
from django.test import AsyncRequestFactory, RequestFactory, TestCase

from debug_toolbar.panels import Panel
from debug_toolbar.toolbar import DebugToolbar


class MockAsyncPanel(Panel):
is_async = True


class MockSyncPanel(Panel):
is_async = False


class PanelAsyncCompatibilityTestCase(TestCase):
def setUp(self):
self.async_factory = AsyncRequestFactory()
self.wsgi_factory = RequestFactory()

def test_panels_with_asgi(self):
async_request = self.async_factory.get("/")
toolbar = DebugToolbar(async_request, lambda request: HttpResponse())

async_panel = MockAsyncPanel(toolbar, async_request)
sync_panel = MockSyncPanel(toolbar, async_request)

self.assertTrue(async_panel.enabled)
self.assertFalse(sync_panel.enabled)

def test_panels_with_wsgi(self):
wsgi_request = self.wsgi_factory.get("/")
toolbar = DebugToolbar(wsgi_request, lambda request: HttpResponse())

async_panel = MockAsyncPanel(toolbar, wsgi_request)
sync_panel = MockSyncPanel(toolbar, wsgi_request)

self.assertTrue(async_panel.enabled)
self.assertTrue(sync_panel.enabled)
44 changes: 44 additions & 0 deletions tests/test_middleware_compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import asyncio

from django.http import HttpResponse
from django.test import AsyncRequestFactory, RequestFactory, TestCase

from debug_toolbar.middleware import DebugToolbarMiddleware


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

def test_sync_mode(self):
"""
test middlware switches to sync (__call__) based on get_response type
"""

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

self.assertFalse(asyncio.iscoroutinefunction(middleware))

response = middleware(request)
self.assertEqual(response.status_code, 200)

async def test_async_mode(self):
"""
test middlware switches to async (__acall__) based on get_response type
and returns a coroutine
"""

async def get_response(request):
return HttpResponse("<html><body>Django debug toolbar</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)
Loading