Skip to content

Commit c70e89f

Browse files
authored
Merge pull request #1784 from tim-schilling/store
Add the Store API and initial documentation.
2 parents e535c9d + 99f4473 commit c70e89f

File tree

5 files changed

+247
-1
lines changed

5 files changed

+247
-1
lines changed

debug_toolbar/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"SQL_WARNING_THRESHOLD": 500, # milliseconds
4343
"OBSERVE_REQUEST_CALLBACK": "debug_toolbar.toolbar.observe_request",
4444
"TOOLBAR_LANGUAGE": None,
45+
"TOOLBAR_STORE_CLASS": "debug_toolbar.store.MemoryStore",
4546
}
4647

4748

debug_toolbar/store.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import contextlib
2+
import json
3+
from collections import defaultdict, deque
4+
from typing import Any, Dict, Iterable
5+
6+
from django.core.serializers.json import DjangoJSONEncoder
7+
from django.utils.module_loading import import_string
8+
9+
from debug_toolbar import settings as dt_settings
10+
11+
12+
def serialize(data: Any) -> str:
13+
# If this starts throwing an exceptions, consider
14+
# Subclassing DjangoJSONEncoder and using force_str to
15+
# make it JSON serializable.
16+
return json.dumps(data, cls=DjangoJSONEncoder)
17+
18+
19+
def deserialize(data: str) -> Any:
20+
return json.loads(data)
21+
22+
23+
class BaseStore:
24+
_config = dt_settings.get_config().copy()
25+
26+
@classmethod
27+
def request_ids(cls) -> Iterable:
28+
"""The stored request ids"""
29+
raise NotImplementedError
30+
31+
@classmethod
32+
def exists(cls, request_id: str) -> bool:
33+
"""Does the given request_id exist in the store"""
34+
raise NotImplementedError
35+
36+
@classmethod
37+
def set(cls, request_id: str):
38+
"""Set a request_id in the store"""
39+
raise NotImplementedError
40+
41+
@classmethod
42+
def clear(cls):
43+
"""Remove all requests from the request store"""
44+
raise NotImplementedError
45+
46+
@classmethod
47+
def delete(cls, request_id: str):
48+
"""Delete the store for the given request_id"""
49+
raise NotImplementedError
50+
51+
@classmethod
52+
def save_panel(cls, request_id: str, panel_id: str, data: Any = None):
53+
"""Save the panel data for the given request_id"""
54+
raise NotImplementedError
55+
56+
@classmethod
57+
def panel(cls, request_id: str, panel_id: str) -> Any:
58+
"""Fetch the panel data for the given request_id"""
59+
raise NotImplementedError
60+
61+
62+
class MemoryStore(BaseStore):
63+
# ids is the collection of storage ids that have been used.
64+
# Use a dequeue to support O(1) appends and pops
65+
# from either direction.
66+
_request_ids: deque = deque()
67+
_request_store: Dict[str, Dict] = defaultdict(dict)
68+
69+
@classmethod
70+
def request_ids(cls) -> Iterable:
71+
"""The stored request ids"""
72+
return cls._request_ids
73+
74+
@classmethod
75+
def exists(cls, request_id: str) -> bool:
76+
"""Does the given request_id exist in the request store"""
77+
return request_id in cls._request_ids
78+
79+
@classmethod
80+
def set(cls, request_id: str):
81+
"""Set a request_id in the request store"""
82+
if request_id not in cls._request_ids:
83+
cls._request_ids.append(request_id)
84+
for _ in range(len(cls._request_ids) - cls._config["RESULTS_CACHE_SIZE"]):
85+
removed_id = cls._request_ids.popleft()
86+
cls._request_store.pop(removed_id, None)
87+
88+
@classmethod
89+
def clear(cls):
90+
"""Remove all requests from the request store"""
91+
cls._request_ids.clear()
92+
cls._request_store.clear()
93+
94+
@classmethod
95+
def delete(cls, request_id: str):
96+
"""Delete the stored request for the given request_id"""
97+
cls._request_store.pop(request_id, None)
98+
# Suppress when request_id doesn't exist in the collection of ids.
99+
with contextlib.suppress(ValueError):
100+
cls._request_ids.remove(request_id)
101+
102+
@classmethod
103+
def save_panel(cls, request_id: str, panel_id: str, data: Any = None):
104+
"""Save the panel data for the given request_id"""
105+
cls.set(request_id)
106+
cls._request_store[request_id][panel_id] = serialize(data)
107+
108+
@classmethod
109+
def panel(cls, request_id: str, panel_id: str) -> Any:
110+
"""Fetch the panel data for the given request_id"""
111+
try:
112+
data = cls._request_store[request_id][panel_id]
113+
except KeyError:
114+
return {}
115+
else:
116+
return deserialize(data)
117+
118+
119+
def get_store():
120+
return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"])

docs/changes.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ Pending
99
<https://beta.ruff.rs/>`__.
1010
* Converted cookie keys to lowercase. Fixed the ``samesite`` argument to
1111
``djdt.cookie.set``.
12+
* Defines the ``BaseStore`` interface for request storage mechanisms.
13+
* Added the setting ``TOOLBAR_STORE_CLASS`` to configure the request
14+
storage mechanism. Defaults to ``debug_toolbar.store.MemoryStore``.
1215

