From bb88c0526c772a55e40bdf074caeb3b3d5124056 Mon Sep 17 00:00:00 2001 From: tschilling Date: Tue, 16 May 2023 21:14:41 -0500 Subject: [PATCH 01/25] Add the Store API and initial documentation. --- debug_toolbar/settings.py | 1 + debug_toolbar/store.py | 127 ++++++++++++++++++++++++++++++++++++++ docs/changes.rst | 9 ++- docs/configuration.rst | 9 +++ tests/test_store.py | 126 +++++++++++++++++++++++++++++++++++++ 5 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 debug_toolbar/store.py create mode 100644 tests/test_store.py diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index eb6b59209..fcd253c59 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -42,6 +42,7 @@ "SQL_WARNING_THRESHOLD": 500, # milliseconds "OBSERVE_REQUEST_CALLBACK": "debug_toolbar.toolbar.observe_request", "TOOLBAR_LANGUAGE": None, + "TOOLBAR_STORE_CLASS": "debug_toolbar.store.MemoryStore", } diff --git a/debug_toolbar/store.py b/debug_toolbar/store.py new file mode 100644 index 000000000..66cd89e8a --- /dev/null +++ b/debug_toolbar/store.py @@ -0,0 +1,127 @@ +import json +from collections import defaultdict, deque +from typing import Any, Dict, Iterable + +from django.core.serializers.json import DjangoJSONEncoder +from django.utils.encoding import force_str +from django.utils.module_loading import import_string + +from debug_toolbar import settings as dt_settings + + +class DebugToolbarJSONEncoder(DjangoJSONEncoder): + def default(self, o: Any) -> Any: + try: + return super().default(o) + except TypeError: + return force_str(o) + + +def serialize(data: Any) -> str: + return json.dumps(data, cls=DebugToolbarJSONEncoder) + + +def deserialize(data: str) -> Any: + return json.loads(data) + + +class BaseStore: + _config = dt_settings.get_config().copy() + + @classmethod + def ids(cls) -> Iterable: + """The stored ids""" + raise NotImplementedError + + @classmethod + def exists(cls, request_id: str) -> bool: + """Does the given request_id exist in the store""" + raise NotImplementedError + + @classmethod + def set(cls, request_id: str): + """Set a request_id in the store""" + raise NotImplementedError + + @classmethod + def clear(cls): + """Remove all requests from the request store""" + raise NotImplementedError + + @classmethod + def delete(cls, request_id: str): + """Delete the store for the given request_id""" + raise NotImplementedError + + @classmethod + def save_panel(cls, request_id: str, panel_id: str, data: Any = None): + """Save the panel data for the given request_id""" + raise NotImplementedError + + @classmethod + def panel(cls, request_id: str, panel_id: str) -> Any: + """Fetch the panel data for the given request_id""" + raise NotImplementedError + + +class MemoryStore(BaseStore): + # ids is the collection of storage ids that have been used. + # Use a dequeue to support O(1) appends and pops + # from either direction. + _ids: deque = deque() + _request_store: Dict[str, Dict] = defaultdict(dict) + + @classmethod + def ids(cls) -> Iterable: + """The stored ids""" + return cls._ids + + @classmethod + def exists(cls, request_id: str) -> bool: + """Does the given request_id exist in the request store""" + return request_id in cls._ids + + @classmethod + def set(cls, request_id: str): + """Set a request_id in the request store""" + if request_id not in cls._ids: + cls._ids.append(request_id) + for _ in range(len(cls._ids) - cls._config["RESULTS_CACHE_SIZE"]): + removed_id = cls._ids.popleft() + cls._request_store.pop(removed_id, None) + + @classmethod + def clear(cls): + """Remove all requests from the request store""" + cls._ids.clear() + cls._request_store.clear() + + @classmethod + def delete(cls, request_id: str): + """Delete the stored request for the given request_id""" + cls._request_store.pop(request_id, None) + try: + cls._ids.remove(request_id) + except ValueError: + # The request_id doesn't exist in the collection of ids. + pass + + @classmethod + def save_panel(cls, request_id: str, panel_id: str, data: Any = None): + """Save the panel data for the given request_id""" + cls.set(request_id) + cls._request_store[request_id][panel_id] = serialize(data) + + @classmethod + def panel(cls, request_id: str, panel_id: str) -> Any: + """Fetch the panel data for the given request_id""" + try: + data = cls._request_store[request_id][panel_id] + except KeyError: + return {} + else: + return deserialize(data) + + +def get_store(): + return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"]) diff --git a/docs/changes.rst b/docs/changes.rst index ad3cab34c..42bf6ac53 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -1,6 +1,14 @@ Change log ========== +Serializable (don't include in main) +------------------------------------ + +* Defines the ``BaseStore`` interface for request storage mechanisms. +* Added the config setting ``TOOLBAR_STORE_CLASS`` to configure the request + storage mechanism. Defaults to ``debug_toolbar.store.MemoryStore``. + + Pending ------- @@ -25,7 +33,6 @@ Pending 4.1.0 (2023-05-15) ------------------ - * Improved SQL statement formatting performance. Additionally, fixed the indentation of ``CASE`` statements and stopped simplifying ``.count()`` queries. diff --git a/docs/configuration.rst b/docs/configuration.rst index 887608c6e..f2f6b7de9 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -150,6 +150,15 @@ Toolbar options the request doesn't originate from the toolbar itself, EG that ``is_toolbar_request`` is false for a given request. +.. _TOOLBAR_STORE_CLASS: + +* ``TOOLBAR_STORE_CLASS`` + + Default: ``"debug_toolbar.store.MemoryStore"`` + + The path to the class to be used for storing the toolbar's data per request. + + .. _TOOLBAR_LANGUAGE: * ``TOOLBAR_LANGUAGE`` diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 000000000..d3381084e --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,126 @@ +from django.test import TestCase +from django.test.utils import override_settings + +from debug_toolbar import store + + +class SerializationTestCase(TestCase): + def test_serialize(self): + self.assertEqual( + store.serialize({"hello": {"foo": "bar"}}), + '{"hello": {"foo": "bar"}}', + ) + + def test_serialize_force_str(self): + class Foo: + spam = "bar" + + def __str__(self): + return f"Foo spam={self.spam}" + + self.assertEqual( + store.serialize({"hello": Foo()}), + '{"hello": "Foo spam=bar"}', + ) + + def test_deserialize(self): + self.assertEqual( + store.deserialize('{"hello": {"foo": "bar"}}'), + {"hello": {"foo": "bar"}}, + ) + + +class BaseStoreTestCase(TestCase): + def test_methods_are_not_implemented(self): + # Find all the non-private and dunder class methods + methods = [ + member for member in vars(store.BaseStore) if not member.startswith("_") + ] + self.assertEqual(len(methods), 7) + with self.assertRaises(NotImplementedError): + store.BaseStore.ids() + with self.assertRaises(NotImplementedError): + store.BaseStore.exists("") + with self.assertRaises(NotImplementedError): + store.BaseStore.set("") + with self.assertRaises(NotImplementedError): + store.BaseStore.clear() + with self.assertRaises(NotImplementedError): + store.BaseStore.delete("") + with self.assertRaises(NotImplementedError): + store.BaseStore.save_panel("", "", None) + with self.assertRaises(NotImplementedError): + store.BaseStore.panel("", "") + + +class MemoryStoreTestCase(TestCase): + @classmethod + def setUpTestData(cls) -> None: + cls.store = store.MemoryStore + + def tearDown(self) -> None: + self.store.clear() + + def test_ids(self): + self.store.set("foo") + self.store.set("bar") + self.assertEqual(list(self.store.ids()), ["foo", "bar"]) + + def test_exists(self): + self.assertFalse(self.store.exists("missing")) + self.store.set("exists") + self.assertTrue(self.store.exists("exists")) + + def test_set(self): + self.store.set("foo") + self.assertEqual(list(self.store.ids()), ["foo"]) + + def test_set_max_size(self): + existing = self.store._config["RESULTS_CACHE_SIZE"] + self.store._config["RESULTS_CACHE_SIZE"] = 1 + self.store.save_panel("foo", "foo.panel", "foo.value") + self.store.save_panel("bar", "bar.panel", {"a": 1}) + self.assertEqual(list(self.store.ids()), ["bar"]) + self.assertEqual(self.store.panel("foo", "foo.panel"), {}) + self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1}) + # Restore the existing config setting since this config is shared. + self.store._config["RESULTS_CACHE_SIZE"] = existing + + def test_clear(self): + self.store.save_panel("bar", "bar.panel", {"a": 1}) + self.store.clear() + self.assertEqual(list(self.store.ids()), []) + self.assertEqual(self.store.panel("bar", "bar.panel"), {}) + + def test_delete(self): + self.store.save_panel("bar", "bar.panel", {"a": 1}) + self.store.delete("bar") + self.assertEqual(list(self.store.ids()), []) + self.assertEqual(self.store.panel("bar", "bar.panel"), {}) + # Make sure it doesn't error + self.store.delete("bar") + + def test_save_panel(self): + self.store.save_panel("bar", "bar.panel", {"a": 1}) + self.assertEqual(list(self.store.ids()), ["bar"]) + self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1}) + + def test_panel(self): + self.assertEqual(self.store.panel("missing", "missing"), {}) + self.store.save_panel("bar", "bar.panel", {"a": 1}) + self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1}) + + +class StubStore(store.BaseStore): + pass + + +class GetStoreTestCase(TestCase): + def test_get_store(self): + self.assertIs(store.get_store(), store.MemoryStore) + + @override_settings( + DEBUG_TOOLBAR_CONFIG={"TOOLBAR_STORE_CLASS": "tests.test_store.StubStore"} + ) + def test_get_store_with_setting(self): + self.assertIs(store.get_store(), StubStore) From 19b56950195d90c7a964950c1b9beba25d0928cd Mon Sep 17 00:00:00 2001 From: tschilling Date: Tue, 16 May 2023 21:24:32 -0500 Subject: [PATCH 02/25] Remove config from docs as sphinx says it's misspelled. --- docs/changes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 42bf6ac53..9d70eb418 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,7 +5,7 @@ Serializable (don't include in main) ------------------------------------ * Defines the ``BaseStore`` interface for request storage mechanisms. -* Added the config setting ``TOOLBAR_STORE_CLASS`` to configure the request +* Added the setting ``TOOLBAR_STORE_CLASS`` to configure the request storage mechanism. Defaults to ``debug_toolbar.store.MemoryStore``. From 97fcda7270ab959aba4a6fc2fab0b2a0ca062972 Mon Sep 17 00:00:00 2001 From: tschilling Date: Sat, 17 Jun 2023 10:15:18 -0500 Subject: [PATCH 03/25] Switch to Store.request_ids and remove serialization force_str. If the serialization logic begins throwing exceptions we can consider subclassing the encoder class and using force_str on the object itself. --- debug_toolbar/store.py | 47 ++++++++++++++++++------------------------ tests/test_store.py | 26 +++++++---------------- 2 files changed, 27 insertions(+), 46 deletions(-) diff --git a/debug_toolbar/store.py b/debug_toolbar/store.py index 66cd89e8a..b32d3b62a 100644 --- a/debug_toolbar/store.py +++ b/debug_toolbar/store.py @@ -1,24 +1,19 @@ +import contextlib import json from collections import defaultdict, deque from typing import Any, Dict, Iterable from django.core.serializers.json import DjangoJSONEncoder -from django.utils.encoding import force_str from django.utils.module_loading import import_string from debug_toolbar import settings as dt_settings -class DebugToolbarJSONEncoder(DjangoJSONEncoder): - def default(self, o: Any) -> Any: - try: - return super().default(o) - except TypeError: - return force_str(o) - - def serialize(data: Any) -> str: - return json.dumps(data, cls=DebugToolbarJSONEncoder) + # If this starts throwing an exceptions, consider + # Subclassing DjangoJSONEncoder and using force_str to + # make it JSON serializable. + return json.dumps(data, cls=DjangoJSONEncoder) def deserialize(data: str) -> Any: @@ -29,8 +24,8 @@ class BaseStore: _config = dt_settings.get_config().copy() @classmethod - def ids(cls) -> Iterable: - """The stored ids""" + def request_ids(cls) -> Iterable: + """The stored request ids""" raise NotImplementedError @classmethod @@ -68,43 +63,41 @@ class MemoryStore(BaseStore): # ids is the collection of storage ids that have been used. # Use a dequeue to support O(1) appends and pops # from either direction. - _ids: deque = deque() + _request_ids: deque = deque() _request_store: Dict[str, Dict] = defaultdict(dict) @classmethod - def ids(cls) -> Iterable: - """The stored ids""" - return cls._ids + def request_ids(cls) -> Iterable: + """The stored request ids""" + return cls._request_ids @classmethod def exists(cls, request_id: str) -> bool: """Does the given request_id exist in the request store""" - return request_id in cls._ids + return request_id in cls._request_ids @classmethod def set(cls, request_id: str): """Set a request_id in the request store""" - if request_id not in cls._ids: - cls._ids.append(request_id) - for _ in range(len(cls._ids) - cls._config["RESULTS_CACHE_SIZE"]): - removed_id = cls._ids.popleft() + if request_id not in cls._request_ids: + cls._request_ids.append(request_id) + for _ in range(len(cls._request_ids) - cls._config["RESULTS_CACHE_SIZE"]): + removed_id = cls._request_ids.popleft() cls._request_store.pop(removed_id, None) @classmethod def clear(cls): """Remove all requests from the request store""" - cls._ids.clear() + cls._request_ids.clear() cls._request_store.clear() @classmethod def delete(cls, request_id: str): """Delete the stored request for the given request_id""" cls._request_store.pop(request_id, None) - try: - cls._ids.remove(request_id) - except ValueError: - # The request_id doesn't exist in the collection of ids. - pass + # Suppress when request_id doesn't exist in the collection of ids. + with contextlib.suppress(ValueError): + cls._request_ids.remove(request_id) @classmethod def save_panel(cls, request_id: str, panel_id: str, data: Any = None): diff --git a/tests/test_store.py b/tests/test_store.py index d3381084e..c51afde1e 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -11,18 +11,6 @@ def test_serialize(self): '{"hello": {"foo": "bar"}}', ) - def test_serialize_force_str(self): - class Foo: - spam = "bar" - - def __str__(self): - return f"Foo spam={self.spam}" - - self.assertEqual( - store.serialize({"hello": Foo()}), - '{"hello": "Foo spam=bar"}', - ) - def test_deserialize(self): self.assertEqual( store.deserialize('{"hello": {"foo": "bar"}}'), @@ -38,7 +26,7 @@ def test_methods_are_not_implemented(self): ] self.assertEqual(len(methods), 7) with self.assertRaises(NotImplementedError): - store.BaseStore.ids() + store.BaseStore.request_ids() with self.assertRaises(NotImplementedError): store.BaseStore.exists("") with self.assertRaises(NotImplementedError): @@ -64,7 +52,7 @@ def tearDown(self) -> None: def test_ids(self): self.store.set("foo") self.store.set("bar") - self.assertEqual(list(self.store.ids()), ["foo", "bar"]) + self.assertEqual(list(self.store.request_ids()), ["foo", "bar"]) def test_exists(self): self.assertFalse(self.store.exists("missing")) @@ -73,14 +61,14 @@ def test_exists(self): def test_set(self): self.store.set("foo") - self.assertEqual(list(self.store.ids()), ["foo"]) + self.assertEqual(list(self.store.request_ids()), ["foo"]) def test_set_max_size(self): existing = self.store._config["RESULTS_CACHE_SIZE"] self.store._config["RESULTS_CACHE_SIZE"] = 1 self.store.save_panel("foo", "foo.panel", "foo.value") self.store.save_panel("bar", "bar.panel", {"a": 1}) - self.assertEqual(list(self.store.ids()), ["bar"]) + self.assertEqual(list(self.store.request_ids()), ["bar"]) self.assertEqual(self.store.panel("foo", "foo.panel"), {}) self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1}) # Restore the existing config setting since this config is shared. @@ -89,20 +77,20 @@ def test_set_max_size(self): def test_clear(self): self.store.save_panel("bar", "bar.panel", {"a": 1}) self.store.clear() - self.assertEqual(list(self.store.ids()), []) + self.assertEqual(list(self.store.request_ids()), []) self.assertEqual(self.store.panel("bar", "bar.panel"), {}) def test_delete(self): self.store.save_panel("bar", "bar.panel", {"a": 1}) self.store.delete("bar") - self.assertEqual(list(self.store.ids()), []) + self.assertEqual(list(self.store.request_ids()), []) self.assertEqual(self.store.panel("bar", "bar.panel"), {}) # Make sure it doesn't error self.store.delete("bar") def test_save_panel(self): self.store.save_panel("bar", "bar.panel", {"a": 1}) - self.assertEqual(list(self.store.ids()), ["bar"]) + self.assertEqual(list(self.store.request_ids()), ["bar"]) self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1}) def test_panel(self): From 487dfb38c1224461aadc186dc15e156d9ac95984 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Sun, 20 Aug 2023 21:21:00 -0500 Subject: [PATCH 04/25] Log serialization warning when a panel errors. (#1810) * Log serialization warning when a panel errors. This will help third party panels identify issues with serializing the content of their panels in the future, without causing the entire toolbar to break. * Change setting name to SUPPRESS_SERIALIZATION_ERRORS --- debug_toolbar/settings.py | 1 + debug_toolbar/store.py | 12 +++++++++++- docs/changes.rst | 2 ++ docs/configuration.rst | 9 +++++++++ tests/test_store.py | 17 +++++++++++++++++ 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index fcd253c59..b2a07dcd9 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -37,6 +37,7 @@ "PROFILER_CAPTURE_PROJECT_CODE": True, "PROFILER_MAX_DEPTH": 10, "PROFILER_THRESHOLD_RATIO": 8, + "SUPPRESS_SERIALIZATION_ERRORS": True, "SHOW_TEMPLATE_CONTEXT": True, "SKIP_TEMPLATE_PREFIXES": ("django/forms/widgets/", "admin/widgets/"), "SQL_WARNING_THRESHOLD": 500, # milliseconds diff --git a/debug_toolbar/store.py b/debug_toolbar/store.py index b32d3b62a..0bba0c2ef 100644 --- a/debug_toolbar/store.py +++ b/debug_toolbar/store.py @@ -1,5 +1,6 @@ import contextlib import json +import logging from collections import defaultdict, deque from typing import Any, Dict, Iterable @@ -8,6 +9,8 @@ from debug_toolbar import settings as dt_settings +logger = logging.getLogger(__name__) + def serialize(data: Any) -> str: # If this starts throwing an exceptions, consider @@ -103,7 +106,14 @@ def delete(cls, request_id: str): def save_panel(cls, request_id: str, panel_id: str, data: Any = None): """Save the panel data for the given request_id""" cls.set(request_id) - cls._request_store[request_id][panel_id] = serialize(data) + try: + cls._request_store[request_id][panel_id] = serialize(data) + except TypeError: + if dt_settings.get_config()["SUPPRESS_SERIALIZATION_ERRORS"]: + log = "Panel (%s) failed to serialized data %s properly." + logger.warning(log % (panel_id, data)) + else: + raise @classmethod def panel(cls, request_id: str, panel_id: str) -> Any: diff --git a/docs/changes.rst b/docs/changes.rst index 9d70eb418..2dea4306f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,6 +7,8 @@ Serializable (don't include in main) * Defines the ``BaseStore`` interface for request storage mechanisms. * Added the setting ``TOOLBAR_STORE_CLASS`` to configure the request storage mechanism. Defaults to ``debug_toolbar.store.MemoryStore``. +* Added setting ``SUPPRESS_SERIALIZATION_ERRORS`` to suppress + warnings when a ``TypeError`` occurs during a panel's serialization. Pending diff --git a/docs/configuration.rst b/docs/configuration.rst index f2f6b7de9..d9d03a853 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -306,6 +306,15 @@ Panel options the nested functions. The threshold is calculated by the root calls' cumulative time divided by this ratio. +* ``SUPPRESS_SERIALIZATION_ERRORS`` + + Default: ``True`` + + If set to ``True`` then panels will log a warning if a ``TypeError`` is + raised when attempting to serialize a panel's stats rather than raising an + exception.. If set to ``False`` then the ``TypeError`` will be raised. The + default will eventually be set to ``False`` and removed entirely. + * ``SHOW_TEMPLATE_CONTEXT`` Default: ``True`` diff --git a/tests/test_store.py b/tests/test_store.py index c51afde1e..1c17aaf96 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,3 +1,5 @@ +import logging + from django.test import TestCase from django.test.utils import override_settings @@ -93,6 +95,21 @@ def test_save_panel(self): self.assertEqual(list(self.store.request_ids()), ["bar"]) self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1}) + def test_save_panel_serialization_warning(self): + """The store should warn the user about a serialization error.""" + self.assertLogs() + + with self.assertLogs("debug_toolbar.store", level=logging.WARNING) as logs: + self.store.save_panel("bar", "bar.panel", {"value": {"foo"}}) + + self.assertEqual( + logs.output, + [ + "WARNING:debug_toolbar.store:Panel (bar.panel) failed to " + "serialized data {'value': {'foo'}} properly." + ], + ) + def test_panel(self): self.assertEqual(self.store.panel("missing", "missing"), {}) self.store.save_panel("bar", "bar.panel", {"a": 1}) From e7cf5758e89c3670df3e52e76e06717316a1151b Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Sun, 20 Aug 2023 18:43:58 -0500 Subject: [PATCH 05/25] Ignore common venv folder. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ee3559cc4..4b581aabd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ htmlcov .tox geckodriver.log coverage.xml +venv From c4201fa0f0dc5d3aad13735245e5e180479a1519 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Sun, 20 Aug 2023 14:39:32 -0500 Subject: [PATCH 06/25] Rename store_id variants to request_id This matches the new naming defined in the store module and will make things eaiser to change moving forward. This will break anything using the store internally causing issues for third party packages. --- debug_toolbar/panels/history/forms.py | 4 +- debug_toolbar/panels/history/panel.py | 12 +++--- debug_toolbar/panels/history/views.py | 8 ++-- .../static/debug_toolbar/js/history.js | 25 ++++++------ .../static/debug_toolbar/js/toolbar.js | 24 ++++++------ .../static/debug_toolbar/js/utils.js | 6 +-- .../templates/debug_toolbar/base.html | 2 +- .../debug_toolbar/panels/history_tr.html | 4 +- debug_toolbar/toolbar.py | 12 +++--- debug_toolbar/views.py | 2 +- docs/changes.rst | 2 + tests/panels/test_history.py | 38 +++++++++---------- tests/test_integration.py | 16 ++++---- 13 files changed, 81 insertions(+), 74 deletions(-) diff --git a/debug_toolbar/panels/history/forms.py b/debug_toolbar/panels/history/forms.py index 952b2409d..2aec18c34 100644 --- a/debug_toolbar/panels/history/forms.py +++ b/debug_toolbar/panels/history/forms.py @@ -5,8 +5,8 @@ class HistoryStoreForm(forms.Form): """ Validate params - store_id: The key for the store instance to be fetched. + request_id: The key for the store instance to be fetched. """ - store_id = forms.CharField(widget=forms.HiddenInput()) + request_id = forms.CharField(widget=forms.HiddenInput()) exclude_history = forms.BooleanField(widget=forms.HiddenInput(), required=False) diff --git a/debug_toolbar/panels/history/panel.py b/debug_toolbar/panels/history/panel.py index 508a60577..2ae1d1855 100644 --- a/debug_toolbar/panels/history/panel.py +++ b/debug_toolbar/panels/history/panel.py @@ -23,9 +23,9 @@ class HistoryPanel(Panel): def get_headers(self, request): headers = super().get_headers(request) observe_request = self.toolbar.get_observe_request() - store_id = self.toolbar.store_id - if store_id and observe_request(request): - headers["djdt-store-id"] = store_id + request_id = self.toolbar.request_id + if request_id and observe_request(request): + headers["djdt-request-id"] = request_id return headers @property @@ -91,18 +91,18 @@ def content(self): stores[id] = { "toolbar": toolbar, "form": HistoryStoreForm( - initial={"store_id": id, "exclude_history": True} + initial={"request_id": id, "exclude_history": True} ), } return render_to_string( self.template, { - "current_store_id": self.toolbar.store_id, + "current_request_id": self.toolbar.request_id, "stores": stores, "refresh_form": HistoryStoreForm( initial={ - "store_id": self.toolbar.store_id, + "request_id": self.toolbar.request_id, "exclude_history": True, } ), diff --git a/debug_toolbar/panels/history/views.py b/debug_toolbar/panels/history/views.py index 3fcbd9b32..0abbec294 100644 --- a/debug_toolbar/panels/history/views.py +++ b/debug_toolbar/panels/history/views.py @@ -13,12 +13,12 @@ def history_sidebar(request): form = HistoryStoreForm(request.GET) if form.is_valid(): - store_id = form.cleaned_data["store_id"] - toolbar = DebugToolbar.fetch(store_id) + request_id = form.cleaned_data["request_id"] + toolbar = DebugToolbar.fetch(request_id) exclude_history = form.cleaned_data["exclude_history"] context = {} if toolbar is None: - # When the store_id has been popped already due to + # When the request_id has been popped already due to # RESULTS_CACHE_SIZE return JsonResponse(context) for panel in toolbar.panels: @@ -58,7 +58,7 @@ def history_refresh(request): "toolbar": toolbar, "form": HistoryStoreForm( initial={ - "store_id": id, + "request_id": id, "exclude_history": True, } ), diff --git a/debug_toolbar/static/debug_toolbar/js/history.js b/debug_toolbar/static/debug_toolbar/js/history.js index b30fcabae..72ebbed72 100644 --- a/debug_toolbar/static/debug_toolbar/js/history.js +++ b/debug_toolbar/static/debug_toolbar/js/history.js @@ -25,14 +25,17 @@ function refreshHistory() { const formTarget = djDebug.querySelector(".refreshHistory"); const container = document.getElementById("djdtHistoryRequests"); const oldIds = new Set( - pluckData(container.querySelectorAll("tr[data-store-id]"), "storeId") + pluckData( + container.querySelectorAll("tr[data-request-id]"), + "requestId" + ) ); ajaxForm(formTarget) .then(function (data) { // Remove existing rows first then re-populate with new data container - .querySelectorAll("tr[data-store-id]") + .querySelectorAll("tr[data-request-id]") .forEach(function (node) { node.remove(); }); @@ -43,8 +46,8 @@ function refreshHistory() { .then(function () { const allIds = new Set( pluckData( - container.querySelectorAll("tr[data-store-id]"), - "storeId" + container.querySelectorAll("tr[data-request-id]"), + "requestId" ) ); const newIds = difference(allIds, oldIds); @@ -58,13 +61,13 @@ function refreshHistory() { .then(function (refreshInfo) { refreshInfo.newIds.forEach(function (newId) { const row = container.querySelector( - `tr[data-store-id="${newId}"]` + `tr[data-request-id="${newId}"]` ); row.classList.add("flash-new"); }); setTimeout(() => { container - .querySelectorAll("tr[data-store-id]") + .querySelectorAll("tr[data-request-id]") .forEach((row) => { row.classList.remove("flash-new"); }); @@ -72,9 +75,9 @@ function refreshHistory() { }); } -function switchHistory(newStoreId) { +function switchHistory(newRequestId) { const formTarget = djDebug.querySelector( - ".switchHistory[data-store-id='" + newStoreId + "']" + ".switchHistory[data-request-id='" + newRequestId + "']" ); const tbody = formTarget.closest("tbody"); @@ -88,16 +91,16 @@ function switchHistory(newStoreId) { if (Object.keys(data).length === 0) { const container = document.getElementById("djdtHistoryRequests"); container.querySelector( - 'button[data-store-id="' + newStoreId + '"]' + 'button[data-request-id="' + newRequestId + '"]' ).innerHTML = "Switch [EXPIRED]"; } - replaceToolbarState(newStoreId, data); + replaceToolbarState(newRequestId, data); }); } $$.on(djDebug, "click", ".switchHistory", function (event) { event.preventDefault(); - switchHistory(this.dataset.storeId); + switchHistory(this.dataset.requestId); }); $$.on(djDebug, "click", ".refreshHistory", function (event) { diff --git a/debug_toolbar/static/debug_toolbar/js/toolbar.js b/debug_toolbar/static/debug_toolbar/js/toolbar.js index 9546ef27e..f651f4ba8 100644 --- a/debug_toolbar/static/debug_toolbar/js/toolbar.js +++ b/debug_toolbar/static/debug_toolbar/js/toolbar.js @@ -37,13 +37,13 @@ const djdt = { const inner = current.querySelector( ".djDebugPanelContent .djdt-scroll" ), - storeId = djDebug.dataset.storeId; - if (storeId && inner.children.length === 0) { + requestId = djDebug.dataset.requestId; + if (requestId && inner.children.length === 0) { const url = new URL( djDebug.dataset.renderPanelUrl, window.location ); - url.searchParams.append("store_id", storeId); + url.searchParams.append("request_id", requestId); url.searchParams.append("panel_id", panelId); ajax(url).then(function (data) { inner.previousElementSibling.remove(); // Remove AJAX loader @@ -270,11 +270,11 @@ const djdt = { document.getElementById("djDebug").dataset.sidebarUrl; const slowjax = debounce(ajax, 200); - function handleAjaxResponse(storeId) { - storeId = encodeURIComponent(storeId); - const dest = `${sidebarUrl}?store_id=${storeId}`; + function handleAjaxResponse(requestId) { + requestId = encodeURIComponent(requestId); + const dest = `${sidebarUrl}?request_id=${requestId}`; slowjax(dest).then(function (data) { - replaceToolbarState(storeId, data); + replaceToolbarState(requestId, data); }); } @@ -286,9 +286,11 @@ const djdt = { // when the header can't be fetched. While it doesn't impede execution // it's worrisome to developers. if ( - this.getAllResponseHeaders().indexOf("djdt-store-id") >= 0 + this.getAllResponseHeaders().indexOf("djdt-request-id") >= 0 ) { - handleAjaxResponse(this.getResponseHeader("djdt-store-id")); + handleAjaxResponse( + this.getResponseHeader("djdt-request-id") + ); } }); origOpen.apply(this, arguments); @@ -298,8 +300,8 @@ const djdt = { window.fetch = function () { const promise = origFetch.apply(this, arguments); promise.then(function (response) { - if (response.headers.get("djdt-store-id") !== null) { - handleAjaxResponse(response.headers.get("djdt-store-id")); + if (response.headers.get("djdt-request-id") !== null) { + handleAjaxResponse(response.headers.get("djdt-request-id")); } // Don't resolve the response via .json(). Instead // continue to return it to allow the caller to consume as needed. diff --git a/debug_toolbar/static/debug_toolbar/js/utils.js b/debug_toolbar/static/debug_toolbar/js/utils.js index b4c7a4cb8..9fe3c90b3 100644 --- a/debug_toolbar/static/debug_toolbar/js/utils.js +++ b/debug_toolbar/static/debug_toolbar/js/utils.js @@ -105,10 +105,10 @@ function ajaxForm(element) { return ajax(url, ajaxData); } -function replaceToolbarState(newStoreId, data) { +function replaceToolbarState(newRequestId, data) { const djDebug = document.getElementById("djDebug"); - djDebug.setAttribute("data-store-id", newStoreId); - // Check if response is empty, it could be due to an expired storeId. + djDebug.setAttribute("data-request-id", newRequestId); + // Check if response is empty, it could be due to an expired requestId. Object.keys(data).forEach(function (panelId) { const panel = document.getElementById(panelId); if (panel) { diff --git a/debug_toolbar/templates/debug_toolbar/base.html b/debug_toolbar/templates/debug_toolbar/base.html index 5447970af..d7ae73e9d 100644 --- a/debug_toolbar/templates/debug_toolbar/base.html +++ b/debug_toolbar/templates/debug_toolbar/base.html @@ -8,7 +8,7 @@ {% endblock %}
+ {{ store_context.toolbar.stats.HistoryPanel.time|escape }} @@ -44,7 +44,7 @@
{{ store_context.form }} - +
diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 11f8a1daa..2c207e111 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -42,7 +42,7 @@ def __init__(self, request, get_response): self._panels[panel.panel_id] = panel self.stats = {} self.server_timing_stats = {} - self.store_id = None + self.request_id = None self._created.send(request, toolbar=self) # Manage panels @@ -110,16 +110,16 @@ def should_render_panels(self): def store(self): # Store already exists. - if self.store_id: + if self.request_id: return - self.store_id = uuid.uuid4().hex - self._store[self.store_id] = self + self.request_id = uuid.uuid4().hex + self._store[self.request_id] = self for _ in range(self.config["RESULTS_CACHE_SIZE"], len(self._store)): self._store.popitem(last=False) @classmethod - def fetch(cls, store_id): - return cls._store.get(store_id) + def fetch(cls, request_id): + return cls._store.get(request_id) # Manually implement class-level caching of panel classes and url patterns # because it's more obvious than going through an abstraction. diff --git a/debug_toolbar/views.py b/debug_toolbar/views.py index b93acbeed..401779251 100644 --- a/debug_toolbar/views.py +++ b/debug_toolbar/views.py @@ -10,7 +10,7 @@ @render_with_toolbar_language def render_panel(request): """Render the contents of a panel""" - toolbar = DebugToolbar.fetch(request.GET["store_id"]) + toolbar = DebugToolbar.fetch(request.GET["request_id"]) if toolbar is None: content = _( "Data for this panel isn't available anymore. " diff --git a/docs/changes.rst b/docs/changes.rst index 2dea4306f..d1da0ae8c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -9,6 +9,8 @@ Serializable (don't include in main) storage mechanism. Defaults to ``debug_toolbar.store.MemoryStore``. * Added setting ``SUPPRESS_SERIALIZATION_ERRORS`` to suppress warnings when a ``TypeError`` occurs during a panel's serialization. +* Rename ``store_id`` properties to ``request_id`` and ``Toolbar.store`` to + ``Toolbar.init_store``. Pending diff --git a/tests/panels/test_history.py b/tests/panels/test_history.py index 2e0aa2179..bacf3ba25 100644 --- a/tests/panels/test_history.py +++ b/tests/panels/test_history.py @@ -101,8 +101,8 @@ def test_history_sidebar_invalid(self): def test_history_headers(self): """Validate the headers injected from the history panel.""" response = self.client.get("/json_view/") - store_id = list(DebugToolbar._store)[0] - self.assertEqual(response.headers["djdt-store-id"], store_id) + request_id = list(DebugToolbar._store)[0] + self.assertEqual(response.headers["djdt-request-id"], request_id) @override_settings( DEBUG_TOOLBAR_CONFIG={"OBSERVE_REQUEST_CALLBACK": lambda request: False} @@ -110,13 +110,13 @@ def test_history_headers(self): def test_history_headers_unobserved(self): """Validate the headers aren't injected from the history panel.""" response = self.client.get("/json_view/") - self.assertNotIn("djdt-store-id", response.headers) + self.assertNotIn("djdt-request-id", response.headers) def test_history_sidebar(self): """Validate the history sidebar view.""" self.client.get("/json_view/") - store_id = list(DebugToolbar._store)[0] - data = {"store_id": store_id, "exclude_history": True} + request_id = list(DebugToolbar._store)[0] + data = {"request_id": request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -130,8 +130,8 @@ def test_history_sidebar_includes_history(self): panel_keys = copy.copy(self.PANEL_KEYS) panel_keys.add("HistoryPanel") panel_keys.add("RedirectsPanel") - store_id = list(DebugToolbar._store)[0] - data = {"store_id": store_id} + request_id = list(DebugToolbar._store)[0] + data = {"request_id": request_id} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -142,11 +142,11 @@ def test_history_sidebar_includes_history(self): @override_settings( DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 1, "RENDER_PANELS": False} ) - def test_history_sidebar_expired_store_id(self): + def test_history_sidebar_expired_request_id(self): """Validate the history sidebar view.""" self.client.get("/json_view/") - store_id = list(DebugToolbar._store)[0] - data = {"store_id": store_id, "exclude_history": True} + request_id = list(DebugToolbar._store)[0] + data = {"request_id": request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -155,15 +155,15 @@ def test_history_sidebar_expired_store_id(self): ) self.client.get("/json_view/") - # Querying old store_id should return in empty response - data = {"store_id": store_id, "exclude_history": True} + # Querying old request_id should return in empty response + data = {"request_id": request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {}) - # Querying with latest store_id - latest_store_id = list(DebugToolbar._store)[0] - data = {"store_id": latest_store_id, "exclude_history": True} + # Querying with latest request_id + latest_request_id = list(DebugToolbar._store)[0] + data = {"request_id": latest_request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) self.assertEqual( @@ -179,15 +179,15 @@ def test_history_refresh(self): ) response = self.client.get( - reverse("djdt:history_refresh"), data={"store_id": "foo"} + reverse("djdt:history_refresh"), data={"request_id": "foo"} ) self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(len(data["requests"]), 2) - store_ids = list(DebugToolbar._store) - self.assertIn(html.escape(store_ids[0]), data["requests"][0]["content"]) - self.assertIn(html.escape(store_ids[1]), data["requests"][1]["content"]) + request_ids = list(DebugToolbar._store) + self.assertIn(html.escape(request_ids[0]), data["requests"][0]["content"]) + self.assertIn(html.escape(request_ids[1]), data["requests"][1]["content"]) for val in ["foo", "bar"]: self.assertIn(val, data["requests"][0]["content"]) diff --git a/tests/test_integration.py b/tests/test_integration.py index b77b7cede..4fceffe3c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -34,13 +34,13 @@ rf = RequestFactory() -def toolbar_store_id(): +def toolbar_request_id(): def get_response(request): return HttpResponse() toolbar = DebugToolbar(rf.get("/"), get_response) toolbar.store() - return toolbar.store_id + return toolbar.request_id class BuggyPanel(Panel): @@ -214,7 +214,7 @@ def test_is_toolbar_request_override_request_urlconf(self): def test_data_gone(self): response = self.client.get( - "/__debug__/render_panel/?store_id=GONE&panel_id=RequestPanel" + "/__debug__/render_panel/?request_id=GONE&panel_id=RequestPanel" ) self.assertIn("Please reload the page and retry.", response.json()["content"]) @@ -252,7 +252,7 @@ def test_html5_validation(self): def test_render_panel_checks_show_toolbar(self): url = "/__debug__/render_panel/" - data = {"store_id": toolbar_store_id(), "panel_id": "VersionsPanel"} + data = {"request_id": toolbar_request_id(), "panel_id": "VersionsPanel"} response = self.client.get(url, data) self.assertEqual(response.status_code, 200) @@ -442,7 +442,7 @@ def test_render_panels_in_request(self): response = self.client.get(url) self.assertIn(b'id="djDebug"', response.content) # Verify the store id is not included. - self.assertNotIn(b"data-store-id", response.content) + self.assertNotIn(b"data-request-id", response.content) # Verify the history panel was disabled self.assertIn( b' Date: Sun, 20 Aug 2023 16:39:48 -0500 Subject: [PATCH 07/25] Support serializable panels. This is a WIP and needs clean-up. The remainder of the work is to fix the individual panels' serialization errors. --- debug_toolbar/panels/__init__.py | 19 +++++ debug_toolbar/panels/history/panel.py | 11 ++- debug_toolbar/panels/history/views.py | 10 ++- debug_toolbar/panels/settings.py | 10 ++- debug_toolbar/panels/templates/panel.py | 12 ++- debug_toolbar/store.py | 2 +- .../debug_toolbar/panels/history.html | 2 +- .../debug_toolbar/panels/history_tr.html | 8 +- debug_toolbar/toolbar.py | 73 ++++++++++++++----- debug_toolbar/views.py | 2 +- docs/changes.rst | 7 +- tests/base.py | 4 +- tests/panels/test_history.py | 33 +++++---- tests/settings.py | 3 +- tests/test_integration.py | 17 +++-- 15 files changed, 148 insertions(+), 65 deletions(-) diff --git a/debug_toolbar/panels/__init__.py b/debug_toolbar/panels/__init__.py index 57f385a5e..71d33ae55 100644 --- a/debug_toolbar/panels/__init__.py +++ b/debug_toolbar/panels/__init__.py @@ -12,6 +12,7 @@ class Panel: def __init__(self, toolbar, get_response): self.toolbar = toolbar self.get_response = get_response + self.from_store = False # Private panel properties @@ -21,6 +22,12 @@ def panel_id(self): @property def enabled(self) -> bool: + if self.from_store: + # If the toolbar was loaded from the store the existence of + # recorded data indicates whether it was enabled or not. + # We can't use the remainder of the logic since we don't have + # a request to work off of. + return bool(self.get_stats()) # 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: @@ -168,6 +175,9 @@ def record_stats(self, stats): Each call to ``record_stats`` updates the statistics dictionary. """ self.toolbar.stats.setdefault(self.panel_id, {}).update(stats) + self.toolbar.store.save_panel( + self.toolbar.request_id, self.panel_id, self.toolbar.stats[self.panel_id] + ) def get_stats(self): """ @@ -251,6 +261,15 @@ def generate_server_timing(self, request, response): Does not return a value. """ + def load_stats_from_store(self, data): + """ + Instantiate the panel from serialized data. + + Return the panel instance. + """ + self.toolbar.stats.setdefault(self.panel_id, {}).update(data) + self.from_store = True + @classmethod def run_checks(cls): """ diff --git a/debug_toolbar/panels/history/panel.py b/debug_toolbar/panels/history/panel.py index 2ae1d1855..684b5f7bf 100644 --- a/debug_toolbar/panels/history/panel.py +++ b/debug_toolbar/panels/history/panel.py @@ -86,12 +86,11 @@ def content(self): Fetch every store for the toolbar and include it in the template. """ - stores = {} - for id, toolbar in reversed(self.toolbar._store.items()): - stores[id] = { - "toolbar": toolbar, + toolbar_history = {} + for request_id in reversed(self.toolbar.store.request_ids()): + toolbar_history[request_id] = { "form": HistoryStoreForm( - initial={"request_id": id, "exclude_history": True} + initial={"request_id": request_id, "exclude_history": True} ), } @@ -99,7 +98,7 @@ def content(self): self.template, { "current_request_id": self.toolbar.request_id, - "stores": stores, + "toolbar_history": toolbar_history, "refresh_form": HistoryStoreForm( initial={ "request_id": self.toolbar.request_id, diff --git a/debug_toolbar/panels/history/views.py b/debug_toolbar/panels/history/views.py index 0abbec294..61d96c265 100644 --- a/debug_toolbar/panels/history/views.py +++ b/debug_toolbar/panels/history/views.py @@ -3,6 +3,7 @@ from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar from debug_toolbar.panels.history.forms import HistoryStoreForm +from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar @@ -46,19 +47,20 @@ def history_refresh(request): if form.is_valid(): requests = [] # Convert to list to handle mutations happening in parallel - for id, toolbar in list(DebugToolbar._store.items()): + for request_id in get_store().request_ids(): + toolbar = DebugToolbar.fetch(request_id) requests.append( { - "id": id, + "id": request_id, "content": render_to_string( "debug_toolbar/panels/history_tr.html", { - "id": id, + "id": request_id, "store_context": { "toolbar": toolbar, "form": HistoryStoreForm( initial={ - "request_id": id, + "request_id": request_id, "exclude_history": True, } ), diff --git a/debug_toolbar/panels/settings.py b/debug_toolbar/panels/settings.py index 7b27c6243..4b694d5bd 100644 --- a/debug_toolbar/panels/settings.py +++ b/debug_toolbar/panels/settings.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from django.views.debug import get_default_exception_reporter_filter @@ -20,4 +21,11 @@ def title(self): return _("Settings from %s") % settings.SETTINGS_MODULE def generate_stats(self, request, response): - self.record_stats({"settings": dict(sorted(get_safe_settings().items()))}) + self.record_stats( + { + "settings": { + key: force_str(value) + for key, value in sorted(get_safe_settings().items()) + } + } + ) diff --git a/debug_toolbar/panels/templates/panel.py b/debug_toolbar/panels/templates/panel.py index 72565f016..75bca5239 100644 --- a/debug_toolbar/panels/templates/panel.py +++ b/debug_toolbar/panels/templates/panel.py @@ -9,6 +9,7 @@ from django.test.signals import template_rendered from django.test.utils import instrumented_test_render from django.urls import path +from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from debug_toolbar.panels import Panel @@ -179,7 +180,7 @@ def generate_stats(self, request, response): else: template.origin_name = _("No origin") template.origin_hash = "" - info["template"] = template + info["template"] = force_str(template) # Clean up context for better readability if self.toolbar.config["SHOW_TEMPLATE_CONTEXT"]: context_list = template_data.get("context", []) @@ -188,7 +189,14 @@ def generate_stats(self, request, response): # Fetch context_processors/template_dirs from any template if self.templates: - context_processors = self.templates[0]["context_processors"] + context_processors = ( + { + key: force_str(value) + for key, value in self.templates[0]["context_processors"].items() + } + if self.templates[0]["context_processors"] + else None + ) template = self.templates[0]["template"] # django templates have the 'engine' attribute, while jinja # templates use 'backend' diff --git a/debug_toolbar/store.py b/debug_toolbar/store.py index 0bba0c2ef..5f8f5f893 100644 --- a/debug_toolbar/store.py +++ b/debug_toolbar/store.py @@ -126,5 +126,5 @@ def panel(cls, request_id: str, panel_id: str) -> Any: return deserialize(data) -def get_store(): +def get_store() -> BaseStore: return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"]) diff --git a/debug_toolbar/templates/debug_toolbar/panels/history.html b/debug_toolbar/templates/debug_toolbar/panels/history.html index 84c6cb5bd..f42e08e0a 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/history.html +++ b/debug_toolbar/templates/debug_toolbar/panels/history.html @@ -15,7 +15,7 @@ - {% for id, store_context in stores.items %} + {% for request_id, store_context in toolbar_history.items %} {% include "debug_toolbar/panels/history_tr.html" %} {% endfor %} diff --git a/debug_toolbar/templates/debug_toolbar/panels/history_tr.html b/debug_toolbar/templates/debug_toolbar/panels/history_tr.html index 91d8120ba..db1ef1251 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/history_tr.html +++ b/debug_toolbar/templates/debug_toolbar/panels/history_tr.html @@ -1,5 +1,5 @@ {% load i18n %} - + {{ store_context.toolbar.stats.HistoryPanel.time|escape }} @@ -10,8 +10,8 @@

