Skip to content

Commit

Permalink
Support an async middleware for the toolbar.
Browse files Browse the repository at this point in the history
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
  • Loading branch information
salty-ivy authored and tim-schilling committed Jul 16, 2024
1 parent 46efd5d commit 25656ee
Show file tree
Hide file tree
Showing 11 changed files with 150 additions and 4 deletions.
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)

0 comments on commit 25656ee

Please sign in to comment.