Skip to content

Commit

Permalink
panels(templates): avoid evaluating LazyObject (#1833)
Browse files Browse the repository at this point in the history
* panels(templates): postpone context processing

- this makes it show evaluated querysets which were used in the template
- removes completely context processing when SHOW_TEMPLATE_CONTEXT is
  disabled

* panels(templates): avoid evaluating LazyObject

LazyObject is typically used for something expensive to evaluate, so avoid evaluating it just for showing it in the debug toolbar.
  • Loading branch information
nijel authored Sep 26, 2023
1 parent 06c42d9 commit 3ceb965
Show file tree
Hide file tree
Showing 3 changed files with 95 additions and 56 deletions.
122 changes: 68 additions & 54 deletions debug_toolbar/panels/templates/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,58 +83,11 @@ def _store_template_info(self, sender, **kwargs):
if is_debug_toolbar_template:
return

context_list = []
for context_layer in context.dicts:
if hasattr(context_layer, "items") and context_layer:
# Check if the layer is in the cache.
pformatted = None
for key_values, _pformatted in self.pformat_layers:
if key_values == context_layer:
pformatted = _pformatted
break

if pformatted is None:
temp_layer = {}
for key, value in context_layer.items():
# Replace any request elements - they have a large
# Unicode representation and the request data is
# already made available from the Request panel.
if isinstance(value, http.HttpRequest):
temp_layer[key] = "<<request>>"
# Replace the debugging sql_queries element. The SQL
# data is already made available from the SQL panel.
elif key == "sql_queries" and isinstance(value, list):
temp_layer[key] = "<<sql_queries>>"
# Replace LANGUAGES, which is available in i18n context
# processor
elif key == "LANGUAGES" and isinstance(value, tuple):
temp_layer[key] = "<<languages>>"
# QuerySet would trigger the database: user can run the
# query from SQL Panel
elif isinstance(value, (QuerySet, RawQuerySet)):
temp_layer[key] = "<<{} of {}>>".format(
value.__class__.__name__.lower(),
value.model._meta.label,
)
else:
token = allow_sql.set(False) # noqa: FBT003
try:
saferepr(value) # this MAY trigger a db query
except SQLQueryTriggered:
temp_layer[key] = "<<triggers database query>>"
except UnicodeEncodeError:
temp_layer[key] = "<<Unicode encode error>>"
except Exception:
temp_layer[key] = "<<unhandled exception>>"
else:
temp_layer[key] = value
finally:
allow_sql.reset(token)
pformatted = pformat(temp_layer)
self.pformat_layers.append((context_layer, pformatted))
context_list.append(pformatted)

kwargs["context"] = context_list
kwargs["context"] = [
context_layer
for context_layer in context.dicts
if hasattr(context_layer, "items") and context_layer
]
kwargs["context_processors"] = getattr(context, "context_processors", None)
self.templates.append(kwargs)

Expand Down Expand Up @@ -167,6 +120,64 @@ def enable_instrumentation(self):
def disable_instrumentation(self):
template_rendered.disconnect(self._store_template_info)

def process_context_list(self, context_layers):
context_list = []
for context_layer in context_layers:
# Check if the layer is in the cache.
pformatted = None
for key_values, _pformatted in self.pformat_layers:
if key_values == context_layer:
pformatted = _pformatted
break

if pformatted is None:
temp_layer = {}
for key, value in context_layer.items():
# Do not force evaluating LazyObject
if hasattr(value, "_wrapped"):
# SimpleLazyObject has __repr__ which includes actual value
# if it has been already evaluated
temp_layer[key] = repr(value)
# Replace any request elements - they have a large
# Unicode representation and the request data is
# already made available from the Request panel.
elif isinstance(value, http.HttpRequest):
temp_layer[key] = "<<request>>"
# Replace the debugging sql_queries element. The SQL
# data is already made available from the SQL panel.
elif key == "sql_queries" and isinstance(value, list):
temp_layer[key] = "<<sql_queries>>"
# Replace LANGUAGES, which is available in i18n context
# processor
elif key == "LANGUAGES" and isinstance(value, tuple):
temp_layer[key] = "<<languages>>"
# QuerySet would trigger the database: user can run the
# query from SQL Panel
elif isinstance(value, (QuerySet, RawQuerySet)):
temp_layer[key] = "<<{} of {}>>".format(
value.__class__.__name__.lower(),
value.model._meta.label,
)
else:
token = allow_sql.set(False) # noqa: FBT003
try:
saferepr(value) # this MAY trigger a db query
except SQLQueryTriggered:
temp_layer[key] = "<<triggers database query>>"
except UnicodeEncodeError:
temp_layer[key] = "<<Unicode encode error>>"
except Exception:
temp_layer[key] = "<<unhandled exception>>"
else:
temp_layer[key] = value
finally:
allow_sql.reset(token)
pformatted = pformat(temp_layer)
self.pformat_layers.append((context_layer, pformatted))
context_list.append(pformatted)

return context_list

def generate_stats(self, request, response):
template_context = []
for template_data in self.templates:
Expand All @@ -182,8 +193,11 @@ def generate_stats(self, request, response):
info["template"] = template
# 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)
if "context_list" not in template_data:
template_data["context_list"] = self.process_context_list(
template_data.get("context", [])
)
info["context"] = "\n".join(template_data["context_list"])
template_context.append(info)

# Fetch context_processors/template_dirs from any template
Expand Down
4 changes: 4 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Pending

* Removed outdated third-party panels from the list.
* Avoided the unnecessary work of recursively quoting SQL parameters.
* Postponed context process in templates panel to include lazy evaluated
content.
* Fixed template panel to avoid evaluating ``LazyObject`` when not already
evaluated.

4.2.0 (2023-08-10)
------------------
Expand Down
25 changes: 23 additions & 2 deletions tests/panels/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.contrib.auth.models import User
from django.template import Context, RequestContext, Template
from django.test import override_settings
from django.utils.functional import SimpleLazyObject

from ..base import BaseTestCase, IntegrationTestCase
from ..forms import TemplateReprForm
Expand All @@ -21,6 +22,7 @@ def tearDown(self):
super().tearDown()

def test_queryset_hook(self):
response = self.panel.process_request(self.request)
t = Template("No context variables here!")
c = Context(
{
Expand All @@ -29,12 +31,13 @@ def test_queryset_hook(self):
}
)
t.render(c)
self.panel.generate_stats(self.request, response)

# ensure the query was NOT logged
self.assertEqual(len(self.sql_panel._queries), 0)

self.assertEqual(
self.panel.templates[0]["context"],
self.panel.templates[0]["context_list"],
[
"{'False': False, 'None': None, 'True': True}",
"{'deep_queryset': '<<triggers database query>>',\n"
Expand Down Expand Up @@ -99,16 +102,34 @@ def test_disabled(self):
self.assertFalse(self.panel.enabled)

def test_empty_context(self):
response = self.panel.process_request(self.request)
t = Template("")
c = Context({})
t.render(c)
self.panel.generate_stats(self.request, response)

# Includes the builtin context but not the empty one.
self.assertEqual(
self.panel.templates[0]["context"],
self.panel.templates[0]["context_list"],
["{'False': False, 'None': None, 'True': True}"],
)

def test_lazyobject(self):
response = self.panel.process_request(self.request)
t = Template("")
c = Context({"lazy": SimpleLazyObject(lambda: "lazy_value")})
t.render(c)
self.panel.generate_stats(self.request, response)
self.assertNotIn("lazy_value", self.panel.content)

def test_lazyobject_eval(self):
response = self.panel.process_request(self.request)
t = Template("{{lazy}}")
c = Context({"lazy": SimpleLazyObject(lambda: "lazy_value")})
self.assertEqual(t.render(c), "lazy_value")
self.panel.generate_stats(self.request, response)
self.assertIn("lazy_value", self.panel.content)


@override_settings(
DEBUG=True, DEBUG_TOOLBAR_PANELS=["debug_toolbar.panels.templates.TemplatesPanel"]
Expand Down

0 comments on commit 3ceb965

Please sign in to comment.