{{ store_context.toolbar.stats.HistoryPanel.request_url|truncatechars:100|escape }}

- -
+ +
@@ -44,7 +44,7 @@ diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 2c207e111..7b6323fd0 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -1,9 +1,8 @@ """ The main DebugToolbar class that loads and renders the Toolbar. """ - +import logging import uuid -from collections import OrderedDict from functools import lru_cache from django.apps import apps @@ -17,13 +16,17 @@ from django.utils.translation import get_language, override as lang_override from debug_toolbar import APP_NAME, settings as dt_settings +from debug_toolbar.store import get_store + +logger = logging.getLogger(__name__) class DebugToolbar: # for internal testing use only _created = Signal() + store = None - def __init__(self, request, get_response): + def __init__(self, request, get_response, request_id=None): self.request = request self.config = dt_settings.get_config().copy() panels = [] @@ -33,16 +36,11 @@ def __init__(self, request, get_response): if panel.enabled: get_response = panel.process_request self.process_request = get_response - # Use OrderedDict for the _panels attribute so that items can be efficiently - # removed using FIFO order in the DebugToolbar.store() method. The .popitem() - # method of Python's built-in dict only supports LIFO removal. - self._panels = OrderedDict() - while panels: - panel = panels.pop() - self._panels[panel.panel_id] = panel + self._panels = {panel.panel_id: panel for panel in reversed(panels)} self.stats = {} self.server_timing_stats = {} - self.request_id = None + self.request_id = request_id + self.init_store() self._created.send(request, toolbar=self) # Manage panels @@ -74,7 +72,7 @@ def render_toolbar(self): Renders the overall Toolbar with panels inside. """ if not self.should_render_panels(): - self.store() + self.init_store() try: context = {"toolbar": self} lang = self.config["TOOLBAR_LANGUAGE"] or get_language() @@ -106,20 +104,20 @@ def should_render_panels(self): # Handle storing toolbars in memory and fetching them later on - _store = OrderedDict() + def init_store(self): + # Store already initialized. + if self.store is None: + self.store = get_store() - def store(self): - # Store already exists. if self.request_id: return self.request_id = uuid.uuid4().hex - self._store[self.request_id] = self - for _ in range(self.config["RESULTS_CACHE_SIZE"], len(self._store)): - self._store.popitem(last=False) + self.store.set(self.request_id) @classmethod - def fetch(cls, request_id): - return cls._store.get(request_id) + def fetch(cls, request_id, panel_id=None): + if get_store().exists(request_id): + return StoredDebugToolbar.from_store(request_id, panel_id=panel_id) # Manually implement class-level caching of panel classes and url patterns # because it's more obvious than going through an abstraction. @@ -186,3 +184,38 @@ def observe_request(request): Determine whether to update the toolbar from a client side request. """ return not DebugToolbar.is_toolbar_request(request) + + +def from_store_get_response(request): + logger.warning( + "get_response was called for debug toolbar after being loaded from the store. No request exists in this scenario as the request is not stored, only the panel's data." + ) + return None + + +class StoredDebugToolbar(DebugToolbar): + def __init__(self, request, get_response, request_id=None): + self.request = None + self.config = dt_settings.get_config().copy() + self.process_request = get_response + self.stats = {} + self.server_timing_stats = {} + self.request_id = request_id + self.init_store() + + @classmethod + def from_store(cls, request_id, panel_id=None): + toolbar = StoredDebugToolbar( + None, from_store_get_response, request_id=request_id + ) + toolbar._panels = {} + + for panel_class in reversed(cls.get_panel_classes()): + panel = panel_class(toolbar, from_store_get_response) + if panel_id and panel.panel_id != panel_id: + continue + data = toolbar.store.panel(toolbar.request_id, panel.panel_id) + if data: + panel.load_stats_from_store(data) + toolbar._panels[panel.panel_id] = panel + return toolbar diff --git a/debug_toolbar/views.py b/debug_toolbar/views.py index 401779251..5d0553f5d 100644 --- a/debug_toolbar/views.py +++ b/debug_toolbar/views.py @@ -10,7 +10,7 @@ @render_with_toolbar_language def render_panel(request): """Render the contents of a panel""" - toolbar = DebugToolbar.fetch(request.GET["request_id"]) + toolbar = DebugToolbar.fetch(request.GET["request_id"], request.GET["panel_id"]) if toolbar is None: content = _( "Data for this panel isn't available anymore. " diff --git a/docs/changes.rst b/docs/changes.rst index d1da0ae8c..6ac00f71b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -11,7 +11,12 @@ Serializable (don't include in main) warnings when a ``TypeError`` occurs during a panel's serialization. * Rename ``store_id`` properties to ``request_id`` and ``Toolbar.store`` to ``Toolbar.init_store``. - +* Support ``Panel`` instances with stored stats via + ``Panel.load_stats_from_store``. +* Swapped ``Toolbar._store`` for the ``get_store()`` class. +* Created a ``StoredDebugToolbar`` that support creating an instance of the + toolbar representing an old request. It should only be used for fetching + panels' contents. Pending ------- diff --git a/tests/base.py b/tests/base.py index 5cc432add..abdb84008 100644 --- a/tests/base.py +++ b/tests/base.py @@ -3,6 +3,7 @@ from django.http import HttpResponse from django.test import Client, RequestFactory, TestCase, TransactionTestCase +from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar @@ -82,6 +83,5 @@ def setUp(self): # The HistoryPanel keeps track of previous stores in memory. # This bleeds into other tests and violates their idempotency. # Clear the store before each test. - for key in list(DebugToolbar._store.keys()): - del DebugToolbar._store[key] + get_store().clear() super().setUp() diff --git a/tests/panels/test_history.py b/tests/panels/test_history.py index bacf3ba25..19998f41e 100644 --- a/tests/panels/test_history.py +++ b/tests/panels/test_history.py @@ -4,8 +4,10 @@ from django.test import RequestFactory, override_settings from django.urls import resolve, reverse +from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar +from .. import settings as test_settings from ..base import BaseTestCase, IntegrationTestCase rf = RequestFactory() @@ -77,19 +79,20 @@ class HistoryViewsTestCase(IntegrationTestCase): "TemplatesPanel", "CachePanel", "SignalsPanel", - "ProfilingPanel", } def test_history_panel_integration_content(self): """Verify the history panel's content renders properly..""" - self.assertEqual(len(DebugToolbar._store), 0) + store = get_store() + self.assertEqual(len(list(store.request_ids())), 0) data = {"foo": "bar"} self.client.get("/json_view/", data, content_type="application/json") # Check the history panel's stats to verify the toolbar rendered properly. - self.assertEqual(len(DebugToolbar._store), 1) - toolbar = list(DebugToolbar._store.values())[0] + request_ids = list(store.request_ids()) + self.assertEqual(len(request_ids), 1) + toolbar = DebugToolbar.fetch(request_ids[0]) content = toolbar.get_panel_by_id("HistoryPanel").content self.assertIn("bar", content) self.assertIn('name="exclude_history" value="True"', content) @@ -101,7 +104,7 @@ def test_history_sidebar_invalid(self): def test_history_headers(self): """Validate the headers injected from the history panel.""" response = self.client.get("/json_view/") - request_id = list(DebugToolbar._store)[0] + request_id = list(get_store().request_ids())[0] self.assertEqual(response.headers["djdt-request-id"], request_id) @override_settings( @@ -115,7 +118,7 @@ def test_history_headers_unobserved(self): def test_history_sidebar(self): """Validate the history sidebar view.""" self.client.get("/json_view/") - request_id = list(DebugToolbar._store)[0] + request_id = list(get_store().request_ids())[0] data = {"request_id": request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) @@ -129,8 +132,7 @@ def test_history_sidebar_includes_history(self): self.client.get("/json_view/") panel_keys = copy.copy(self.PANEL_KEYS) panel_keys.add("HistoryPanel") - panel_keys.add("RedirectsPanel") - request_id = list(DebugToolbar._store)[0] + request_id = list(get_store().request_ids())[0] data = {"request_id": request_id} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) @@ -139,13 +141,11 @@ def test_history_sidebar_includes_history(self): panel_keys, ) - @override_settings( - DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 1, "RENDER_PANELS": False} - ) + @override_settings(DEBUG_TOOLBAR_CONFIG={"RENDER_PANELS": False}) def test_history_sidebar_expired_request_id(self): """Validate the history sidebar view.""" self.client.get("/json_view/") - request_id = list(DebugToolbar._store)[0] + request_id = list(get_store().request_ids())[0] data = {"request_id": request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) @@ -153,7 +153,9 @@ def test_history_sidebar_expired_request_id(self): set(response.json()), self.PANEL_KEYS, ) - self.client.get("/json_view/") + # Make enough requests to unset the original + for _i in range(test_settings.DEBUG_TOOLBAR_CONFIG["RESULTS_CACHE_SIZE"]): + self.client.get("/json_view/") # Querying old request_id should return in empty response data = {"request_id": request_id, "exclude_history": True} @@ -162,10 +164,11 @@ def test_history_sidebar_expired_request_id(self): self.assertEqual(response.json(), {}) # Querying with latest request_id - latest_request_id = list(DebugToolbar._store)[0] + latest_request_id = list(get_store().request_ids())[0] data = {"request_id": latest_request_id, "exclude_history": True} response = self.client.get(reverse("djdt:history_sidebar"), data=data) self.assertEqual(response.status_code, 200) + self.assertEqual( set(response.json()), self.PANEL_KEYS, @@ -185,7 +188,7 @@ def test_history_refresh(self): data = response.json() self.assertEqual(len(data["requests"]), 2) - request_ids = list(DebugToolbar._store) + request_ids = list(get_store().request_ids()) self.assertIn(html.escape(request_ids[0]), data["requests"][0]["content"]) self.assertIn(html.escape(request_ids[1]), data["requests"][1]["content"]) diff --git a/tests/settings.py b/tests/settings.py index b3c281242..e9556592b 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -126,5 +126,6 @@ DEBUG_TOOLBAR_CONFIG = { # Django's test client sets wsgi.multiprocess to True inappropriately - "RENDER_PANELS": False + "RENDER_PANELS": False, + "RESULTS_CACHE_SIZE": 3, } diff --git a/tests/test_integration.py b/tests/test_integration.py index 4fceffe3c..b0e478483 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -15,6 +15,7 @@ from debug_toolbar.forms import SignedDataForm from debug_toolbar.middleware import DebugToolbarMiddleware, show_toolbar from debug_toolbar.panels import Panel +from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar from .base import BaseTestCase, IntegrationTestCase @@ -39,7 +40,7 @@ def get_response(request): return HttpResponse() toolbar = DebugToolbar(rf.get("/"), get_response) - toolbar.store() + toolbar.init_store() return toolbar.request_id @@ -252,7 +253,9 @@ def test_html5_validation(self): def test_render_panel_checks_show_toolbar(self): url = "/__debug__/render_panel/" - data = {"request_id": toolbar_request_id(), "panel_id": "VersionsPanel"} + request_id = toolbar_request_id() + get_store().save_panel(request_id, "VersionsPanel", {"value": "Test data"}) + data = {"request_id": request_id, "panel_id": "VersionsPanel"} response = self.client.get(url, data) self.assertEqual(response.status_code, 200) @@ -268,18 +271,20 @@ def test_render_panel_checks_show_toolbar(self): def test_middleware_render_toolbar_json(self): """Verify the toolbar is rendered and data is stored for a json request.""" - self.assertEqual(len(DebugToolbar._store), 0) + store = get_store() + self.assertEqual(len(list(store.request_ids())), 0) data = {"foo": "bar"} response = self.client.get("/json_view/", data, content_type="application/json") self.assertEqual(response.status_code, 200) self.assertEqual(response.content.decode("utf-8"), '{"foo": "bar"}') # Check the history panel's stats to verify the toolbar rendered properly. - self.assertEqual(len(DebugToolbar._store), 1) - toolbar = list(DebugToolbar._store.values())[0] + request_ids = list(store.request_ids()) + self.assertEqual(len(request_ids), 1) + toolbar = DebugToolbar.fetch(request_ids[0]) self.assertEqual( toolbar.get_panel_by_id("HistoryPanel").get_stats()["data"], - {"foo": ["bar"]}, + {"foo": "bar"}, ) def test_template_source_checks_show_toolbar(self): From e2f695b98d8058432a2a70d11a6f4a2f107e51d1 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Sun, 20 Aug 2023 21:01:03 -0500 Subject: [PATCH 08/25] Support serializable sql panel --- debug_toolbar/panels/sql/forms.py | 96 ++++++++++++++++++++++++---- debug_toolbar/panels/sql/panel.py | 54 +++++++++++----- debug_toolbar/panels/sql/tracking.py | 1 - debug_toolbar/panels/sql/views.py | 80 ++++++----------------- docs/changes.rst | 6 ++ tests/panels/test_sql.py | 30 ++++++--- tests/test_integration.py | 60 ++++++++++------- tests/urls.py | 1 + tests/views.py | 7 ++ 9 files changed, 217 insertions(+), 118 deletions(-) diff --git a/debug_toolbar/panels/sql/forms.py b/debug_toolbar/panels/sql/forms.py index 0515c5c8e..d4fff35ee 100644 --- a/debug_toolbar/panels/sql/forms.py +++ b/debug_toolbar/panels/sql/forms.py @@ -4,25 +4,22 @@ from django.core.exceptions import ValidationError from django.db import connections from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ from debug_toolbar.panels.sql.utils import reformat_sql +from debug_toolbar.toolbar import DebugToolbar class SQLSelectForm(forms.Form): """ Validate params - sql: The sql statement with interpolated params - raw_sql: The sql statement with placeholders - params: JSON encoded parameter values - duration: time for SQL to execute passed in from toolbar just for redisplay + request_id: The identifier for the request + query_id: The identifier for the query """ - sql = forms.CharField() - raw_sql = forms.CharField() - params = forms.CharField() - alias = forms.CharField(required=False, initial="default") - duration = forms.FloatField() + request_id = forms.CharField() + djdt_query_id = forms.CharField() def clean_raw_sql(self): value = self.cleaned_data["raw_sql"] @@ -48,12 +45,89 @@ def clean_alias(self): return value + def clean(self): + cleaned_data = super().clean() + toolbar = DebugToolbar.fetch( + self.cleaned_data["request_id"], panel_id="SQLPanel" + ) + if toolbar is None: + raise ValidationError(_("Data for this panel isn't available anymore.")) + + panel = toolbar.get_panel_by_id("SQLPanel") + # Find the query for this form submission + query = None + for q in panel.get_stats()["queries"]: + if q["djdt_query_id"] != self.cleaned_data["djdt_query_id"]: + continue + else: + query = q + break + if not query: + raise ValidationError(_("Invalid query id.")) + cleaned_data["query"] = query + return cleaned_data + + def select(self): + query = self.cleaned_data["query"] + sql = query["raw_sql"] + params = json.loads(query["params"]) + with self.cursor as cursor: + cursor.execute(sql, params) + headers = [d[0] for d in cursor.description] + result = cursor.fetchall() + return result, headers + + def explain(self): + query = self.cleaned_data["query"] + sql = query["raw_sql"] + params = json.loads(query["params"]) + vendor = query["vendor"] + with self.cursor as cursor: + if vendor == "sqlite": + # SQLite's EXPLAIN dumps the low-level opcodes generated for a query; + # EXPLAIN QUERY PLAN dumps a more human-readable summary + # See https://www.sqlite.org/lang_explain.html for details + cursor.execute(f"EXPLAIN QUERY PLAN {sql}", params) + elif vendor == "postgresql": + cursor.execute(f"EXPLAIN ANALYZE {sql}", params) + else: + cursor.execute(f"EXPLAIN {sql}", params) + headers = [d[0] for d in cursor.description] + result = cursor.fetchall() + return result, headers + + def profile(self): + query = self.cleaned_data["query"] + sql = query["raw_sql"] + params = json.loads(query["params"]) + with self.cursor as cursor: + cursor.execute("SET PROFILING=1") # Enable profiling + cursor.execute(sql, params) # Execute SELECT + cursor.execute("SET PROFILING=0") # Disable profiling + # The Query ID should always be 1 here but I'll subselect to get + # the last one just in case... + cursor.execute( + """ + SELECT * + FROM information_schema.profiling + WHERE query_id = ( + SELECT query_id + FROM information_schema.profiling + ORDER BY query_id DESC + LIMIT 1 + ) + """ + ) + headers = [d[0] for d in cursor.description] + result = cursor.fetchall() + return result, headers + def reformat_sql(self): - return reformat_sql(self.cleaned_data["sql"], with_toggle=False) + return reformat_sql(self.cleaned_data["query"]["sql"], with_toggle=False) @property def connection(self): - return connections[self.cleaned_data["alias"]] + return connections[self.cleaned_data["query"]["alias"]] @cached_property def cursor(self): diff --git a/debug_toolbar/panels/sql/panel.py b/debug_toolbar/panels/sql/panel.py index 58c1c2738..873bcee8c 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -1,9 +1,10 @@ import uuid from collections import defaultdict -from copy import copy from django.db import connections +from django.template.loader import render_to_string from django.urls import path +from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _, ngettext from debug_toolbar import settings as dt_settings @@ -81,7 +82,7 @@ def _similar_query_key(query): def _duplicate_query_key(query): - raw_params = () if query["raw_params"] is None else tuple(query["raw_params"]) + raw_params = () if query["params"] is None else tuple(query["params"]) # repr() avoids problems because of unhashable types # (e.g. lists) when used as dictionary keys. # https://github.com/jazzband/django-debug-toolbar/issues/1091 @@ -139,6 +140,7 @@ def current_transaction_id(self, alias): return trans_id def record(self, **kwargs): + kwargs["djdt_query_id"] = uuid.uuid4().hex self._queries.append(kwargs) alias = kwargs["alias"] if alias not in self._databases: @@ -197,8 +199,6 @@ def disable_instrumentation(self): connection._djdt_logger = None def generate_stats(self, request, response): - colors = contrasting_color_generator() - trace_colors = defaultdict(lambda: next(colors)) similar_query_groups = defaultdict(list) duplicate_query_groups = defaultdict(list) @@ -255,14 +255,6 @@ def generate_stats(self, request, response): query["trans_status"] = get_transaction_status_display( query["vendor"], query["trans_status"] ) - - query["form"] = SignedDataForm( - auto_id=None, initial=SQLSelectForm(initial=copy(query)).initial - ) - - if query["sql"]: - query["sql"] = reformat_sql(query["sql"], with_toggle=True) - query["is_slow"] = query["duration"] > sql_warning_threshold query["is_select"] = ( query["raw_sql"].lower().lstrip().startswith("select") @@ -276,9 +268,6 @@ def generate_stats(self, request, response): query["start_offset"] = width_ratio_tally query["end_offset"] = query["width_ratio"] + query["start_offset"] width_ratio_tally += query["width_ratio"] - query["stacktrace"] = render_stacktrace(query["stacktrace"]) - - query["trace_color"] = trace_colors[query["stacktrace"]] last_by_alias[alias] = query @@ -311,3 +300,38 @@ def generate_server_timing(self, request, response): title = "SQL {} queries".format(len(stats.get("queries", []))) value = stats.get("sql_time", 0) self.record_server_timing("sql_time", title, value) + + def record_stats(self, stats): + """ + Store data gathered by the panel. ``stats`` is a :class:`dict`. + + Each call to ``record_stats`` updates the statistics dictionary. + """ + for query in stats.get("queries", []): + query["params"] + return super().record_stats(stats) + + # Cache the content property since it manipulates the queries in the stats + # This allows the caller to treat content as idempotent + @cached_property + def content(self): + if self.has_content: + stats = self.get_stats() + colors = contrasting_color_generator() + trace_colors = defaultdict(lambda: next(colors)) + + for query in stats.get("queries", []): + query["sql"] = reformat_sql(query["sql"], with_toggle=True) + query["form"] = SignedDataForm( + auto_id=None, + initial=SQLSelectForm( + initial={ + "djdt_query_id": query["djdt_query_id"], + "request_id": self.toolbar.request_id, + } + ).initial, + ) + query["stacktrace"] = render_stacktrace(query["stacktrace"]) + query["trace_color"] = trace_colors[query["stacktrace"]] + + return render_to_string(self.template, stats) diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py index 0c53dc2c5..a8c7ee677 100644 --- a/debug_toolbar/panels/sql/tracking.py +++ b/debug_toolbar/panels/sql/tracking.py @@ -201,7 +201,6 @@ def _record(self, method, sql, params): "duration": duration, "raw_sql": sql, "params": _params, - "raw_params": params, "stacktrace": get_stack_trace(skip=2), "template_info": template_info, } diff --git a/debug_toolbar/panels/sql/views.py b/debug_toolbar/panels/sql/views.py index 4b6ced9da..b498c140a 100644 --- a/debug_toolbar/panels/sql/views.py +++ b/debug_toolbar/panels/sql/views.py @@ -5,6 +5,7 @@ from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar from debug_toolbar.forms import SignedDataForm from debug_toolbar.panels.sql.forms import SQLSelectForm +from debug_toolbar.panels.sql.utils import reformat_sql def get_signed_data(request): @@ -27,19 +28,14 @@ def sql_select(request): form = SQLSelectForm(verified_data) if form.is_valid(): - sql = form.cleaned_data["raw_sql"] - params = form.cleaned_data["params"] - with form.cursor as cursor: - cursor.execute(sql, params) - headers = [d[0] for d in cursor.description] - result = cursor.fetchall() - + query = form.cleaned_data["query"] + result, headers = form.select() context = { "result": result, - "sql": form.reformat_sql(), - "duration": form.cleaned_data["duration"], + "sql": reformat_sql(query["sql"], with_toggle=False), + "duration": query["duration"], "headers": headers, - "alias": form.cleaned_data["alias"], + "alias": query["alias"], } content = render_to_string("debug_toolbar/panels/sql_select.html", context) return JsonResponse({"content": content}) @@ -57,28 +53,14 @@ def sql_explain(request): form = SQLSelectForm(verified_data) if form.is_valid(): - sql = form.cleaned_data["raw_sql"] - params = form.cleaned_data["params"] - vendor = form.connection.vendor - with form.cursor as cursor: - if vendor == "sqlite": - # SQLite's EXPLAIN dumps the low-level opcodes generated for a query; - # EXPLAIN QUERY PLAN dumps a more human-readable summary - # See https://www.sqlite.org/lang_explain.html for details - cursor.execute(f"EXPLAIN QUERY PLAN {sql}", params) - elif vendor == "postgresql": - cursor.execute(f"EXPLAIN ANALYZE {sql}", params) - else: - cursor.execute(f"EXPLAIN {sql}", params) - headers = [d[0] for d in cursor.description] - result = cursor.fetchall() - + query = form.cleaned_data["query"] + result, headers = form.explain() context = { "result": result, - "sql": form.reformat_sql(), - "duration": form.cleaned_data["duration"], + "sql": reformat_sql(query["sql"], with_toggle=False), + "duration": query["duration"], "headers": headers, - "alias": form.cleaned_data["alias"], + "alias": query["alias"], } content = render_to_string("debug_toolbar/panels/sql_explain.html", context) return JsonResponse({"content": content}) @@ -96,45 +78,25 @@ def sql_profile(request): form = SQLSelectForm(verified_data) if form.is_valid(): - sql = form.cleaned_data["raw_sql"] - params = form.cleaned_data["params"] + query = form.cleaned_data["query"] result = None headers = None result_error = None - with form.cursor as cursor: - try: - cursor.execute("SET PROFILING=1") # Enable profiling - cursor.execute(sql, params) # Execute SELECT - cursor.execute("SET PROFILING=0") # Disable profiling - # The Query ID should always be 1 here but I'll subselect to get - # the last one just in case... - cursor.execute( - """ - SELECT * - FROM information_schema.profiling - WHERE query_id = ( - SELECT query_id - FROM information_schema.profiling - ORDER BY query_id DESC - LIMIT 1 - ) - """ - ) - headers = [d[0] for d in cursor.description] - result = cursor.fetchall() - except Exception: - result_error = ( - "Profiling is either not available or not supported by your " - "database." - ) + try: + result, headers = form.profile() + except Exception: + result_error = ( + "Profiling is either not available or not supported by your " + "database." + ) context = { "result": result, "result_error": result_error, "sql": form.reformat_sql(), - "duration": form.cleaned_data["duration"], + "duration": query["duration"], "headers": headers, - "alias": form.cleaned_data["alias"], + "alias": query["alias"], } content = render_to_string("debug_toolbar/panels/sql_profile.html", context) return JsonResponse({"content": content}) diff --git a/docs/changes.rst b/docs/changes.rst index 6ac00f71b..a95c7a6ee 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -17,6 +17,12 @@ Serializable (don't include in main) * Created a ``StoredDebugToolbar`` that support creating an instance of the toolbar representing an old request. It should only be used for fetching panels' contents. +* Drop ``raw_params`` from query data. +* Queries now have a unique ``djdt_query_id``. The SQL forms now reference + this id and avoid passing SQL to be executed. +* Move the formatting logic of SQL queries to just before rendering in + ``SQLPanel.content``. + Pending ------- diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index 932a0dd92..ce24e5503 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -15,6 +15,7 @@ from django.test.utils import override_settings import debug_toolbar.panels.sql.tracking as sql_tracking +from debug_toolbar.panels.sql import SQLPanel try: import psycopg @@ -311,7 +312,7 @@ def test_binary_param_force_text(self): self.assertIn( "SELECT * FROM" " tests_binary WHERE field =", - self.panel._queries[0]["sql"], + self.panel.content, ) @unittest.skipUnless(connection.vendor != "sqlite", "Test invalid for SQLite") @@ -383,8 +384,6 @@ def test_insert_content(self): """ list(User.objects.filter(username="café")) response = self.panel.process_request(self.request) - # ensure the panel does not have content yet. - self.assertNotIn("café", self.panel.content) self.panel.generate_stats(self.request, response) # ensure the panel renders correctly. content = self.panel.content @@ -513,20 +512,29 @@ def test_prettify_sql(self): list(User.objects.filter(username__istartswith="spam")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) + # The content formats the sql and prettifies it + self.assertTrue(self.panel.content) pretty_sql = self.panel._queries[-1]["sql"] self.assertEqual(len(self.panel._queries), 1) - # Reset the queries - self.panel._queries = [] + # Recreate the panel to reset the queries. Content being a cached_property + # which doesn't have a way to reset it. + self.panel.disable_instrumentation() + self.panel = SQLPanel(self.panel.toolbar, self.panel.get_response) + self.panel.enable_instrumentation() # Run it again, but with prettify off. Verify that it's different. with override_settings(DEBUG_TOOLBAR_CONFIG={"PRETTIFY_SQL": False}): list(User.objects.filter(username__istartswith="spam")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) + # The content formats the sql and prettifies it + self.assertTrue(self.panel.content) self.assertEqual(len(self.panel._queries), 1) - self.assertNotEqual(pretty_sql, self.panel._queries[-1]["sql"]) + self.assertNotIn(pretty_sql, self.panel.content) - self.panel._queries = [] + self.panel.disable_instrumentation() + self.panel = SQLPanel(self.panel.toolbar, self.panel.get_response) + self.panel.enable_instrumentation() # Run it again, but with prettify back on. # This is so we don't have to check what PRETTIFY_SQL does exactly, # but we know it's doing something. @@ -534,8 +542,10 @@ def test_prettify_sql(self): list(User.objects.filter(username__istartswith="spam")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) + # The content formats the sql and prettifies it + self.assertTrue(self.panel.content) self.assertEqual(len(self.panel._queries), 1) - self.assertEqual(pretty_sql, self.panel._queries[-1]["sql"]) + self.assertIn(pretty_sql, self.panel.content) def test_simplification(self): """ @@ -547,6 +557,8 @@ def test_simplification(self): list(User.objects.values_list("id")) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) + # The content formats the sql which injects the ellipsis character + self.assertTrue(self.panel.content) self.assertEqual(len(self.panel._queries), 3) self.assertNotIn("\u2022", self.panel._queries[0]["sql"]) self.assertNotIn("\u2022", self.panel._queries[1]["sql"]) @@ -572,6 +584,8 @@ def test_top_level_simplification(self): ) response = self.panel.process_request(self.request) self.panel.generate_stats(self.request, response) + # The content formats the sql which injects the ellipsis character + self.assertTrue(self.panel.content) if connection.vendor != "mysql": self.assertEqual(len(self.panel._queries), 4) else: diff --git a/tests/test_integration.py b/tests/test_integration.py index b0e478483..45a0eee2b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -328,15 +328,19 @@ def test_template_source_errors(self): self.assertContains(response, "Template Does Not Exist: does_not_exist.html") def test_sql_select_checks_show_toolbar(self): + self.client.get("/execute_sql/") + request_ids = list(get_store().request_ids()) + request_id = request_ids[-1] + toolbar = DebugToolbar.fetch(request_id, "SQLPanel") + panel = toolbar.get_panel_by_id("SQLPanel") + djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] + url = "/__debug__/sql_select/" data = { "signed": SignedDataForm.sign( { - "sql": "SELECT * FROM auth_user", - "raw_sql": "SELECT * FROM auth_user", - "params": "{}", - "alias": "default", - "duration": "0", + "request_id": request_id, + "djdt_query_id": djdt_query_id, } ) } @@ -354,15 +358,19 @@ def test_sql_select_checks_show_toolbar(self): self.assertEqual(response.status_code, 404) def test_sql_explain_checks_show_toolbar(self): + self.client.get("/execute_sql/") + request_ids = list(get_store().request_ids()) + request_id = request_ids[-1] + toolbar = DebugToolbar.fetch(request_id, "SQLPanel") + panel = toolbar.get_panel_by_id("SQLPanel") + djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] + url = "/__debug__/sql_explain/" data = { "signed": SignedDataForm.sign( { - "sql": "SELECT * FROM auth_user", - "raw_sql": "SELECT * FROM auth_user", - "params": "{}", - "alias": "default", - "duration": "0", + "request_id": request_id, + "djdt_query_id": djdt_query_id, } ) } @@ -383,19 +391,19 @@ def test_sql_explain_checks_show_toolbar(self): connection.vendor == "postgresql", "Test valid only on PostgreSQL" ) def test_sql_explain_postgres_json_field(self): + self.client.get("/execute_json_sql/") + request_ids = list(get_store().request_ids()) + request_id = request_ids[-1] + toolbar = DebugToolbar.fetch(request_id, "SQLPanel") + panel = toolbar.get_panel_by_id("SQLPanel") + djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] + url = "/__debug__/sql_explain/" - base_query = ( - 'SELECT * FROM "tests_postgresjson" WHERE "tests_postgresjson"."field" @>' - ) - query = base_query + """ '{"foo": "bar"}'""" data = { "signed": SignedDataForm.sign( { - "sql": query, - "raw_sql": base_query + " %s", - "params": '["{\\"foo\\": \\"bar\\"}"]', - "alias": "default", - "duration": "0", + "request_id": request_id, + "djdt_query_id": djdt_query_id, } ) } @@ -412,15 +420,19 @@ def test_sql_explain_postgres_json_field(self): self.assertEqual(response.status_code, 404) def test_sql_profile_checks_show_toolbar(self): + self.client.get("/execute_sql/") + request_ids = list(get_store().request_ids()) + request_id = request_ids[-1] + toolbar = DebugToolbar.fetch(request_id, "SQLPanel") + panel = toolbar.get_panel_by_id("SQLPanel") + djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] + url = "/__debug__/sql_profile/" data = { "signed": SignedDataForm.sign( { - "sql": "SELECT * FROM auth_user", - "raw_sql": "SELECT * FROM auth_user", - "params": "{}", - "alias": "default", - "duration": "0", + "request_id": request_id, + "djdt_query_id": djdt_query_id, } ) } diff --git a/tests/urls.py b/tests/urls.py index 6fc8811b7..4291f71c3 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -17,6 +17,7 @@ path("non_ascii_request/", views.regular_view, {"title": NonAsciiRepr()}), path("new_user/", views.new_user), path("execute_sql/", views.execute_sql), + path("execute_json_sql/", views.execute_json_sql), 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 b2fd21c54..4ffaf9ec5 100644 --- a/tests/views.py +++ b/tests/views.py @@ -5,12 +5,19 @@ from django.template.response import TemplateResponse from django.views.decorators.cache import cache_page +from tests.models import PostgresJSON + def execute_sql(request): list(User.objects.all()) return render(request, "base.html") +def execute_json_sql(request): + list(PostgresJSON.objects.filter(field__contains={"foo": "bar"})) + return render(request, "base.html") + + def regular_view(request, title): return render(request, "basic.html", {"title": title}) From 14a5e0cf1b01cf52eb08e6b1fa2946a391bf0146 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Sun, 20 Aug 2023 21:11:27 -0500 Subject: [PATCH 09/25] Make Panel.panel_id a classmember. This avoids needing to create an instance of a panel to get its panel ID. --- debug_toolbar/panels/__init__.py | 7 +- debug_toolbar/panels/history/__init__.py | 2 +- debug_toolbar/panels/sql/__init__.py | 2 +- debug_toolbar/panels/sql/forms.py | 6 +- debug_toolbar/panels/templates/__init__.py | 2 +- docs/changes.rst | 2 +- tests/panels/test_cache.py | 4 +- tests/panels/test_history.py | 7 +- tests/panels/test_profiling.py | 4 +- tests/panels/test_redirects.py | 4 +- tests/panels/test_request.py | 4 +- tests/panels/test_sql.py | 4 +- tests/panels/test_staticfiles.py | 4 +- tests/panels/test_template.py | 7 +- tests/panels/test_versions.py | 4 +- tests/test_integration.py | 100 +++++++++++++-------- 16 files changed, 102 insertions(+), 61 deletions(-) diff --git a/debug_toolbar/panels/__init__.py b/debug_toolbar/panels/__init__.py index 71d33ae55..a3869387f 100644 --- a/debug_toolbar/panels/__init__.py +++ b/debug_toolbar/panels/__init__.py @@ -1,4 +1,5 @@ from django.template.loader import render_to_string +from django.utils.functional import classproperty from debug_toolbar import settings as dt_settings from debug_toolbar.utils import get_name_from_obj @@ -16,9 +17,9 @@ def __init__(self, toolbar, get_response): # Private panel properties - @property - def panel_id(self): - return self.__class__.__name__ + @classproperty + def panel_id(cls): + return cls.__name__ @property def enabled(self) -> bool: diff --git a/debug_toolbar/panels/history/__init__.py b/debug_toolbar/panels/history/__init__.py index 52ceb7984..193ced242 100644 --- a/debug_toolbar/panels/history/__init__.py +++ b/debug_toolbar/panels/history/__init__.py @@ -1,3 +1,3 @@ from debug_toolbar.panels.history.panel import HistoryPanel -__all__ = ["HistoryPanel"] +__all__ = [HistoryPanel.panel_id] diff --git a/debug_toolbar/panels/sql/__init__.py b/debug_toolbar/panels/sql/__init__.py index 46c68a3c6..9da548f7f 100644 --- a/debug_toolbar/panels/sql/__init__.py +++ b/debug_toolbar/panels/sql/__init__.py @@ -1,3 +1,3 @@ from debug_toolbar.panels.sql.panel import SQLPanel -__all__ = ["SQLPanel"] +__all__ = [SQLPanel.panel_id] diff --git a/debug_toolbar/panels/sql/forms.py b/debug_toolbar/panels/sql/forms.py index d4fff35ee..4caa29836 100644 --- a/debug_toolbar/panels/sql/forms.py +++ b/debug_toolbar/panels/sql/forms.py @@ -46,14 +46,16 @@ def clean_alias(self): return value def clean(self): + from debug_toolbar.panels.sql import SQLPanel + cleaned_data = super().clean() toolbar = DebugToolbar.fetch( - self.cleaned_data["request_id"], panel_id="SQLPanel" + self.cleaned_data["request_id"], panel_id=SQLPanel.panel_id ) if toolbar is None: raise ValidationError(_("Data for this panel isn't available anymore.")) - panel = toolbar.get_panel_by_id("SQLPanel") + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) # Find the query for this form submission query = None for q in panel.get_stats()["queries"]: diff --git a/debug_toolbar/panels/templates/__init__.py b/debug_toolbar/panels/templates/__init__.py index a1d509b9e..5cd78bbb3 100644 --- a/debug_toolbar/panels/templates/__init__.py +++ b/debug_toolbar/panels/templates/__init__.py @@ -1,3 +1,3 @@ from debug_toolbar.panels.templates.panel import TemplatesPanel -__all__ = ["TemplatesPanel"] +__all__ = [TemplatesPanel.panel_id] diff --git a/docs/changes.rst b/docs/changes.rst index a95c7a6ee..039851b97 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -22,7 +22,7 @@ Serializable (don't include in main) this id and avoid passing SQL to be executed. * Move the formatting logic of SQL queries to just before rendering in ``SQLPanel.content``. - +* Make ``Panel.panel_id`` a class member. Pending ------- diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py index aacf521cb..a016f81f0 100644 --- a/tests/panels/test_cache.py +++ b/tests/panels/test_cache.py @@ -1,10 +1,12 @@ from django.core import cache +from debug_toolbar.panels.cache import CachePanel + from ..base import BaseTestCase class CachePanelTestCase(BaseTestCase): - panel_id = "CachePanel" + panel_id = CachePanel.panel_id def test_recording(self): self.assertEqual(len(self.panel.calls), 0) diff --git a/tests/panels/test_history.py b/tests/panels/test_history.py index 19998f41e..f70dc65b9 100644 --- a/tests/panels/test_history.py +++ b/tests/panels/test_history.py @@ -4,6 +4,7 @@ from django.test import RequestFactory, override_settings from django.urls import resolve, reverse +from debug_toolbar.panels.history import HistoryPanel from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar @@ -14,7 +15,7 @@ class HistoryPanelTestCase(BaseTestCase): - panel_id = "HistoryPanel" + panel_id = HistoryPanel.panel_id def test_disabled(self): config = {"DISABLE_PANELS": {"debug_toolbar.panels.history.HistoryPanel"}} @@ -93,7 +94,7 @@ def test_history_panel_integration_content(self): request_ids = list(store.request_ids()) self.assertEqual(len(request_ids), 1) toolbar = DebugToolbar.fetch(request_ids[0]) - content = toolbar.get_panel_by_id("HistoryPanel").content + content = toolbar.get_panel_by_id(HistoryPanel.panel_id).content self.assertIn("bar", content) self.assertIn('name="exclude_history" value="True"', content) @@ -131,7 +132,7 @@ def test_history_sidebar_includes_history(self): """Validate the history sidebar view.""" self.client.get("/json_view/") panel_keys = copy.copy(self.PANEL_KEYS) - panel_keys.add("HistoryPanel") + panel_keys.add(HistoryPanel.panel_id) request_id = list(get_store().request_ids())[0] data = {"request_id": request_id} response = self.client.get(reverse("djdt:history_sidebar"), data=data) diff --git a/tests/panels/test_profiling.py b/tests/panels/test_profiling.py index ff613dfe1..2d4cdf110 100644 --- a/tests/panels/test_profiling.py +++ b/tests/panels/test_profiling.py @@ -3,6 +3,8 @@ from django.http import HttpResponse from django.test.utils import override_settings +from debug_toolbar.panels.profiling import ProfilingPanel + from ..base import BaseTestCase, IntegrationTestCase from ..views import listcomp_view, regular_view @@ -11,7 +13,7 @@ DEBUG_TOOLBAR_PANELS=["debug_toolbar.panels.profiling.ProfilingPanel"] ) class ProfilingPanelTestCase(BaseTestCase): - panel_id = "ProfilingPanel" + panel_id = ProfilingPanel.panel_id def test_regular_view(self): self._get_response = lambda request: regular_view(request, "profiling") diff --git a/tests/panels/test_redirects.py b/tests/panels/test_redirects.py index 6b67e6f1d..fb1fb8516 100644 --- a/tests/panels/test_redirects.py +++ b/tests/panels/test_redirects.py @@ -3,11 +3,13 @@ from django.conf import settings from django.http import HttpResponse +from debug_toolbar.panels.redirects import RedirectsPanel + from ..base import BaseTestCase class RedirectsPanelTestCase(BaseTestCase): - panel_id = "RedirectsPanel" + panel_id = RedirectsPanel.panel_id def test_regular_response(self): not_redirect = HttpResponse() diff --git a/tests/panels/test_request.py b/tests/panels/test_request.py index ea7f1681a..6b08404e9 100644 --- a/tests/panels/test_request.py +++ b/tests/panels/test_request.py @@ -1,10 +1,12 @@ from django.http import QueryDict +from debug_toolbar.panels.request import RequestPanel + from ..base import BaseTestCase class RequestPanelTestCase(BaseTestCase): - panel_id = "RequestPanel" + panel_id = RequestPanel.panel_id def test_non_ascii_session(self): self.request.session = {"où": "où"} diff --git a/tests/panels/test_sql.py b/tests/panels/test_sql.py index ce24e5503..3b1e7d388 100644 --- a/tests/panels/test_sql.py +++ b/tests/panels/test_sql.py @@ -34,7 +34,7 @@ def sql_call(*, use_iterator=False): class SQLPanelTestCase(BaseTestCase): - panel_id = "SQLPanel" + panel_id = SQLPanel.panel_id def test_disabled(self): config = {"DISABLE_PANELS": {"debug_toolbar.panels.sql.SQLPanel"}} @@ -699,7 +699,7 @@ def test_similar_and_duplicate_grouping(self): class SQLPanelMultiDBTestCase(BaseMultiDBTestCase): - panel_id = "SQLPanel" + panel_id = SQLPanel.panel_id def test_aliases(self): self.assertFalse(self.panel._queries) diff --git a/tests/panels/test_staticfiles.py b/tests/panels/test_staticfiles.py index 32ed7ea61..4b3817f37 100644 --- a/tests/panels/test_staticfiles.py +++ b/tests/panels/test_staticfiles.py @@ -6,13 +6,15 @@ from django.contrib.staticfiles import finders from django.test.utils import override_settings +from debug_toolbar.panels.staticfiles import StaticFilesPanel + from ..base import BaseTestCase PATH_DOES_NOT_EXIST = os.path.join(settings.BASE_DIR, "tests", "invalid_static") class StaticFilesPanelTestCase(BaseTestCase): - panel_id = "StaticFilesPanel" + panel_id = StaticFilesPanel.panel_id def test_default_case(self): response = self.panel.process_request(self.request) diff --git a/tests/panels/test_template.py b/tests/panels/test_template.py index 37e70cfa5..314ee2fd4 100644 --- a/tests/panels/test_template.py +++ b/tests/panels/test_template.py @@ -3,17 +3,20 @@ from django.template import Context, RequestContext, Template from django.test import override_settings +from debug_toolbar.panels.sql import SQLPanel +from debug_toolbar.panels.templates import TemplatesPanel + from ..base import BaseTestCase, IntegrationTestCase from ..forms import TemplateReprForm from ..models import NonAsciiRepr class TemplatesPanelTestCase(BaseTestCase): - panel_id = "TemplatesPanel" + panel_id = TemplatesPanel.panel_id def setUp(self): super().setUp() - self.sql_panel = self.toolbar.get_panel_by_id("SQLPanel") + self.sql_panel = self.toolbar.get_panel_by_id(SQLPanel.panel_id) self.sql_panel.enable_instrumentation() def tearDown(self): diff --git a/tests/panels/test_versions.py b/tests/panels/test_versions.py index 27ccba92b..b484c043a 100644 --- a/tests/panels/test_versions.py +++ b/tests/panels/test_versions.py @@ -1,5 +1,7 @@ from collections import namedtuple +from debug_toolbar.panels.versions import VersionsPanel + from ..base import BaseTestCase version_info_t = namedtuple( @@ -8,7 +10,7 @@ class VersionsPanelTestCase(BaseTestCase): - panel_id = "VersionsPanel" + panel_id = VersionsPanel.panel_id def test_app_version_from_get_version_fn(self): class FakeApp: diff --git a/tests/test_integration.py b/tests/test_integration.py index 45a0eee2b..909126315 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -15,6 +15,12 @@ from debug_toolbar.forms import SignedDataForm from debug_toolbar.middleware import DebugToolbarMiddleware, show_toolbar from debug_toolbar.panels import Panel +from debug_toolbar.panels.cache import CachePanel +from debug_toolbar.panels.history import HistoryPanel +from debug_toolbar.panels.request import RequestPanel +from debug_toolbar.panels.sql import SQLPanel +from debug_toolbar.panels.templates import TemplatesPanel +from debug_toolbar.panels.versions import VersionsPanel from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar @@ -98,7 +104,7 @@ def test_should_render_panels_multiprocess(self): def _resolve_stats(self, path): # takes stats from Request panel self.request.path = path - panel = self.toolbar.get_panel_by_id("RequestPanel") + panel = self.toolbar.get_panel_by_id(RequestPanel.panel_id) response = panel.process_request(self.request) panel.generate_stats(self.request, response) return panel.get_stats() @@ -149,9 +155,13 @@ def test_cache_page(self): # may run earlier and cause fewer cache calls. cache.clear() response = self.client.get("/cached_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 3) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 3 + ) response = self.client.get("/cached_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 2) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 2 + ) @override_settings(ROOT_URLCONF="tests.urls_use_package_urls") def test_include_package_urls(self): @@ -160,16 +170,24 @@ def test_include_package_urls(self): # may run earlier and cause fewer cache calls. cache.clear() response = self.client.get("/cached_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 3) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 3 + ) response = self.client.get("/cached_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 2) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 2 + ) def test_low_level_cache_view(self): """Test cases when low level caching API is used within a request.""" response = self.client.get("/cached_low_level_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 2) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 2 + ) response = self.client.get("/cached_low_level_view/") - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 1) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 1 + ) def test_cache_disable_instrumentation(self): """ @@ -181,7 +199,9 @@ def test_cache_disable_instrumentation(self): response = self.client.get("/execute_sql/") self.assertEqual(cache.get("UseCacheAfterToolbar.before"), 1) self.assertEqual(cache.get("UseCacheAfterToolbar.after"), 1) - self.assertEqual(len(response.toolbar.get_panel_by_id("CachePanel").calls), 0) + self.assertEqual( + len(response.toolbar.get_panel_by_id(CachePanel.panel_id).calls), 0 + ) def test_is_toolbar_request(self): self.request.path = "/__debug__/render_panel/" @@ -254,8 +274,10 @@ def test_html5_validation(self): def test_render_panel_checks_show_toolbar(self): url = "/__debug__/render_panel/" request_id = toolbar_request_id() - get_store().save_panel(request_id, "VersionsPanel", {"value": "Test data"}) - data = {"request_id": request_id, "panel_id": "VersionsPanel"} + get_store().save_panel( + request_id, VersionsPanel.panel_id, {"value": "Test data"} + ) + data = {"request_id": request_id, "panel_id": VersionsPanel.panel_id} response = self.client.get(url, data) self.assertEqual(response.status_code, 200) @@ -283,7 +305,7 @@ def test_middleware_render_toolbar_json(self): self.assertEqual(len(request_ids), 1) toolbar = DebugToolbar.fetch(request_ids[0]) self.assertEqual( - toolbar.get_panel_by_id("HistoryPanel").get_stats()["data"], + toolbar.get_panel_by_id(HistoryPanel.panel_id).get_stats()["data"], {"foo": "bar"}, ) @@ -331,8 +353,8 @@ def test_sql_select_checks_show_toolbar(self): self.client.get("/execute_sql/") request_ids = list(get_store().request_ids()) request_id = request_ids[-1] - toolbar = DebugToolbar.fetch(request_id, "SQLPanel") - panel = toolbar.get_panel_by_id("SQLPanel") + toolbar = DebugToolbar.fetch(request_id, SQLPanel.panel_id) + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] url = "/__debug__/sql_select/" @@ -361,8 +383,8 @@ def test_sql_explain_checks_show_toolbar(self): self.client.get("/execute_sql/") request_ids = list(get_store().request_ids()) request_id = request_ids[-1] - toolbar = DebugToolbar.fetch(request_id, "SQLPanel") - panel = toolbar.get_panel_by_id("SQLPanel") + toolbar = DebugToolbar.fetch(request_id, SQLPanel.panel_id) + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] url = "/__debug__/sql_explain/" @@ -394,8 +416,8 @@ def test_sql_explain_postgres_json_field(self): self.client.get("/execute_json_sql/") request_ids = list(get_store().request_ids()) request_id = request_ids[-1] - toolbar = DebugToolbar.fetch(request_id, "SQLPanel") - panel = toolbar.get_panel_by_id("SQLPanel") + toolbar = DebugToolbar.fetch(request_id, SQLPanel.panel_id) + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] url = "/__debug__/sql_explain/" @@ -423,8 +445,8 @@ def test_sql_profile_checks_show_toolbar(self): self.client.get("/execute_sql/") request_ids = list(get_store().request_ids()) request_id = request_ids[-1] - toolbar = DebugToolbar.fetch(request_id, "SQLPanel") - panel = toolbar.get_panel_by_id("SQLPanel") + toolbar = DebugToolbar.fetch(request_id, SQLPanel.panel_id) + panel = toolbar.get_panel_by_id(SQLPanel.panel_id) djdt_query_id = panel.get_stats()["queries"][-1]["djdt_query_id"] url = "/__debug__/sql_profile/" @@ -532,7 +554,7 @@ def test_auth_login_view_without_redirect(self): request_id = el.attrib["data-request-id"] response = self.client.get( "/__debug__/render_panel/", - {"request_id": request_id, "panel_id": "TemplatesPanel"}, + {"request_id": request_id, "panel_id": TemplatesPanel.panel_id}, ) self.assertEqual(response.status_code, 200) # The key None (without quotes) exists in the list of template @@ -568,14 +590,14 @@ def wait(self): def test_basic(self): self.get("/regular/basic/") - version_panel = self.selenium.find_element(By.ID, "VersionsPanel") + version_panel = self.selenium.find_element(By.ID, VersionsPanel.panel_id) # Versions panel isn't loaded with self.assertRaises(NoSuchElementException): version_panel.find_element(By.TAG_NAME, "table") # Click to show the versions panel - self.selenium.find_element(By.CLASS_NAME, "VersionsPanel").click() + self.selenium.find_element(By.CLASS_NAME, VersionsPanel.panel_id).click() # Version panel loads table = self.wait.until( @@ -591,10 +613,10 @@ def test_basic(self): ) def test_basic_jinja(self): self.get("/regular_jinja/basic") - template_panel = self.selenium.find_element(By.ID, "TemplatesPanel") + template_panel = self.selenium.find_element(By.ID, TemplatesPanel.panel_id) # Click to show the template panel - self.selenium.find_element(By.CLASS_NAME, "TemplatesPanel").click() + self.selenium.find_element(By.CLASS_NAME, TemplatesPanel.panel_id).click() self.assertIn("Templates (2 rendered)", template_panel.text) self.assertIn("base.html", template_panel.text) @@ -609,14 +631,14 @@ def test_rerender_on_history_switch(self): self.get("/regular_jinja/basic") # Make a new request so the history panel has more than one option. self.get("/execute_sql/") - template_panel = self.selenium.find_element(By.ID, "HistoryPanel") + template_panel = self.selenium.find_element(By.ID, HistoryPanel.panel_id) # Record the current side panel of buttons for later comparison. previous_button_panel = self.selenium.find_element( By.ID, "djDebugPanelList" ).text # Click to show the history panel - self.selenium.find_element(By.CLASS_NAME, "HistoryPanel").click() + self.selenium.find_element(By.CLASS_NAME, HistoryPanel.panel_id).click() # Click to switch back to the jinja page view snapshot list(template_panel.find_elements(By.CSS_SELECTOR, "button"))[-1].click() @@ -631,10 +653,10 @@ def test_rerender_on_history_switch(self): @override_settings(DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 0}) def test_expired_store(self): self.get("/regular/basic/") - version_panel = self.selenium.find_element(By.ID, "VersionsPanel") + version_panel = self.selenium.find_element(By.ID, VersionsPanel.panel_id) # Click to show the version panel - self.selenium.find_element(By.CLASS_NAME, "VersionsPanel").click() + self.selenium.find_element(By.CLASS_NAME, VersionsPanel.panel_id).click() # Version panel doesn't loads error = self.wait.until( @@ -662,10 +684,10 @@ def test_expired_store(self): ) def test_django_cached_template_loader(self): self.get("/regular/basic/") - version_panel = self.selenium.find_element(By.ID, "TemplatesPanel") + version_panel = self.selenium.find_element(By.ID, TemplatesPanel.panel_id) # Click to show the templates panel - self.selenium.find_element(By.CLASS_NAME, "TemplatesPanel").click() + self.selenium.find_element(By.CLASS_NAME, TemplatesPanel.panel_id).click() # Templates panel loads trigger = self.wait.until( @@ -682,11 +704,11 @@ def test_django_cached_template_loader(self): def test_sql_action_and_go_back(self): self.get("/execute_sql/") - sql_panel = self.selenium.find_element(By.ID, "SQLPanel") + sql_panel = self.selenium.find_element(By.ID, SQLPanel.panel_id) debug_window = self.selenium.find_element(By.ID, "djDebugWindow") # Click to show the SQL panel - self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() + self.selenium.find_element(By.CLASS_NAME, SQLPanel.panel_id).click() # SQL panel loads button = self.wait.until( @@ -709,7 +731,7 @@ def test_sql_action_and_go_back(self): def test_displays_server_error(self): self.get("/regular/basic/") debug_window = self.selenium.find_element(By.ID, "djDebugWindow") - self.selenium.find_element(By.CLASS_NAME, "BuggyPanel").click() + self.selenium.find_element(By.CLASS_NAME, BuggyPanel.panel_id).click() self.wait.until(EC.visibility_of(debug_window)) self.assertEqual(debug_window.text, "»\n500: Internal Server Error") @@ -719,10 +741,10 @@ def test_toolbar_language_will_render_to_default_language_when_not_set(self): assert hide_button.text == "Hide »" self.get("/execute_sql/") - sql_panel = self.selenium.find_element(By.ID, "SQLPanel") + sql_panel = self.selenium.find_element(By.ID, SQLPanel.panel_id) # Click to show the SQL panel - self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() + self.selenium.find_element(By.CLASS_NAME, SQLPanel.panel_id).click() table = self.wait.until( lambda selenium: sql_panel.find_element(By.TAG_NAME, "table") @@ -737,10 +759,10 @@ def test_toolbar_language_will_render_to_locale_when_set(self): assert hide_button.text == "Esconder »" self.get("/execute_sql/") - sql_panel = self.selenium.find_element(By.ID, "SQLPanel") + sql_panel = self.selenium.find_element(By.ID, SQLPanel.panel_id) # Click to show the SQL panel - self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() + self.selenium.find_element(By.CLASS_NAME, SQLPanel.panel_id).click() table = self.wait.until( lambda selenium: sql_panel.find_element(By.TAG_NAME, "table") @@ -756,10 +778,10 @@ def test_toolbar_language_will_render_to_locale_when_set_both(self): assert hide_button.text == "Hide »" self.get("/execute_sql/") - sql_panel = self.selenium.find_element(By.ID, "SQLPanel") + sql_panel = self.selenium.find_element(By.ID, SQLPanel.panel_id) # Click to show the SQL panel - self.selenium.find_element(By.CLASS_NAME, "SQLPanel").click() + self.selenium.find_element(By.CLASS_NAME, SQLPanel.panel_id).click() table = self.wait.until( lambda selenium: sql_panel.find_element(By.TAG_NAME, "table") From a31115f18749b0b442bfc79b8c0b1b9a3a21269b Mon Sep 17 00:00:00 2001 From: tschilling Date: Mon, 28 Aug 2023 19:08:45 -0500 Subject: [PATCH 10/25] Force everything to a string if it can't be serialized. The alternative here is to inspect and iterate over every collection and object passed around. This avoids having to reinvent the wheel in that scenario. --- debug_toolbar/settings.py | 1 - debug_toolbar/store.py | 21 ++++++++++++--------- docs/changes.rst | 2 -- docs/configuration.rst | 9 --------- tests/test_store.py | 17 ----------------- 5 files changed, 12 insertions(+), 38 deletions(-) diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py index b2a07dcd9..fcd253c59 100644 --- a/debug_toolbar/settings.py +++ b/debug_toolbar/settings.py @@ -37,7 +37,6 @@ "PROFILER_CAPTURE_PROJECT_CODE": True, "PROFILER_MAX_DEPTH": 10, "PROFILER_THRESHOLD_RATIO": 8, - "SUPPRESS_SERIALIZATION_ERRORS": True, "SHOW_TEMPLATE_CONTEXT": True, "SKIP_TEMPLATE_PREFIXES": ("django/forms/widgets/", "admin/widgets/"), "SQL_WARNING_THRESHOLD": 500, # milliseconds diff --git a/debug_toolbar/store.py b/debug_toolbar/store.py index 5f8f5f893..68081177a 100644 --- a/debug_toolbar/store.py +++ b/debug_toolbar/store.py @@ -5,6 +5,7 @@ from typing import Any, Dict, Iterable from django.core.serializers.json import DjangoJSONEncoder +from django.utils.encoding import force_str from django.utils.module_loading import import_string from debug_toolbar import settings as dt_settings @@ -12,11 +13,20 @@ logger = logging.getLogger(__name__) +class DebugToolbarJSONEncoder(DjangoJSONEncoder): + def default(self, o): + try: + return super().default(o) + except (TypeError, ValueError): + logger.debug("The debug toolbar can't serialize %s into JSON" % o) + return force_str(o) + + def serialize(data: Any) -> str: # If this starts throwing an exceptions, consider # Subclassing DjangoJSONEncoder and using force_str to # make it JSON serializable. - return json.dumps(data, cls=DjangoJSONEncoder) + return json.dumps(data, cls=DebugToolbarJSONEncoder) def deserialize(data: str) -> Any: @@ -106,14 +116,7 @@ def delete(cls, request_id: str): def save_panel(cls, request_id: str, panel_id: str, data: Any = None): """Save the panel data for the given request_id""" cls.set(request_id) - try: - cls._request_store[request_id][panel_id] = serialize(data) - except TypeError: - if dt_settings.get_config()["SUPPRESS_SERIALIZATION_ERRORS"]: - log = "Panel (%s) failed to serialized data %s properly." - logger.warning(log % (panel_id, data)) - else: - raise + cls._request_store[request_id][panel_id] = serialize(data) @classmethod def panel(cls, request_id: str, panel_id: str) -> Any: diff --git a/docs/changes.rst b/docs/changes.rst index 039851b97..9a8ed0176 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,8 +7,6 @@ Serializable (don't include in main) * Defines the ``BaseStore`` interface for request storage mechanisms. * Added the setting ``TOOLBAR_STORE_CLASS`` to configure the request storage mechanism. Defaults to ``debug_toolbar.store.MemoryStore``. -* Added setting ``SUPPRESS_SERIALIZATION_ERRORS`` to suppress - warnings when a ``TypeError`` occurs during a panel's serialization. * Rename ``store_id`` properties to ``request_id`` and ``Toolbar.store`` to ``Toolbar.init_store``. * Support ``Panel`` instances with stored stats via diff --git a/docs/configuration.rst b/docs/configuration.rst index d9d03a853..f2f6b7de9 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -306,15 +306,6 @@ Panel options the nested functions. The threshold is calculated by the root calls' cumulative time divided by this ratio. -* ``SUPPRESS_SERIALIZATION_ERRORS`` - - Default: ``True`` - - If set to ``True`` then panels will log a warning if a ``TypeError`` is - raised when attempting to serialize a panel's stats rather than raising an - exception.. If set to ``False`` then the ``TypeError`` will be raised. The - default will eventually be set to ``False`` and removed entirely. - * ``SHOW_TEMPLATE_CONTEXT`` Default: ``True`` diff --git a/tests/test_store.py b/tests/test_store.py index 1c17aaf96..c51afde1e 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,5 +1,3 @@ -import logging - from django.test import TestCase from django.test.utils import override_settings @@ -95,21 +93,6 @@ def test_save_panel(self): self.assertEqual(list(self.store.request_ids()), ["bar"]) self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1}) - def test_save_panel_serialization_warning(self): - """The store should warn the user about a serialization error.""" - self.assertLogs() - - with self.assertLogs("debug_toolbar.store", level=logging.WARNING) as logs: - self.store.save_panel("bar", "bar.panel", {"value": {"foo"}}) - - self.assertEqual( - logs.output, - [ - "WARNING:debug_toolbar.store:Panel (bar.panel) failed to " - "serialized data {'value': {'foo'}} properly." - ], - ) - def test_panel(self): self.assertEqual(self.store.panel("missing", "missing"), {}) self.store.save_panel("bar", "bar.panel", {"a": 1}) From 71edcf5dc4797e12986812a2ece302289868a819 Mon Sep 17 00:00:00 2001 From: tschilling Date: Mon, 28 Aug 2023 19:10:27 -0500 Subject: [PATCH 11/25] Support serialization of FunctionCall --- debug_toolbar/panels/profiling.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/debug_toolbar/panels/profiling.py b/debug_toolbar/panels/profiling.py index 9d10229ad..77b5d3120 100644 --- a/debug_toolbar/panels/profiling.py +++ b/debug_toolbar/panels/profiling.py @@ -25,6 +25,7 @@ def __init__( self.id = id self.parent_ids = parent_ids or [] self.hsv = hsv + self.has_subfuncs = False def parent_classes(self): return self.parent_classes @@ -128,6 +129,21 @@ def cumtime_per_call(self): def indent(self): return 16 * self.depth + def serialize(self): + return { + "has_subfuncs": self.has_subfuncs, + "id": self.id, + "parent_ids": self.parent_ids, + "is_project_func": self.is_project_func(), + "indent": self.indent(), + "func_std_string": self.func_std_string(), + "cumtime": self.cumtime(), + "cumtime_per_call": self.cumtime_per_call(), + "tottime": self.tottime(), + "tottime_per_call": self.tottime_per_call(), + "count": self.count(), + } + class ProfilingPanel(Panel): """ @@ -145,7 +161,6 @@ def process_request(self, request): def add_node(self, func_list, func, max_depth, cum_time): func_list.append(func) - func.has_subfuncs = False if func.depth < max_depth: for subfunc in func.subfuncs(): # Always include the user's code @@ -179,4 +194,4 @@ def generate_stats(self, request, response): dt_settings.get_config()["PROFILER_MAX_DEPTH"], cum_time_threshold, ) - self.record_stats({"func_list": func_list}) + self.record_stats({"func_list": [func.serialize() for func in func_list]}) From c03f08f96a8002427b5c37b37143a8238404a491 Mon Sep 17 00:00:00 2001 From: tschilling Date: Mon, 4 Sep 2023 20:23:25 -0500 Subject: [PATCH 12/25] Update all panels to use data from get_stats on render Any instance attributes shouldn't be used because they can't be relied upon for historical purposes. Especially when it comes to the titles and nav titles. --- debug_toolbar/panels/cache.py | 8 +-- debug_toolbar/panels/history/panel.py | 3 ++ debug_toolbar/panels/history/views.py | 20 ++++---- debug_toolbar/panels/settings.py | 5 +- debug_toolbar/panels/sql/panel.py | 7 +-- debug_toolbar/panels/staticfiles.py | 12 ++--- debug_toolbar/panels/templates/panel.py | 17 ++++--- debug_toolbar/panels/timer.py | 50 +++++++++++++------ debug_toolbar/panels/versions.py | 8 ++- debug_toolbar/store.py | 10 ++++ .../debug_toolbar/panels/history.html | 2 +- .../debug_toolbar/panels/history_tr.html | 12 ++--- debug_toolbar/toolbar.py | 9 +--- docs/changes.rst | 2 + tests/panels/test_history.py | 2 + tests/panels/test_staticfiles.py | 6 +-- tests/test_integration.py | 17 ------- 17 files changed, 105 insertions(+), 85 deletions(-) diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py index 4c7bf5af7..8235d37ff 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -169,16 +169,17 @@ def _record_call(self, cache, name, original_method, args, kwargs): @property def nav_subtitle(self): - cache_calls = len(self.calls) + stats = self.get_stats() + cache_calls = len(stats.get("calls")) return ngettext( "%(cache_calls)d call in %(time).2fms", "%(cache_calls)d calls in %(time).2fms", cache_calls, - ) % {"cache_calls": cache_calls, "time": self.total_time} + ) % {"cache_calls": cache_calls, "time": stats.get("total_time")} @property def title(self): - count = len(getattr(settings, "CACHES", ["default"])) + count = self.get_stats().get("total_caches") return ngettext( "Cache calls from %(count)d backend", "Cache calls from %(count)d backends", @@ -214,6 +215,7 @@ def generate_stats(self, request, response): "hits": self.hits, "misses": self.misses, "counts": self.counts, + "total_caches": len(getattr(settings, "CACHES", ["default"])), } ) diff --git a/debug_toolbar/panels/history/panel.py b/debug_toolbar/panels/history/panel.py index 684b5f7bf..81dbc71d9 100644 --- a/debug_toolbar/panels/history/panel.py +++ b/debug_toolbar/panels/history/panel.py @@ -89,6 +89,9 @@ def content(self): toolbar_history = {} for request_id in reversed(self.toolbar.store.request_ids()): toolbar_history[request_id] = { + "history_stats": self.toolbar.store.panel( + request_id, HistoryPanel.panel_id + ), "form": HistoryStoreForm( initial={"request_id": request_id, "exclude_history": True} ), diff --git a/debug_toolbar/panels/history/views.py b/debug_toolbar/panels/history/views.py index 61d96c265..0cb6885f8 100644 --- a/debug_toolbar/panels/history/views.py +++ b/debug_toolbar/panels/history/views.py @@ -55,16 +55,18 @@ def history_refresh(request): "content": render_to_string( "debug_toolbar/panels/history_tr.html", { - "id": request_id, - "store_context": { - "toolbar": toolbar, - "form": HistoryStoreForm( - initial={ - "request_id": request_id, - "exclude_history": True, - } - ), + "request_id": request_id, + "history_context": { + "history_stats": toolbar.store.panel( + request_id, "HistoryPanel" + ) }, + "form": HistoryStoreForm( + initial={ + "request_id": request_id, + "exclude_history": True, + } + ), }, ), } diff --git a/debug_toolbar/panels/settings.py b/debug_toolbar/panels/settings.py index 4b694d5bd..c14c1f28b 100644 --- a/debug_toolbar/panels/settings.py +++ b/debug_toolbar/panels/settings.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from django.views.debug import get_default_exception_reporter_filter @@ -18,7 +17,9 @@ class SettingsPanel(Panel): nav_title = _("Settings") def title(self): - return _("Settings from %s") % settings.SETTINGS_MODULE + return _("Settings from %s") % self.get_stats()["settings"].get( + "SETTINGS_MODULE" + ) def generate_stats(self, request, response): self.record_stats( diff --git a/debug_toolbar/panels/sql/panel.py b/debug_toolbar/panels/sql/panel.py index 873bcee8c..698430325 100644 --- a/debug_toolbar/panels/sql/panel.py +++ b/debug_toolbar/panels/sql/panel.py @@ -159,19 +159,20 @@ def record(self, **kwargs): @property def nav_subtitle(self): - query_count = len(self._queries) + stats = self.get_stats() + query_count = len(stats.get("queries", [])) return ngettext( "%(query_count)d query in %(sql_time).2fms", "%(query_count)d queries in %(sql_time).2fms", query_count, ) % { "query_count": query_count, - "sql_time": self._sql_time, + "sql_time": stats.get("sql_time"), } @property def title(self): - count = len(self._databases) + count = len(self.get_stats().get("databases")) return ngettext( "SQL queries from %(count)d connection", "SQL queries from %(count)d connections", diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py index 5f9efb5c3..85c2c8c81 100644 --- a/debug_toolbar/panels/staticfiles.py +++ b/debug_toolbar/panels/staticfiles.py @@ -79,9 +79,10 @@ class StaticFilesPanel(panels.Panel): @property def title(self): + stats = self.get_stats() return _("Static files (%(num_found)s found, %(num_used)s used)") % { - "num_found": self.num_found, - "num_used": self.num_used, + "num_found": stats.get("num_found"), + "num_used": stats.get("num_used"), } def __init__(self, *args, **kwargs): @@ -95,16 +96,11 @@ def enable_instrumentation(self): def disable_instrumentation(self): storage.staticfiles_storage = _original_storage - @property - def num_used(self): - stats = self.get_stats() - return stats and stats["num_used"] - nav_title = _("Static files") @property def nav_subtitle(self): - num_used = self.num_used + num_used = self.get_stats().get("num_used") return ngettext( "%(num_used)s file used", "%(num_used)s files used", num_used ) % {"num_used": num_used} diff --git a/debug_toolbar/panels/templates/panel.py b/debug_toolbar/panels/templates/panel.py index 75bca5239..684a80b21 100644 --- a/debug_toolbar/panels/templates/panel.py +++ b/debug_toolbar/panels/templates/panel.py @@ -145,15 +145,16 @@ def _store_template_info(self, sender, **kwargs): @property def title(self): - num_templates = len(self.templates) + num_templates = len(self.get_stats()["templates"]) return _("Templates (%(num_templates)s rendered)") % { "num_templates": num_templates } @property def nav_subtitle(self): - if self.templates: - return self.templates[0]["template"].name + templates = self.get_stats()["templates"] + if templates: + return templates[0]["name"] return "" template = "debug_toolbar/panels/templates.html" @@ -171,7 +172,6 @@ def disable_instrumentation(self): def generate_stats(self, request, response): template_context = [] for template_data in self.templates: - info = {} # Clean up some info about templates template = template_data["template"] if hasattr(template, "origin") and template.origin and template.origin.name: @@ -180,12 +180,15 @@ def generate_stats(self, request, response): else: template.origin_name = _("No origin") template.origin_hash = "" - info["template"] = force_str(template) + context = { + "template": force_str(template), + "name": template.name, + } # Clean up context for better readability if self.toolbar.config["SHOW_TEMPLATE_CONTEXT"]: context_list = template_data.get("context", []) - info["context"] = "\n".join(context_list) - template_context.append(info) + context["context"] = "\n".join(context_list) + template_context.append(context) # Fetch context_processors/template_dirs from any template if self.templates: diff --git a/debug_toolbar/panels/timer.py b/debug_toolbar/panels/timer.py index 554798e7d..6ef9f0d7c 100644 --- a/debug_toolbar/panels/timer.py +++ b/debug_toolbar/panels/timer.py @@ -19,11 +19,11 @@ class TimerPanel(Panel): def nav_subtitle(self): stats = self.get_stats() - if hasattr(self, "_start_rusage"): - utime = self._end_rusage.ru_utime - self._start_rusage.ru_utime - stime = self._end_rusage.ru_stime - self._start_rusage.ru_stime + if stats.get("utime"): + utime = stats.get("utime") + stime = stats.get("stime") return _("CPU: %(cum)0.2fms (%(total)0.2fms)") % { - "cum": (utime + stime) * 1000.0, + "cum": (utime + stime), "total": stats["total_time"], } elif "total_time" in stats: @@ -64,27 +64,44 @@ def process_request(self, request): self._start_rusage = resource.getrusage(resource.RUSAGE_SELF) return super().process_request(request) + def serialize_rusage(self, data): + fields_to_serialize = [ + "ru_utime", + "ru_stime", + "ru_nvcsw", + "ru_nivcsw", + "ru_minflt", + "ru_majflt", + ] + return {field: getattr(data, field) for field in fields_to_serialize} + def generate_stats(self, request, response): stats = {} if hasattr(self, "_start_time"): stats["total_time"] = (perf_counter() - self._start_time) * 1000 - if hasattr(self, "_start_rusage"): + if self.has_content: self._end_rusage = resource.getrusage(resource.RUSAGE_SELF) - stats["utime"] = 1000 * self._elapsed_ru("ru_utime") - stats["stime"] = 1000 * self._elapsed_ru("ru_stime") + start = self.serialize_rusage(self._start_rusage) + end = self.serialize_rusage(self._end_rusage) + stats.update( + { + "utime": 1000 * self._elapsed_ru(start, end, "ru_utime"), + "stime": 1000 * self._elapsed_ru(start, end, "ru_stime"), + "vcsw": self._elapsed_ru(start, end, "ru_nvcsw"), + "ivcsw": self._elapsed_ru(start, end, "ru_nivcsw"), + "minflt": self._elapsed_ru(start, end, "ru_minflt"), + "majflt": self._elapsed_ru(start, end, "ru_majflt"), + } + ) stats["total"] = stats["utime"] + stats["stime"] - stats["vcsw"] = self._elapsed_ru("ru_nvcsw") - stats["ivcsw"] = self._elapsed_ru("ru_nivcsw") - stats["minflt"] = self._elapsed_ru("ru_minflt") - stats["majflt"] = self._elapsed_ru("ru_majflt") # these are documented as not meaningful under Linux. If you're # running BSD feel free to enable them, and add any others that I # hadn't gotten to before I noticed that I was getting nothing but # zeroes and that the docs agreed. :-( # - # stats['blkin'] = self._elapsed_ru('ru_inblock') - # stats['blkout'] = self._elapsed_ru('ru_oublock') - # stats['swap'] = self._elapsed_ru('ru_nswap') + # stats['blkin'] = self._elapsed_ru(start, end, 'ru_inblock') + # stats['blkout'] = self._elapsed_ru(start, end, 'ru_oublock') + # stats['swap'] = self._elapsed_ru(start, end, 'ru_nswap') # stats['rss'] = self._end_rusage.ru_maxrss # stats['srss'] = self._end_rusage.ru_ixrss # stats['urss'] = self._end_rusage.ru_idrss @@ -102,5 +119,6 @@ def generate_server_timing(self, request, response): "total_time", "Elapsed time", stats.get("total_time", 0) ) - def _elapsed_ru(self, name): - return getattr(self._end_rusage, name) - getattr(self._start_rusage, name) + @staticmethod + def _elapsed_ru(start, end, name): + return end.get(name) - start.get(name) diff --git a/debug_toolbar/panels/versions.py b/debug_toolbar/panels/versions.py index d517ecfb3..a86dce94e 100644 --- a/debug_toolbar/panels/versions.py +++ b/debug_toolbar/panels/versions.py @@ -14,7 +14,7 @@ class VersionsPanel(Panel): @property def nav_subtitle(self): - return "Django %s" % django.get_version() + return "Django %s" % self.get_stats()["django_version"] title = _("Versions") @@ -27,7 +27,11 @@ def generate_stats(self, request, response): ] versions += list(self.gen_app_versions()) self.record_stats( - {"versions": sorted(versions, key=lambda v: v[0]), "paths": sys.path} + { + "django_version": django.get_version(), + "versions": sorted(versions, key=lambda v: v[0]), + "paths": sys.path, + } ) def gen_app_versions(self): diff --git a/debug_toolbar/store.py b/debug_toolbar/store.py index 68081177a..27147645f 100644 --- a/debug_toolbar/store.py +++ b/debug_toolbar/store.py @@ -128,6 +128,16 @@ def panel(cls, request_id: str, panel_id: str) -> Any: else: return deserialize(data) + @classmethod + def panels(cls, request_id: str) -> Any: + """Fetch all the panel data for the given request_id""" + try: + panel_mapping = cls._request_store[request_id] + except KeyError: + return {} + for panel, data in panel_mapping.items(): + yield panel, deserialize(data) + def get_store() -> BaseStore: return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"]) diff --git a/debug_toolbar/templates/debug_toolbar/panels/history.html b/debug_toolbar/templates/debug_toolbar/panels/history.html index f42e08e0a..92ab43922 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/history.html +++ b/debug_toolbar/templates/debug_toolbar/panels/history.html @@ -15,7 +15,7 @@ - {% for request_id, store_context in toolbar_history.items %} + {% for request_id, history_context in toolbar_history.items %} {% include "debug_toolbar/panels/history_tr.html" %} {% endfor %} diff --git a/debug_toolbar/templates/debug_toolbar/panels/history_tr.html b/debug_toolbar/templates/debug_toolbar/panels/history_tr.html index db1ef1251..2b9abfa89 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/history_tr.html +++ b/debug_toolbar/templates/debug_toolbar/panels/history_tr.html @@ -1,13 +1,13 @@ {% load i18n %} - {% for key, value in store_context.toolbar.stats.HistoryPanel.data.items %} + {% for key, value in history_context.history_stats.data.items %} @@ -39,11 +39,11 @@ diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py index 7b6323fd0..f6b2031b0 100644 --- a/debug_toolbar/toolbar.py +++ b/debug_toolbar/toolbar.py @@ -93,14 +93,7 @@ def should_render_panels(self): If False, the panels will be loaded via Ajax. """ - if (render_panels := self.config["RENDER_PANELS"]) is None: - # If wsgi.multiprocess isn't in the headers, then it's likely - # being served by ASGI. This type of set up is most likely - # incompatible with the toolbar until - # https://github.com/jazzband/django-debug-toolbar/issues/1430 - # is resolved. - render_panels = self.request.META.get("wsgi.multiprocess", True) - return render_panels + return self.config["RENDER_PANELS"] or False # Handle storing toolbars in memory and fetching them later on diff --git a/docs/changes.rst b/docs/changes.rst index 9a8ed0176..16a80abdf 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -21,6 +21,8 @@ Serializable (don't include in main) * Move the formatting logic of SQL queries to just before rendering in ``SQLPanel.content``. * Make ``Panel.panel_id`` a class member. +* Update all panels to utilize data from ``Panel.get_stats()`` to load content + to render. Specifically for ``Panel.title`` and ``Panel.nav_title``. Pending ------- diff --git a/tests/panels/test_history.py b/tests/panels/test_history.py index f70dc65b9..540bef39a 100644 --- a/tests/panels/test_history.py +++ b/tests/panels/test_history.py @@ -104,6 +104,7 @@ def test_history_sidebar_invalid(self): def test_history_headers(self): """Validate the headers injected from the history panel.""" + DebugToolbar.get_observe_request.cache_clear() response = self.client.get("/json_view/") request_id = list(get_store().request_ids())[0] self.assertEqual(response.headers["djdt-request-id"], request_id) @@ -113,6 +114,7 @@ def test_history_headers(self): ) def test_history_headers_unobserved(self): """Validate the headers aren't injected from the history panel.""" + DebugToolbar.get_observe_request.cache_clear() response = self.client.get("/json_view/") self.assertNotIn("djdt-request-id", response.headers) diff --git a/tests/panels/test_staticfiles.py b/tests/panels/test_staticfiles.py index 4b3817f37..bd5e8fa53 100644 --- a/tests/panels/test_staticfiles.py +++ b/tests/panels/test_staticfiles.py @@ -26,7 +26,7 @@ def test_default_case(self): self.assertIn( "django.contrib.staticfiles.finders.FileSystemFinder (2 files)", content ) - self.assertEqual(self.panel.num_used, 0) + self.assertEqual(self.panel.get_stats()["num_used"], 0) self.assertNotEqual(self.panel.num_found, 0) expected_apps = ["django.contrib.admin", "debug_toolbar"] if settings.USE_GIS: @@ -75,8 +75,8 @@ def test_finder_directory_does_not_exist(self): self.assertNotIn( "django.contrib.staticfiles.finders.FileSystemFinder (2 files)", content ) - self.assertEqual(self.panel.num_used, 0) - self.assertNotEqual(self.panel.num_found, 0) + self.assertEqual(self.panel.get_stats()["num_used"], 0) + self.assertNotEqual(self.panel.get_stats()["num_found"], 0) expected_apps = ["django.contrib.admin", "debug_toolbar"] if settings.USE_GIS: expected_apps = ["django.contrib.gis"] + expected_apps diff --git a/tests/test_integration.py b/tests/test_integration.py index 909126315..7b0e78945 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -82,25 +82,8 @@ def test_should_render_panels_RENDER_PANELS(self): toolbar.config["RENDER_PANELS"] = True self.assertTrue(toolbar.should_render_panels()) toolbar.config["RENDER_PANELS"] = None - self.assertTrue(toolbar.should_render_panels()) - - def test_should_render_panels_multiprocess(self): - """ - The toolbar should render the panels on each request when wsgi.multiprocess - is True or missing. - """ - request = rf.get("/") - request.META["wsgi.multiprocess"] = True - toolbar = DebugToolbar(request, self.get_response) - toolbar.config["RENDER_PANELS"] = None - self.assertTrue(toolbar.should_render_panels()) - - request.META["wsgi.multiprocess"] = False self.assertFalse(toolbar.should_render_panels()) - request.META.pop("wsgi.multiprocess") - self.assertTrue(toolbar.should_render_panels()) - def _resolve_stats(self, path): # takes stats from Request panel self.request.path = path From 47bdabe8042a3a7457d108208acc4be05e214f0b Mon Sep 17 00:00:00 2001 From: tschilling Date: Mon, 4 Sep 2023 20:25:48 -0500 Subject: [PATCH 13/25] Extend example app to have an async version. --- Makefile | 7 +++++++ docs/changes.rst | 1 + example/asgi.py | 16 ++++++++++++++++ example/async_/__init__.py | 0 example/async_/settings.py | 5 +++++ example/async_/urls.py | 9 +++++++++ example/async_/views.py | 9 +++++++++ example/settings.py | 1 + example/templates/index.html | 11 +++++++++++ requirements_dev.txt | 4 ++++ 10 files changed, 63 insertions(+) create mode 100644 example/asgi.py create mode 100644 example/async_/__init__.py create mode 100644 example/async_/settings.py create mode 100644 example/async_/urls.py create mode 100644 example/async_/views.py diff --git a/Makefile b/Makefile index 1600496e5..aa7a56c70 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,13 @@ example: --noinput --username="$(USER)" --email="$(USER)@mailinator.com" python example/manage.py runserver +example_async: + python example/manage.py migrate --noinput + -DJANGO_SUPERUSER_PASSWORD=p python example/manage.py createsuperuser \ + --noinput --username="$(USER)" --email="$(USER)@mailinator.com" + daphne example.asgi:application + + test: DJANGO_SETTINGS_MODULE=tests.settings \ python -m django test $${TEST_ARGS:-tests} diff --git a/docs/changes.rst b/docs/changes.rst index 16a80abdf..c29fea233 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -23,6 +23,7 @@ Serializable (don't include in main) * Make ``Panel.panel_id`` a class member. * Update all panels to utilize data from ``Panel.get_stats()`` to load content to render. Specifically for ``Panel.title`` and ``Panel.nav_title``. +* Extend example app to contain an async version. Pending ------- diff --git a/example/asgi.py b/example/asgi.py new file mode 100644 index 000000000..39d4ccb5e --- /dev/null +++ b/example/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for example_async project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.async_.settings") + +application = get_asgi_application() diff --git a/example/async_/__init__.py b/example/async_/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/example/async_/settings.py b/example/async_/settings.py new file mode 100644 index 000000000..f3bef673a --- /dev/null +++ b/example/async_/settings.py @@ -0,0 +1,5 @@ +"""Django settings for example project.""" + +from ..settings import * # noqa: F403 + +ROOT_URLCONF = "example.async_.urls" diff --git a/example/async_/urls.py b/example/async_/urls.py new file mode 100644 index 000000000..ad19cbc83 --- /dev/null +++ b/example/async_/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from example.async_ import views +from example.urls import urlpatterns as sync_urlpatterns + +urlpatterns = [ + path("async/db/", views.async_db_view, name="async_db_view"), + *sync_urlpatterns, +] diff --git a/example/async_/views.py b/example/async_/views.py new file mode 100644 index 000000000..7326e0d0b --- /dev/null +++ b/example/async_/views.py @@ -0,0 +1,9 @@ +from django.contrib.auth.models import User +from django.http import JsonResponse + + +async def async_db_view(request): + names = [] + async for user in User.objects.all(): + names.append(user.username) + return JsonResponse({"names": names}) diff --git a/example/settings.py b/example/settings.py index d2bd57387..9c33dc78c 100644 --- a/example/settings.py +++ b/example/settings.py @@ -28,6 +28,7 @@ MIDDLEWARE = [ "debug_toolbar.middleware.DebugToolbarMiddleware", "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", diff --git a/example/templates/index.html b/example/templates/index.html index 382bfb0e9..b4ffd4cf6 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -23,9 +23,12 @@

