Skip to content

Commit dd394db

Browse files
committed
Add the Store API and initial documentation.
1 parent e535c9d commit dd394db

File tree

5 files changed

+266
-1
lines changed

5 files changed

+266
-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: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import json
2+
from collections import defaultdict, deque
3+
from typing import Any, Dict, Iterable
4+
5+
from django.core.serializers.json import DjangoJSONEncoder
6+
from django.utils.encoding import force_str
7+
from django.utils.module_loading import import_string
8+
9+
from debug_toolbar import settings as dt_settings
10+
11+
12+
class DebugToolbarJSONEncoder(DjangoJSONEncoder):
13+
def default(self, o: Any) -> Any:
14+
try:
15+
return super().default(o)
16+
except TypeError:
17+
return force_str(o)
18+
19+
20+
def serialize(data: Any) -> str:
21+
return json.dumps(data, cls=DebugToolbarJSONEncoder)
22+
23+
24+
def deserialize(data: str) -> Any:
25+
return json.loads(data)
26+
27+
28+
class BaseStore:
29+
_config = dt_settings.get_config().copy()
30+
31+
@classmethod
32+
def ids(cls) -> Iterable:
33+
"""The stored ids"""
34+
raise NotImplementedError
35+
36+
@classmethod
37+
def exists(cls, request_id: str) -> bool:
38+
"""Does the given request_id exist in the store"""
39+
raise NotImplementedError
40+
41+
@classmethod
42+
def set(cls, request_id: str):
43+
"""Set a request_id in the store"""
44+
raise NotImplementedError
45+
46+
@classmethod
47+
def clear(cls):
48+
"""Remove all requests from the request store"""
49+
raise NotImplementedError
50+
51+
@classmethod
52+
def delete(cls, request_id: str):
53+
"""Delete the store for the given request_id"""
54+
raise NotImplementedError
55+
56+
@classmethod
57+
def save_panel(cls, request_id: str, panel_id: str, data: Any = None):
58+
"""Save the panel data for the given request_id"""
59+
raise NotImplementedError
60+
61+
@classmethod
62+
def panel(cls, request_id: str, panel_id: str) -> Any:
63+
"""Fetch the panel data for the given request_id"""
64+
raise NotImplementedError
65+
66+
67+
class MemoryStore(BaseStore):
68+
# ids is the collection of storage ids that have been used.
69+
# Use a dequeue to support O(1) appends and pops
70+
# from either direction.
71+
_ids: deque = deque()
72+
_request_store: Dict[str, Dict] = defaultdict(dict)
73+
74+
@classmethod
75+
def ids(cls) -> Iterable:
76+
"""The stored ids"""
77+
return cls._ids
78+
79+
@classmethod
80+
def exists(cls, request_id: str) -> bool:
81+
"""Does the given request_id exist in the request store"""
82+
return request_id in cls._ids
83+
84+
@classmethod
85+
def set(cls, request_id: str):
86+
"""Set a request_id in the request store"""
87+
if request_id not in cls._ids:
88+
cls._ids.append(request_id)
89+
for _ in range(len(cls._ids) - cls._config["RESULTS_CACHE_SIZE"]):
90+
removed_id = cls._ids.popleft()
91+
cls._request_store.pop(removed_id, None)
92+
93+
@classmethod
94+
def clear(cls):
95+
"""Remove all requests from the request store"""
96+
cls._ids.clear()
97+
cls._request_store.clear()
98+
99+
@classmethod
100+
def delete(cls, request_id: str):
101+
"""Delete the stored request for the given request_id"""
102+
cls._request_store.pop(request_id, None)
103+
try:
104+
cls._ids.remove(request_id)
105+
except ValueError:
106+
# The request_id doesn't exist in the collection of ids.
107+
pass
108+
109+
@classmethod
110+
def save_panel(cls, request_id: str, panel_id: str, data: Any = None):
111+
"""Save the panel data for the given request_id"""
112+
cls.set(request_id)
113+
cls._request_store[request_id][panel_id] = serialize(data)
114+
115+
@classmethod
116+
def panel(cls, request_id: str, panel_id: str) -> Any:
117+
"""Fetch the panel data for the given request_id"""
118+
try:
119+
data = cls._request_store[request_id][panel_id]
120+
except KeyError:
121+
return {}
122+
else:
123+
return deserialize(data)
124+
125+
126+
def get_store():
127+
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 config 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: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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_serialize_force_str(self):
15+
class Foo:
16+
spam = "bar"
17+
18+
def __str__(self):
19+
return f"Foo spam={self.spam}"
20+
21+
self.assertEqual(
22+
store.serialize({"hello": Foo()}),
23+
'{"hello": "Foo spam=bar"}',
24+
)
25+
26+
def test_deserialize(self):
27+
self.assertEqual(
28+
store.deserialize('{"hello": {"foo": "bar"}}'),
29+
{"hello": {"foo": "bar"}},
30+
)
31+
32+
33+
class BaseStoreTestCase(TestCase):
34+
def test_methods_are_not_implemented(self):
35+
# Find all the non-private and dunder class methods
36+
methods = [
37+
member for member in vars(store.BaseStore) if not member.startswith("_")
38+
]
39+
self.assertEqual(len(methods), 7)
40+
with self.assertRaises(NotImplementedError):
41+
store.BaseStore.ids()
42+
with self.assertRaises(NotImplementedError):
43+
store.BaseStore.exists("")
44+
with self.assertRaises(NotImplementedError):
45+
store.BaseStore.set("")
46+
with self.assertRaises(NotImplementedError):
47+
store.BaseStore.clear()
48+
with self.assertRaises(NotImplementedError):
49+
store.BaseStore.delete("")
50+
with self.assertRaises(NotImplementedError):
51+
store.BaseStore.save_panel("", "", None)
52+
with self.assertRaises(NotImplementedError):
53+
store.BaseStore.panel("", "")
54+
55+
56+
class MemoryStoreTestCase(TestCase):
57+
@classmethod
58+
def setUpTestData(cls) -> None:
59+
cls.store = store.MemoryStore
60+
61+
def tearDown(self) -> None:
62+
self.store.clear()
63+
64+
def test_ids(self):
65+
self.store.set("foo")
66+
self.store.set("bar")
67+
self.assertEqual(list(self.store.ids()), ["foo", "bar"])
68+
69+
def test_exists(self):
70+
self.assertFalse(self.store.exists("missing"))
71+
self.store.set("exists")
72+
self.assertTrue(self.store.exists("exists"))
73+
74+
def test_set(self):
75+
self.store.set("foo")
76+
self.assertEqual(list(self.store.ids()), ["foo"])
77+
78+
def test_set_max_size(self):
79+
existing = self.store._config["RESULTS_CACHE_SIZE"]
80+
self.store._config["RESULTS_CACHE_SIZE"] = 1
81+
self.store.save_panel("foo", "foo.panel", "foo.value")
82+
self.store.save_panel("bar", "bar.panel", {"a": 1})
83+
self.assertEqual(list(self.store.ids()), ["bar"])
84+
self.assertEqual(self.store.panel("foo", "foo.panel"), {})
85+
self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1})
86+
# Restore the existing config setting since this config is shared.
87+
self.store._config["RESULTS_CACHE_SIZE"] = existing
88+
89+
def test_clear(self):
90+
self.store.save_panel("bar", "bar.panel", {"a": 1})
91+
self.store.clear()
92+
self.assertEqual(list(self.store.ids()), [])
93+
self.assertEqual(self.store.panel("bar", "bar.panel"), {})
94+
95+
def test_delete(self):
96+
self.store.save_panel("bar", "bar.panel", {"a": 1})
97+
self.store.delete("bar")
98+
self.assertEqual(list(self.store.ids()), [])
99+
self.assertEqual(self.store.panel("bar", "bar.panel"), {})
100+
# Make sure it doesn't error
101+
self.store.delete("bar")
102+
103+
def test_save_panel(self):
104+
self.store.save_panel("bar", "bar.panel", {"a": 1})
105+
self.assertEqual(list(self.store.ids()), ["bar"])
106+
self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1})
107+
108+
def test_panel(self):
109+
self.assertEqual(self.store.panel("missing", "missing"), {})
110+
self.store.save_panel("bar", "bar.panel", {"a": 1})
111+
self.assertEqual(self.store.panel("bar", "bar.panel"), {"a": 1})
112+
113+
114+
class StubStore(store.BaseStore):
115+
pass
116+
117+
118+
class GetStoreTestCase(TestCase):
119+
def test_get_store(self):
120+
self.assertIs(store.get_store(), store.MemoryStore)
121+
122+
@override_settings(
123+
DEBUG_TOOLBAR_CONFIG={"TOOLBAR_STORE_CLASS": "tests.test_store.StubStore"}
124+
)
125+
def test_get_store_with_setting(self):
126+
self.assertIs(store.get_store(), StubStore)

0 commit comments

Comments
 (0)