Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bb88c05
Add the Store API and initial documentation.
tim-schilling May 17, 2023
19b5695
Remove config from docs as sphinx says it's misspelled.
tim-schilling May 17, 2023
97fcda7
Switch to Store.request_ids and remove serialization force_str.
tim-schilling Jun 17, 2023
487dfb3
Log serialization warning when a panel errors. (#1810)
tim-schilling Aug 21, 2023
e7cf575
Ignore common venv folder.
tim-schilling Aug 20, 2023
c4201fa
Rename store_id variants to request_id
Aug 20, 2023
bbbbb34
Support serializable panels. This is a WIP and needs clean-up.
Aug 20, 2023
e2f695b
Support serializable sql panel
tim-schilling Aug 21, 2023
14a5e0c
Make Panel.panel_id a classmember.
tim-schilling Aug 21, 2023
a31115f
Force everything to a string if it can't be serialized.
tim-schilling Aug 29, 2023
71edcf5
Support serialization of FunctionCall
tim-schilling Aug 29, 2023
c03f08f
Update all panels to use data from get_stats on render
tim-schilling Sep 5, 2023
47bdabe
Extend example app to have an async version.
tim-schilling Sep 5, 2023
dd53424
Merge branch 'main' into serialize-panels
tim-schilling Jul 10, 2024
16e02f5
Rework the alerts panel to be compatible with serialization.
tim-schilling Jul 10, 2024
3e4c484
Make template panel serializable.
tim-schilling Jul 10, 2024
f4ff5f4
Avoid caching the config settings.
Jul 11, 2024
d3730a6
Fix tests for serializable changes with selenium.
Jul 11, 2024
c660269
Comment out the async button because it breaks the wsgi app.
Jul 11, 2024
8402c4d
Hack: Sleep before checking to see if the history panel auto updated.
Jul 11, 2024
e1c0755
Improve clarity of record_stats for serialization. (#1965)
tim-schilling Jul 19, 2024
5a21920
Merge branch 'main' into serializable
tim-schilling Feb 9, 2025
89c4786
Added check for pytest as test runner for IS_RUNNING_TESTS.
tim-schilling May 14, 2025
73eea66
Fixes #2073 -- Added DatabaseStore for persistent debug data storage.…
dr-rompecabezas May 14, 2025
153c22b
Merge branch 'main' into serializable
tim-schilling May 15, 2025
f8bfb0d
Move serializable changes into the main change log.
tim-schilling May 15, 2025
bf77c70
Updated replaceToolbarState to use request id.
tim-schilling May 15, 2025
0cbf478
Merge branch 'main' into serializable
matthiask May 23, 2025
7a638c7
Removed unnecessary SQLPanel.record_stats
tim-schilling Jun 3, 2025
2fafbb4
Merge branch 'main' into serializable
tim-schilling Jul 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add the Store API and initial documentation.
  • Loading branch information
tim-schilling authored and Tim Schilling committed Aug 20, 2023
commit bb88c0526c772a55e40bdf074caeb3b3d5124056
1 change: 1 addition & 0 deletions debug_toolbar/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down
127 changes: 127 additions & 0 deletions debug_toolbar/store.py
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using _request_ids = deque(maxlen=dt_settings.get_config()["RESULTS_CACHE_SIZE"]) would keep this bounded without having to manually pop items off. You'd still have to maintain the contents of _request_store.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing things from _request_store is the problem unfortunately. We need the ids from _request_ids to remove the correct elements from _request_store. If we make _request_ids bounded, we lose the ability to identify which ids are being removed from the collection to remove the correct elements from the store.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, after adding to _request_ids you'd have to get the set of current ids, find the stale ids in the _request_store if any got popped off b/c of the bounding, then delete those.... which would be less efficient. What you have is optimized, I agree.


@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"])
9 changes: 8 additions & 1 deletion docs/changes.rst
Original file line number Diff line number Diff line change
@@ -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
-------

Expand All @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
126 changes: 126 additions & 0 deletions tests/test_store.py
Original file line number Diff line number Diff line change
@@ -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)