Skip to content

Commit 25656ee

Browse files
salty-ivytim-schilling
authored andcommitted
Support an async middleware for the toolbar.
extract redudant code in _postprocess disable non async capable panels add middleware sync and async compatible test case rename file add panel async compatibility tests/ added panel async compatibility tests/ marked erreneous panels as non async refactor panel test Add function docstrings update async panel compatibility tests revert middleware back to __call__ and __acall__ approach update architecture.rst documentation fix typo in docs remove ASGI keyword from docs
1 parent 46efd5d commit 25656ee

11 files changed

+150
-4
lines changed

debug_toolbar/middleware.py

+44-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import socket
77
from functools import lru_cache
88

9+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
910
from django.conf import settings
1011
from django.utils.module_loading import import_string
1112

@@ -62,14 +63,50 @@ class DebugToolbarMiddleware:
6263
on outgoing response.
6364
"""
6465

66+
sync_capable = True
67+
async_capable = True
68+
6569
def __init__(self, get_response):
6670
self.get_response = get_response
71+
# If get_response is a coroutine function, turns us into async mode so
72+
# a thread is not consumed during a whole request.
73+
self.async_mode = iscoroutinefunction(self.get_response)
74+
75+
if self.async_mode:
76+
# Mark the class as async-capable, but do the actual switch inside
77+
# __call__ to avoid swapping out dunder methods.
78+
markcoroutinefunction(self)
6779

6880
def __call__(self, request):
6981
# Decide whether the toolbar is active for this request.
82+
if self.async_mode:
83+
return self.__acall__(request)
84+
# Decide whether the toolbar is active for this request.
7085
show_toolbar = get_show_toolbar()
7186
if not show_toolbar(request) or DebugToolbar.is_toolbar_request(request):
7287
return self.get_response(request)
88+
toolbar = DebugToolbar(request, self.get_response)
89+
# Activate instrumentation ie. monkey-patch.
90+
for panel in toolbar.enabled_panels:
91+
panel.enable_instrumentation()
92+
try:
93+
# Run panels like Django middleware.
94+
response = toolbar.process_request(request)
95+
finally:
96+
clear_stack_trace_caches()
97+
# Deactivate instrumentation ie. monkey-unpatch. This must run
98+
# regardless of the response. Keep 'return' clauses below.
99+
for panel in reversed(toolbar.enabled_panels):
100+
panel.disable_instrumentation()
101+
102+
return self._postprocess(request, response, toolbar)
103+
104+
async def __acall__(self, request):
105+
# 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):
108+
response = await self.get_response(request)
109+
return response
73110

74111
toolbar = DebugToolbar(request, self.get_response)
75112

@@ -78,14 +115,20 @@ def __call__(self, request):
78115
panel.enable_instrumentation()
79116
try:
80117
# Run panels like Django middleware.
81-
response = toolbar.process_request(request)
118+
response = await toolbar.process_request(request)
82119
finally:
83120
clear_stack_trace_caches()
84121
# Deactivate instrumentation ie. monkey-unpatch. This must run
85122
# regardless of the response. Keep 'return' clauses below.
86123
for panel in reversed(toolbar.enabled_panels):
87124
panel.disable_instrumentation()
88125

126+
return self._postprocess(request, response, toolbar)
127+
128+
def _postprocess(self, request, response, toolbar):
129+
"""
130+
Post-process the response.
131+
"""
89132
# Generate the stats for all requests when the toolbar is being shown,
90133
# but not necessarily inserted.
91134
for panel in reversed(toolbar.enabled_panels):

debug_toolbar/panels/__init__.py

+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.core.handlers.asgi import ASGIRequest
12
from django.template.loader import render_to_string
23

34
from debug_toolbar import settings as dt_settings
@@ -9,6 +10,8 @@ class Panel:
910
Base class for panels.
1011
"""
1112

13+
is_async = True
14+
1215
def __init__(self, toolbar, get_response):
1316
self.toolbar = toolbar
1417
self.get_response = get_response
@@ -21,6 +24,10 @@ def panel_id(self):
2124

2225
@property
2326
def enabled(self) -> bool:
27+
# check if the panel is async compatible
28+
if not self.is_async and isinstance(self.toolbar.request, ASGIRequest):
29+
return False
30+
2431
# The user's cookies should override the default value
2532
cookie_value = self.toolbar.request.COOKIES.get("djdt" + self.panel_id)
2633
if cookie_value is not None:

debug_toolbar/panels/profiling.py

+1
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ class ProfilingPanel(Panel):
136136
Panel that displays profiling information.
137137
"""
138138

139+
is_async = False
139140
title = _("Profiling")
140141

141142
template = "debug_toolbar/panels/profiling.html"

debug_toolbar/panels/redirects.py

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class RedirectsPanel(Panel):
99
Panel that intercepts redirects and displays a page with debug info.
1010
"""
1111

12+
is_async = False
1213
has_content = False
1314

1415
nav_title = _("Intercept redirects")

debug_toolbar/panels/request.py

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class RequestPanel(Panel):
1515

1616
title = _("Request")
1717