1316
4.1.0 (2023-05-15)
1417
------------------
15-
1618
* Improved SQL statement formatting performance. Additionally, fixed the
1719
indentation of ``CASE`` statements and stopped simplifying ``.count()``
1820
queries.

docs/configuration.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ Toolbar options
150150
the request doesn't originate from the toolbar itself, EG that
151151
``is_toolbar_request`` is false for a given request.
152152

153+
.. _TOOLBAR_STORE_CLASS:
154+
155+
* ``TOOLBAR_STORE_CLASS``
156+
157+
Default: ``"debug_toolbar.store.MemoryStore"``
158+
159+
The path to the class to be used for storing the toolbar's data per request.
160+
161+
153162
.. _TOOLBAR_LANGUAGE:
154163

155164
* ``TOOLBAR_LANGUAGE``

tests/test_store.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from django.test import TestCase
2+
from django.test.utils import override_settings
3+
4+
from debug_toolbar import store
5+
6+
7+
class SerializationTestCase(TestCase):
8+
def test_serialize(self):
9+
self.assertEqual(
10+
store.serialize({"hello": {"foo": "bar"}}),
11+
'{"hello": {"foo": "bar"}}',
12+
)
13+
14+
def test_deserialize(self):
15+
self.assertEqual(
16+
store.deserialize('{"hello": {"foo": "bar"}}'),
17+
{"hello": {"foo": "bar"}},
18+
)
19+
20+
21+
class BaseStoreTestCase(TestCase):
22+
def test_methods_are_not_implemented(self):
23+
# Find all the non-private and dunder class methods
24+
methods = [
25+
member for member in vars(store.BaseStore) if not member.startswith("_")
26+
]
27+
self.assertEqual(len(methods), 7)
28+
with self.assertRaises(NotImplementedError):
29+
store.BaseStore.request_ids()
30+
with self.assertRaises(NotImplementedError):
31+
store.BaseStore.exists("")
32+
with self.assertRaises(NotImplementedError):
33+
store.BaseStore.set("")
34+
with self.assertRaises(NotImplementedError):
35+
store.BaseStore.clear()
36+
with self.assertRaises(NotImplementedError):
37+
store.BaseStore.delete("")
38+
with self.assertRaises(NotImplementedError):
39+
store.BaseStore.save_panel("", "", None)
40+
with self.assertRaises(NotImplementedError):
41+
store.BaseStore.panel("", "")
42+
43+
44+
class MemoryStoreTestCase(TestCase):
45+
@classmethod
46+
def setUpTestData(cls) -> None:
47+
cls.store = store.MemoryStore
48+
49+
def tearDown(self) -> None:
50+
self.store.clear()
51+
52+
def test_ids(self):
53+
self.store.set("foo")
54+
self.store.set("bar")
55+
self.assertEqual(list(self.store.request_ids()), ["foo", "bar"])
56+
57+
def test_exists(self):
58+
self.assertFalse(self.store.exists("missing"))
59+
self.store.set("exists")
60+
self.assertTrue(self.store.exists("exists"))
61+
62+
def test_set(self):
63+
self.store.set("foo")
64+
self.assertEqual(list(self.store.request_ids()), ["foo"])
65+
66+
def test_set_max_size(self):
67+
existing = self.store._config["RESULTS_CACHE_SIZE"]
68+
self.store._config["RESULTS_CACHE_SIZE"] = 1
69+
self.store.save_panel("foo", "foo.panel", "foo.value")
70+
self.store.save_panel("bar", "bar.panel", {"a": 1})
71+
self.assertEqual(list(self.store.request_ids()), ["bar"])
72+
self.assertEqual(self.store.panel("foo", "foo.panel"), {})
73+
self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1})
74+
# Restore the existing config setting since this config is shared.
75+
self.store._config["RESULTS_CACHE_SIZE"] = existing
76+
77+
def test_clear(self):
78+
self.store.save_panel("bar", "bar.panel", {"a": 1})
79+
self.store.clear()
80+
self.assertEqual(list(self.store.request_ids()), [])
81+
self.assertEqual(self.store.panel("bar", "bar.panel"), {})
82+
83+
def test_delete(self):
84+
self.store.save_panel("bar", "bar.panel", {"a": 1})
85+
self.store.delete("bar")
86+
self.assertEqual(list(self.store.request_ids()), [])
87+
self.assertEqual(self.store.panel("bar", "bar.panel"), {})
88+
# Make sure it doesn't error
89+
self.store.delete("bar")
90+
91+
def test_save_panel(self):
92+
self.store.save_panel("bar", "bar.panel", {"a": 1})
93+
self.assertEqual(list(self.store.request_ids()), ["bar"])
94+
self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1})
95+
96+
def test_panel(self):
97+
self.assertEqual(self.store.panel("missing", "missing"), {})
98+
self.store.save_panel("bar", "bar.panel", {"a": 1})
99+
self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1})
100+
101+
102+
class StubStore(store.BaseStore):
103+
pass
104+
105+
106+
class GetStoreTestCase(TestCase):
107+
def test_get_store(self):
108+
self.assertIs(store.get_store(), store.MemoryStore)
109+
110+
@override_settings(
111+
DEBUG_TOOLBAR_CONFIG={"TOOLBAR_STORE_CLASS": "tests.test_store.StubStore"}
112+
)
113+
def test_get_store_with_setting(self):
114+
self.assertIs(store.get_store(), StubStore)

0 commit comments

Comments
 (0)