diff --git a/example/README.rst b/example/README.rst index 94c09f8e5..1c34e4893 100644 --- a/example/README.rst +++ b/example/README.rst @@ -46,3 +46,13 @@ environment variable:: $ DB_BACKEND=postgresql python example/manage.py migrate $ DB_BACKEND=postgresql python example/manage.py runserver + +Using an asynchronous (ASGI) server: + +Install [Daphne](https://pypi.org/project/daphne/) first: + + $ python -m pip install daphne + +Then run the Django development server: + + $ ASYNC_SERVER=true python example/manage.py runserver diff --git a/example/asgi.py b/example/asgi.py new file mode 100644 index 000000000..9d7c78703 --- /dev/null +++ b/example/asgi.py @@ -0,0 +1,9 @@ +"""ASGI config for example project.""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") + +application = get_asgi_application() diff --git a/example/settings.py b/example/settings.py index 26b75fa5c..06b70f7fa 100644 --- a/example/settings.py +++ b/example/settings.py @@ -18,6 +18,7 @@ # Application definition INSTALLED_APPS = [ + *(["daphne"] if os.getenv("ASYNC_SERVER", False) else []), # noqa: FBT003 "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -66,6 +67,7 @@ USE_TZ = True WSGI_APPLICATION = "example.wsgi.application" +ASGI_APPLICATION = "example.asgi.application" # Cache and database @@ -103,7 +105,6 @@ STATICFILES_DIRS = [os.path.join(BASE_DIR, "example", "static")] - # Only enable the toolbar when we're in debug mode and we're # not running tests. Django will change DEBUG to be False for # tests, so we can't rely on DEBUG alone. diff --git a/example/templates/async_db.html b/example/templates/async_db.html new file mode 100644 index 000000000..771c039e3 --- /dev/null +++ b/example/templates/async_db.html @@ -0,0 +1,14 @@ + + + + + Async DB + + +

Async DB

+

+ Value + {{ user_count }} +

+ + diff --git a/example/urls.py b/example/urls.py index c5e60c309..86e6827fc 100644 --- a/example/urls.py +++ b/example/urls.py @@ -3,7 +3,13 @@ from django.views.generic import TemplateView from debug_toolbar.toolbar import debug_toolbar_urls -from example.views import increment, jinja2_view +from example.views import ( + async_db, + async_db_concurrent, + async_home, + increment, + jinja2_view, +) urlpatterns = [ path("", TemplateView.as_view(template_name="index.html"), name="home"), @@ -13,6 +19,9 @@ name="bad_form", ), path("jinja/", jinja2_view, name="jinja"), + path("async/", async_home, name="async_home"), + path("async/db/", async_db, name="async_db"), + path("async/db-concurrent/", async_db_concurrent, name="async_db_concurrent"), path("jquery/", TemplateView.as_view(template_name="jquery/index.html")), path("mootools/", TemplateView.as_view(template_name="mootools/index.html")), path("prototype/", TemplateView.as_view(template_name="prototype/index.html")), diff --git a/example/views.py b/example/views.py index e7e4c1253..3e1cb04a6 100644 --- a/example/views.py +++ b/example/views.py @@ -1,3 +1,7 @@ +import asyncio + +from asgiref.sync import sync_to_async +from django.contrib.auth.models import User from django.http import JsonResponse from django.shortcuts import render @@ -13,3 +17,26 @@ def increment(request): def jinja2_view(request): return render(request, "index.jinja", {"foo": "bar"}, using="jinja2") + + +async def async_home(request): + return await sync_to_async(render)(request, "index.html") + + +async def async_db(request): + user_count = await User.objects.acount() + + return await sync_to_async(render)( + request, "async_db.html", {"user_count": user_count} + ) + + +async def async_db_concurrent(request): + # Do database queries concurrently + (user_count, _) = await asyncio.gather( + User.objects.acount(), User.objects.filter(username="test").acount() + ) + + return await sync_to_async(render)( + request, "async_db.html", {"user_count": user_count} + ) diff --git a/requirements_dev.txt b/requirements_dev.txt index 8b24a8fbb..03e436622 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -12,6 +12,10 @@ selenium tox black +# Integration support + +daphne # async in Example app + # Documentation Sphinx diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 48c9e3845..332e9b1e8 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -32,6 +32,20 @@ def sql_call(*, use_iterator=False): return list(qs) +async def async_sql_call(*, use_iterator=False): + qs = User.objects.all() + if use_iterator: + qs = qs.iterator() + return await sync_to_async(list)(qs) + + +async def concurrent_async_sql_call(*, use_iterator=False): + qs = User.objects.all() + if use_iterator: + qs = qs.iterator() + return await asyncio.gather(sync_to_async(list)(qs), User.objects.acount()) + + class SQLPanelTestCase(BaseTestCase): panel_id = "SQLPanel" @@ -57,6 +71,38 @@ def test_recording(self): # ensure the stacktrace is populated self.assertTrue(len(query["stacktrace"]) > 0) + async def test_recording_async(self): + self.assertEqual(len(self.panel._queries), 0) + + await async_sql_call() + + # ensure query was logged + self.assertEqual(len(self.panel._queries), 1) + query = self.panel._queries[0] + self.assertEqual(query["alias"], "default") + self.assertTrue("sql" in query) + self.assertTrue("duration" in query) + self.assertTrue("stacktrace" in query) + + # ensure the stacktrace is populated + self.assertTrue(len(query["stacktrace"]) > 0) + + async def test_recording_concurrent_async(self): + self.assertEqual(len(self.panel._queries), 0) + + await concurrent_async_sql_call() + + # ensure query was logged + self.assertEqual(len(self.panel._queries), 2) + query = self.panel._queries[0] + self.assertEqual(query["alias"], "default") + self.assertTrue("sql" in query) + self.assertTrue("duration" in query) + self.assertTrue("stacktrace" in query) + + # ensure the stacktrace is populated + self.assertTrue(len(query["stacktrace"]) > 0) + @unittest.skipUnless( connection.vendor == "postgresql", "Test valid only on PostgreSQL" ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 95207c21b..e6863e7a9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -250,6 +250,24 @@ def test_data_gone(self): ) self.assertIn("Please reload the page and retry.", response.json()["content"]) + def test_sql_page(self): + response = self.client.get("/execute_sql/") + self.assertEqual( + len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 1 + ) + + def test_async_sql_page(self): + response = self.client.get("/async_execute_sql/") + self.assertEqual( + len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 1 + ) + + def test_concurrent_async_sql_page(self): + response = self.client.get("/async_execute_sql_concurrently/") + self.assertEqual( + len(response.toolbar.get_panel_by_id("SQLPanel").get_stats()["queries"]), 2 + ) + @override_settings(DEBUG=True) class DebugToolbarIntegrationTestCase(IntegrationTestCase): @@ -843,3 +861,29 @@ def test_theme_toggle(self): self.get("/regular/basic/") toolbar = self.selenium.find_element(By.ID, "djDebug") self.assertEqual(toolbar.get_attribute("data-theme"), "light") + + def test_async_sql_action(self): + self.get("/async_execute_sql/") + self.selenium.find_element(By.ID, "SQLPanel") + self.selenium.find_element(By.ID, "djDebugWindow") + + # Click to show the SQL panel + self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() + + # SQL panel loads + self.wait.until( + EC.visibility_of_element_located((By.CSS_SELECTOR, ".remoteCall")) + ) + + def test_concurrent_async_sql_action(self): + self.get("/async_execute_sql_concurrently/") + self.selenium.find_element(By.ID, "SQLPanel") + self.selenium.find_element(By.ID, "djDebugWindow") + + # Click to show the SQL panel + self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() + + # SQL panel loads + self.wait.until( + EC.visibility_of_element_located((By.CSS_SELECTOR, ".remoteCall")) + ) diff --git a/tests/urls.py b/tests/urls.py index f8929f1e8..68c6e0354 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -17,6 +17,8 @@ path("non_ascii_request/", views.regular_view, {"title": NonAsciiRepr()}), path("new_user/", views.new_user), path("execute_sql/", views.execute_sql), + path("async_execute_sql/", views.async_execute_sql), + path("async_execute_sql_concurrently/", views.async_execute_sql_concurrently), path("cached_view/", views.cached_view), path("cached_low_level_view/", views.cached_low_level_view), path("json_view/", views.json_view), diff --git a/tests/views.py b/tests/views.py index 8ae4631fe..8b8b75ef6 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,3 +1,6 @@ +import asyncio + +from asgiref.sync import sync_to_async from django.contrib.auth.models import User from django.core.cache import cache from django.http import HttpResponseRedirect, JsonResponse @@ -11,6 +14,16 @@ def execute_sql(request): return render(request, "base.html") +async def async_execute_sql(request): + await sync_to_async(list)(User.objects.all()) + return render(request, "base.html") + + +async def async_execute_sql_concurrently(request): + await asyncio.gather(sync_to_async(list)(User.objects.all()), User.objects.acount()) + return render(request, "base.html") + + def regular_view(request, title): return render(request, "basic.html", {"title": title})