18+
is_async = False
19+
1820
@property
1921
def nav_subtitle(self):
2022
"""

debug_toolbar/panels/sql/panel.py

+2
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ class SQLPanel(Panel):
109109
the request.
110110
"""
111111

112+
is_async = False
113+
112114
def __init__(self, *args, **kwargs):
113115
super().__init__(*args, **kwargs)
114116
self._sql_time = 0

debug_toolbar/panels/staticfiles.py

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class StaticFilesPanel(panels.Panel):
7373
A panel to display the found staticfiles.
7474
"""
7575

76+
is_async = False
7677
name = "Static files"
7778
template = "debug_toolbar/panels/staticfiles.html"
7879

debug_toolbar/panels/timer.py

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class TimerPanel(Panel):
1717
Panel that displays the time a response took in milliseconds.
1818
"""
1919

20+
is_async = False
21+
2022
def nav_subtitle(self):
2123
stats = self.get_stats()
2224
if hasattr(self, "_start_rusage"):

docs/architecture.rst

+7-3
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ Problematic Parts
7979
when the panel module is loaded
8080
- ``debug.panels.sql``: This package is particularly complex, but provides
8181
the main benefit of the toolbar
82-
- Support for async and multi-threading: This is currently unsupported, but
83-
is being implemented as per the
84-
`Async compatible toolbar project <https://github.com/orgs/jazzband/projects/9>`_.
82+
- Support for async and multi-threading: ``debug_toolbar.middleware.DebugToolbarMiddleware``
83+
is now async compatible and can process async requests. However certain
84+
panels such as ``SQLPanel``, ``TimerPanel``, ``StaticFilesPanel``,
85+
``RequestPanel``, ``RedirectsPanel`` and ``ProfilingPanel`` aren't fully
86+
compatible and currently being worked on. For now, these panels
87+
are disabled by default when running in async environment.
88+
follow the progress of this issue in `Async compatible toolbar project <https://github.com/orgs/jazzband/projects/9>`_.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from django.http import HttpResponse
2+
from django.test import AsyncRequestFactory, RequestFactory, TestCase
3+
4+
from debug_toolbar.panels import Panel
5+
from debug_toolbar.toolbar import DebugToolbar
6+
7+
8+
class MockAsyncPanel(Panel):
9+
is_async = True
10+
11+
12+
class MockSyncPanel(Panel):
13+
is_async = False
14+
15+
16+
class PanelAsyncCompatibilityTestCase(TestCase):
17+
def setUp(self):
18+
self.async_factory = AsyncRequestFactory()
19+
self.wsgi_factory = RequestFactory()
20+
21+
def test_panels_with_asgi(self):
22+
async_request = self.async_factory.get("/")
23+
toolbar = DebugToolbar(async_request, lambda request: HttpResponse())
24+
25+
async_panel = MockAsyncPanel(toolbar, async_request)
26+
sync_panel = MockSyncPanel(toolbar, async_request)
27+
28+
self.assertTrue(async_panel.enabled)
29+
self.assertFalse(sync_panel.enabled)
30+
31+
def test_panels_with_wsgi(self):
32+
wsgi_request = self.wsgi_factory.get("/")
33+
toolbar = DebugToolbar(wsgi_request, lambda request: HttpResponse())
34+
35+
async_panel = MockAsyncPanel(toolbar, wsgi_request)
36+
sync_panel = MockSyncPanel(toolbar, wsgi_request)
37+
38+
self.assertTrue(async_panel.enabled)
39+
self.assertTrue(sync_panel.enabled)
+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import asyncio
2+
3+
from django.http import HttpResponse
4+
from django.test import AsyncRequestFactory, RequestFactory, TestCase
5+
6+
from debug_toolbar.middleware import DebugToolbarMiddleware
7+
8+
9+
class MiddlewareSyncAsyncCompatibilityTestCase(TestCase):
10+
def setUp(self):
11+
self.factory = RequestFactory()
12+
self.async_factory = AsyncRequestFactory()
13+
14+
def test_sync_mode(self):
15+
"""
16+
test middlware switches to sync (__call__) based on get_response type
17+
"""
18+
19+
request = self.factory.get("/")
20+
middleware = DebugToolbarMiddleware(
21+
lambda x: HttpResponse("<html><body>Django debug toolbar</body></html>")
22+
)
23+
24+
self.assertFalse(asyncio.iscoroutinefunction(middleware))
25+
26+
response = middleware(request)
27+
self.assertEqual(response.status_code, 200)
28+
29+
async def test_async_mode(self):
30+
"""
31+
test middlware switches to async (__acall__) based on get_response type
32+
and returns a coroutine
33+
"""
34+
35+
async def get_response(request):
36+
return HttpResponse("<html><body>Django debug toolbar</body></html>")
37+
38+
middleware = DebugToolbarMiddleware(get_response)
39+
request = self.async_factory.get("/")
40+
41+
self.assertTrue(asyncio.iscoroutinefunction(middleware))
42+
43+
response = await middleware(request)
44+
self.assertEqual(response.status_code, 200)

0 commit comments

Comments
 (0)