diff --git a/debug_toolbar/middleware.py b/debug_toolbar/middleware.py index b089d1484..03044f3a4 100644 --- a/debug_toolbar/middleware.py +++ b/debug_toolbar/middleware.py @@ -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 @@ -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) @@ -78,7 +115,7 @@ 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 @@ -86,6 +123,12 @@ def __call__(self, request): 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): diff --git a/debug_toolbar/panels/__init__.py b/debug_toolbar/panels/__init__.py index 57f385a5e..fd3312bc3 100644 --- a/debug_toolbar/panels/__init__.py +++ b/debug_toolbar/panels/__init__.py @@ -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 @@ -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 @@ -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: diff --git a/debug_toolbar/panels/profiling.py b/debug_toolbar/panels/profiling.py index 64224a2db..ffe9b7e37 100644 --- a/debug_toolbar/panels/profiling.py +++ b/debug_toolbar/panels/profiling.py @@ -136,6 +136,7 @@ class ProfilingPanel(Panel): Panel that displays profiling information. """ + is_async = False title = _("Profiling") template = "debug_toolbar/panels/profiling.html" diff --git a/debug_toolbar/panels/redirects.py b/debug_toolbar/panels/redirects.py index 195d0cf11..8894d1a18 100644 --- a/debug_toolbar/panels/redirects.py +++ b/debug_toolbar/panels/redirects.py @@ -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") diff --git a/debug_toolbar/panels/request.py b/debug_toolbar/panels/request.py index a936eba6b..8df382fb3 100644 --- a/debug_toolbar/panels/request.py +++ b/debug_toolbar/panels/request.py @@ -15,6 +15,8 @@ class RequestPanel(Panel): title = _("Request") + is_async = False + @property def nav_subtitle(self): """ diff --git a/debug_toolbar/panels/sql/panel.py b/debug_toolbar/panels/sql/panel.py index 58c1c2738..879be38b0 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -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 diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py index 2eed2efa0..061068a30 100644 --- a/debug_toolbar/panels/staticfiles.py +++ b/debug_toolbar/panels/staticfiles.py @@ -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" diff --git a/debug_toolbar/panels/timer.py b/debug_toolbar/panels/timer.py index 554798e7d..962702f7e 100644 --- a/debug_toolbar/panels/timer.py +++ b/debug_toolbar/panels/timer.py @@ -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"): diff --git a/docs/architecture.rst b/docs/architecture.rst index 7be5ac78d..145676459 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -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 `_. +- 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 `_. diff --git a/tests/panels/test_async_panel_compatibility.py b/tests/panels/test_async_panel_compatibility.py new file mode 100644 index 000000000..d5a85ffbb --- /dev/null +++ b/tests/panels/test_async_panel_compatibility.py @@ -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) diff --git a/tests/test_middleware_compatibility.py b/tests/test_middleware_compatibility.py new file mode 100644 index 000000000..d3025c1ea --- /dev/null +++ b/tests/test_middleware_compatibility.py @@ -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("Django debug toolbar") + ) + + 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("Django debug toolbar") + + middleware = DebugToolbarMiddleware(get_response) + request = self.async_factory.get("/") + + self.assertTrue(asyncio.iscoroutinefunction(middleware)) + + response = await middleware(request) + self.assertEqual(response.status_code, 200)