Index of Tests

+ + diff --git a/requirements_dev.txt b/requirements_dev.txt index 8b24a8fbb..6baa55cec 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,6 +4,10 @@ Django sqlparse Jinja2 +# Django Async +daphne +whitenoise # To avoid dealing with static files + # Testing coverage[toml] From 16e02f52cd2e757ed221b5ec84b305e4a7f27af7 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Wed, 10 Jul 2024 07:38:45 -0500 Subject: [PATCH 14/25] Rework the alerts panel to be compatible with serialization. The alerts panel may eventually have other types of alerts that don't depend on the response. Such as Django's check system. --- debug_toolbar/panels/alerts.py | 8 +++----- tests/panels/test_alerts.py | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/debug_toolbar/panels/alerts.py b/debug_toolbar/panels/alerts.py index 51334820d..530e384e6 100644 --- a/debug_toolbar/panels/alerts.py +++ b/debug_toolbar/panels/alerts.py @@ -139,11 +139,9 @@ def check_invalid_file_form_configuration(self, html_content): return self.alerts def generate_stats(self, request, response): - if not is_processable_html_response(response): - return - - html_content = response.content.decode(response.charset) - self.check_invalid_file_form_configuration(html_content) + if is_processable_html_response(response): + html_content = response.content.decode(response.charset) + self.check_invalid_file_form_configuration(html_content) # Further alert checks can go here diff --git a/tests/panels/test_alerts.py b/tests/panels/test_alerts.py index 5c926f275..40ad8cf67 100644 --- a/tests/panels/test_alerts.py +++ b/tests/panels/test_alerts.py @@ -109,4 +109,4 @@ def _render(): response = StreamingHttpResponse(_render()) self.panel.generate_stats(self.request, response) - self.assertEqual(self.panel.get_stats(), {}) + self.assertEqual(self.panel.get_stats(), {"alerts": []}) From 3e4c484f5f4ef53d602f6cea07bf5387dd48acac Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Wed, 10 Jul 2024 08:44:45 -0500 Subject: [PATCH 15/25] Make template panel serializable. The stats must be stored as JSON, otherwise it'll be converted to a string. --- debug_toolbar/panels/templates/panel.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/debug_toolbar/panels/templates/panel.py b/debug_toolbar/panels/templates/panel.py index 7af449931..f35e6aa7b 100644 --- a/debug_toolbar/panels/templates/panel.py +++ b/debug_toolbar/panels/templates/panel.py @@ -112,7 +112,7 @@ def title(self): def nav_subtitle(self): templates = self.get_stats()["templates"] if templates: - return self.templates[0]["template"].name + return templates[0]["template"]["name"] return "" template = "debug_toolbar/panels/templates.html" @@ -196,7 +196,11 @@ def generate_stats(self, request, response): else: template.origin_name = _("No origin") template.origin_hash = "" - info["template"] = template + info["template"] = { + "name": template.name, + "origin_name": template.origin_name, + "origin_hash": template.origin_hash, + } # Clean up context for better readability if self.toolbar.config["SHOW_TEMPLATE_CONTEXT"]: if "context_list" not in template_data: From f4ff5f4fd97b819396effaa161bbf710db3e4984 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Wed, 10 Jul 2024 20:32:10 -0500 Subject: [PATCH 16/25] Avoid caching the config settings. This causes problems with tests and changing the settings via override_settings. Since we're using the lru_cache decorator on get_config, there's very little benefit to caching within the store too. --- debug_toolbar/store.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/debug_toolbar/store.py b/debug_toolbar/store.py index 27147645f..0bbdbaced 100644 --- a/debug_toolbar/store.py +++ b/debug_toolbar/store.py @@ -34,8 +34,6 @@ def deserialize(data: str) -> Any: class BaseStore: - _config = dt_settings.get_config().copy() - @classmethod def request_ids(cls) -> Iterable: """The stored request ids""" @@ -94,7 +92,9 @@ def set(cls, request_id: str): """Set a request_id in the request store""" if request_id not in cls._request_ids: cls._request_ids.append(request_id) - for _ in range(len(cls._request_ids) - cls._config["RESULTS_CACHE_SIZE"]): + for _ in range( + len(cls._request_ids) - dt_settings.get_config()["RESULTS_CACHE_SIZE"] + ): removed_id = cls._request_ids.popleft() cls._request_store.pop(removed_id, None) From d3730a66471d887c827f73169606531e30b35d81 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Wed, 10 Jul 2024 20:33:48 -0500 Subject: [PATCH 17/25] Fix tests for serializable changes with selenium. The majority were difficulities with caching and settings. --- tests/panels/test_history.py | 21 ++++++++++++--------- tests/test_integration.py | 12 +++++++----- tests/test_store.py | 15 ++++++--------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/tests/panels/test_history.py b/tests/panels/test_history.py index e548ec7ac..29e062da0 100644 --- a/tests/panels/test_history.py +++ b/tests/panels/test_history.py @@ -8,7 +8,6 @@ from debug_toolbar.store import get_store from debug_toolbar.toolbar import DebugToolbar -from .. import settings as test_settings from ..base import BaseTestCase, IntegrationTestCase rf = RequestFactory() @@ -110,14 +109,17 @@ def test_history_headers(self): request_id = list(get_store().request_ids())[0] self.assertEqual(response.headers["djdt-request-id"], request_id) - @override_settings( - DEBUG_TOOLBAR_CONFIG={"OBSERVE_REQUEST_CALLBACK": lambda request: False} - ) def test_history_headers_unobserved(self): """Validate the headers aren't injected from the history panel.""" + with self.settings( + DEBUG_TOOLBAR_CONFIG={"OBSERVE_REQUEST_CALLBACK": lambda request: False} + ): + DebugToolbar.get_observe_request.cache_clear() + response = self.client.get("/json_view/") + self.assertNotIn("djdt-request-id", response.headers) + # Clear it again to avoid conflicting with another test + # Specifically, DebugToolbarLiveTestCase.test_ajax_refresh DebugToolbar.get_observe_request.cache_clear() - response = self.client.get("/json_view/") - self.assertNotIn("djdt-request-id", response.headers) def test_history_sidebar(self): """Validate the history sidebar view.""" @@ -145,7 +147,9 @@ def test_history_sidebar_includes_history(self): panel_keys, ) - @override_settings(DEBUG_TOOLBAR_CONFIG={"RENDER_PANELS": False}) + @override_settings( + DEBUG_TOOLBAR_CONFIG={"RENDER_PANELS": False, "RESULTS_CACHE_SIZE": 1} + ) def test_history_sidebar_expired_request_id(self): """Validate the history sidebar view.""" self.client.get("/json_view/") @@ -158,8 +162,7 @@ def test_history_sidebar_expired_request_id(self): self.PANEL_KEYS, ) # Make enough requests to unset the original - for _i in range(test_settings.DEBUG_TOOLBAR_CONFIG["RESULTS_CACHE_SIZE"]): - self.client.get("/json_view/") + self.client.get("/json_view/") # Querying old request_id should return in empty response data = {"request_id": request_id, "exclude_history": True} diff --git a/tests/test_integration.py b/tests/test_integration.py index 5ea8870d4..787c8ed0f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,6 +1,5 @@ import os import re -import time import unittest from unittest.mock import patch @@ -648,7 +647,7 @@ def test_basic_jinja(self): # This should be 2 templates rendered, including base.html See # JinjaTemplateTestCase.test_django_jinja2_parent_template_instrumented self.assertIn("Templates (1 rendered)", template_panel.text) - self.assertIn("base.html", template_panel.text) + self.assertIn("basic.jinja", template_panel.text) @override_settings( DEBUG_TOOLBAR_CONFIG={ @@ -831,10 +830,13 @@ def test_ajax_refresh(self): make_ajax = self.selenium.find_element(By.ID, "click_for_ajax") make_ajax.click() # Need to wait until the ajax request is over and json_view is displayed on the toolbar - time.sleep(2) - history_panel = self.wait.until( - lambda selenium: self.selenium.find_element(By.ID, "djdt-HistoryPanel") + self.wait.until( + lambda selenium: self.selenium.find_element( + By.CSS_SELECTOR, "#djdt-HistoryPanel a.HistoryPanel small" + ).text + == "/json_view/" ) + history_panel = self.selenium.find_element(By.ID, "djdt-HistoryPanel") self.assertNotIn("/ajax/", history_panel.text) self.assertIn("/json_view/", history_panel.text) diff --git a/tests/test_store.py b/tests/test_store.py index c51afde1e..41be4b1a7 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -64,15 +64,12 @@ def test_set(self): self.assertEqual(list(self.store.request_ids()), ["foo"]) def test_set_max_size(self): - existing = self.store._config["RESULTS_CACHE_SIZE"] - self.store._config["RESULTS_CACHE_SIZE"] = 1 - self.store.save_panel("foo", "foo.panel", "foo.value") - self.store.save_panel("bar", "bar.panel", {"a": 1}) - self.assertEqual(list(self.store.request_ids()), ["bar"]) - self.assertEqual(self.store.panel("foo", "foo.panel"), {}) - self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1}) - # Restore the existing config setting since this config is shared. - self.store._config["RESULTS_CACHE_SIZE"] = existing + with self.settings(DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 1}): + self.store.save_panel("foo", "foo.panel", "foo.value") + self.store.save_panel("bar", "bar.panel", {"a": 1}) + self.assertEqual(list(self.store.request_ids()), ["bar"]) + self.assertEqual(self.store.panel("foo", "foo.panel"), {}) + self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1}) def test_clear(self): self.store.save_panel("bar", "bar.panel", {"a": 1}) From c66026949f96f47f57f21707b8e000bb42f9a142 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Wed, 10 Jul 2024 20:40:09 -0500 Subject: [PATCH 18/25] Comment out the async button because it breaks the wsgi app. --- example/templates/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example/templates/index.html b/example/templates/index.html index ac53ab37b..a10c2b5ac 100644 --- a/example/templates/index.html +++ b/example/templates/index.html @@ -25,7 +25,9 @@

Index of Tests

+ {% comment %} + {% endcomment %}
{{ store_context.form }} - +
- {{ store_context.toolbar.stats.HistoryPanel.time|escape }} + {{ history_context.history_stats.time|escape }} -

{{ store_context.toolbar.stats.HistoryPanel.request_method|escape }}

+

{{ history_context.history_stats.request_method|escape }}

-

{{ store_context.toolbar.stats.HistoryPanel.request_url|truncatechars:100|escape }}

+

{{ history_context.history_stats.request_url|truncatechars:100|escape }}

@@ -24,7 +24,7 @@
{{ key|pprint }} {{ value|pprint }} -

{{ store_context.toolbar.stats.HistoryPanel.status_code|escape }}

+

{{ history_context.history_stats.status_code|escape }}

- {{ store_context.form }} + {{ history_context.form }}